This commit is contained in:
aaron 2026-05-24 13:58:09 +08:00
parent d5645b52d7
commit b4df926e93
4 changed files with 648 additions and 58 deletions

View File

@ -7,6 +7,7 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.db.schema import init_db from app.db.schema import init_db
@ -45,6 +46,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="AlphaX Agent", lifespan=lifespan) app = FastAPI(title="AlphaX Agent", lifespan=lifespan)
templates = Jinja2Templates(directory=str(REPO_ROOT / "static")) templates = Jinja2Templates(directory=str(REPO_ROOT / "static"))
app.mount("/static", StaticFiles(directory=str(REPO_ROOT / "static")), name="static")
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(chat_router) app.include_router(chat_router)

View File

@ -216,7 +216,8 @@
.kline-int-btn { border: 1px solid var(--hairline); background: var(--canvas); color: var(--stone); padding: 3px 8px; border-radius: 5px; font-size: 10px; font-weight: 700; cursor: pointer; transition: .15s; } .kline-int-btn { border: 1px solid var(--hairline); background: var(--canvas); color: var(--stone); padding: 3px 8px; border-radius: 5px; font-size: 10px; font-weight: 700; cursor: pointer; transition: .15s; }
.kline-int-btn:hover { border-color: var(--hairline-strong); color: var(--slate); } .kline-int-btn:hover { border-color: var(--hairline-strong); color: var(--slate); }
.kline-int-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); } .kline-int-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); }
.kline-container svg { display:block; margin:0 auto; } .kline-container { position: relative; width: 100%; height: 200px; }
.kline-container .ax-chart { min-height: 200px; }
.chart-loading { color: var(--stone); font-size: 12px; text-align: center; padding: 16px; } .chart-loading { color: var(--stone); font-size: 12px; text-align: center; padding: 16px; }
/* ===== ENTRY PLAN ===== */ /* ===== ENTRY PLAN ===== */
@ -339,6 +340,7 @@
{% endblock %} {% endblock %}
{% block extra_script %} {% block extra_script %}
<script src="/static/chart_widgets.js"></script>
<script> <script>
function drawPin(){ return null; } function drawPin(){ return null; }
var curTab = 'live'; var curTab = 'live';
@ -841,7 +843,22 @@ function loadOneKline(container) {
var slTime = container.dataset.slTime||''; var slTime = container.dataset.slTime||'';
var actionStatus = container.dataset.actionStatus || container.dataset.status || ''; var actionStatus = container.dataset.actionStatus || container.dataset.status || '';
var refPrice = Number(container.dataset.refPrice || entryPrice || (candles[0] && candles[0].close) || 0); var refPrice = Number(container.dataset.refPrice || entryPrice || (candles[0] && candles[0].close) || 0);
container.innerHTML = renderKlineChart(symbol, candles, entryPrice, stopLoss, tp1, recTime, tp1Time, slTime, actionStatus, refPrice); if (!window.AlphaXCharts || !window.AlphaXCharts.renderKline) {
throw new Error('AlphaXCharts.renderKline is not available');
}
container.innerHTML = '';
window.AlphaXCharts.renderKline(container, {
symbol: symbol,
candles: candles,
entryPrice: entryPrice,
stopLoss: stopLoss,
tp1: tp1,
recTime: recTime,
tp1Time: tp1Time,
slTime: slTime,
actionStatus: actionStatus,
refPrice: refPrice
});
container.classList.remove('loading'); container.classList.remove('loading');
container.dataset.klineLoaded = '1'; container.dataset.klineLoaded = '1';
delete container.dataset.klineLoading; delete container.dataset.klineLoading;
@ -902,61 +919,6 @@ function switchKlineInterval(btn) {
loadOneKline(container); loadOneKline(container);
} }
function renderKlineChart(symbol, candles, entryPrice, stopLoss, tp1, recTime, tp1Time, slTime, actionStatus, refPrice) {
if(!candles||candles.length<5) return '<div class="chart-loading">K线数据不足</div>';
var recPrice=Number(entryPrice||0), slPrice=Number(stopLoss||0), tpPrice=Number(tp1||0);
var isWait = (actionStatus === '等回踩');
var W=360,H=200,volH=28,padL=2,padR=2,padT=8,padB=18,chartW=W-padL-padR,chartH=H-padT-padB-volH;
var data=candles.slice(-60),n=data.length;
var candleW=Math.max(1.5,(chartW/n)*0.75),gap=Math.max(0.5,(chartW/n)-candleW);
var minPrice=Infinity,maxPrice=-Infinity,maxVol=0;
data.forEach(function(c){minPrice=Math.min(minPrice,c.low);maxPrice=Math.max(maxPrice,c.high);maxVol=Math.max(maxVol,c.volume||0);});
if(recPrice>0){minPrice=Math.min(minPrice,recPrice);maxPrice=Math.max(maxPrice,recPrice);}
if(slPrice>0){minPrice=Math.min(minPrice,slPrice);maxPrice=Math.max(maxPrice,slPrice);}
if(tpPrice>0){minPrice=Math.min(minPrice,tpPrice);maxPrice=Math.max(maxPrice,tpPrice);}
var priceRange=maxPrice-minPrice||1,volMaxH=volH-2;
var decimals = priceDecimals(refPrice || recPrice || maxPrice || minPrice);
var pt=function(p){return padT+chartH-(p-minPrice)/priceRange*chartH;};
function cx(i){return padL+i*(candleW+gap)+candleW/2;}
var svg='<svg viewBox="0 0 '+W+' '+H+'" width="100%" style="display:block"><style>.c-up{fill:#00b473;stroke:#00b473}.c-down{fill:#e53e3e;stroke:#e53e3e}.c-wick{stroke-width:1}.v-up{fill:rgba(0,180,115,.2)}.v-down{fill:rgba(229,62,62,.15)}.grid{stroke:#eef0f3;stroke-width:0.5}.label{font:8px sans-serif;fill:#a5a8b5}.htag{font:8px sans-serif}.event-marker{font:9px sans-serif;font-weight:700}</style>';
for(var i=0;i<=4;i++){var y=padT+chartH*i/4;svg+='<line class="grid" x1="'+padL+'" y1="'+y.toFixed(1)+'" x2="'+(W-padR)+'" y2="'+y.toFixed(1)+'"/><text class="label" x="'+(W-padR)+'" y="'+(y+10)+'" text-anchor="end">'+fmtPrice(maxPrice-priceRange*i/4, decimals)+'</text>';}
data.forEach(function(c,i){
var x=padL+i*(candleW+gap),open=pt(c.open),close=pt(c.close),high=pt(c.high),low=pt(c.low);
var isUp=c.close>=c.open,cls=isUp?'c-up':'c-down',vCls=isUp?'v-up':'v-down';
svg+='<line class="'+cls+' c-wick" x1="'+(x+candleW/2)+'" y1="'+high+'" x2="'+(x+candleW/2)+'" y2="'+low+'"/>';
svg+='<rect class="'+cls+'" x="'+x+'" y="'+Math.min(open,close)+'" width="'+candleW+'" height="'+Math.max(1,Math.abs(close-open))+'\"/>';
var vh=Math.max(1,(c.volume||0)/maxVol*volMaxH);
svg+='<rect class="'+vCls+'" x="'+x+'" y="'+(H-padB-vh)+'" width="'+candleW+'" height="'+vh+'"/>';
});
function markerAt(eventTime,label,color,price){
if(!eventTime) return;
var et=new Date(eventTime).getTime(),bestIdx=-1,bestDiff=Infinity;
data.forEach(function(c,i){var diff=Math.abs(c.time-et);if(diff<bestDiff){bestDiff=diff;bestIdx=i;}});
if(bestIdx<0||bestIdx>=n) return;
var mx=cx(bestIdx),candleLow=pt(data[bestIdx].low);
var my=H-padB-volH+12;
svg+='<line stroke="'+color+'" stroke-width="0.8" stroke-dasharray="2,2" x1="'+mx+'" y1="'+(candleLow+2)+'" x2="'+mx+'" y2="'+my+'"/>';
svg+='<text class="event-marker" fill="'+color+'" x="'+mx+'" y="'+(my+10)+'" text-anchor="middle">'+label+'</text>';
if(price>0) svg+='<text class="htag" fill="'+color+'" x="'+mx+'" y="'+(my+22)+'" text-anchor="middle">$'+fmtPrice(price, decimals)+'</text>';
}
if(recTime) {
if(isWait) {
markerAt(recTime,'\u25B3','#a5a8b5',0);
} else {
markerAt(recTime,'\u25B2','#4262ff',recPrice);
}
}
if(tp1Time) markerAt(tp1Time,'\u2713','#00b473',tpPrice);
if(slTime) markerAt(slTime,'\u2717','#e53e3e',slPrice);
svg+='</svg>';
return svg;
}
// ====== HISTORY ====== // ====== HISTORY ======
function historyOutcome(r) { function historyOutcome(r) {

625
static/chart_widgets.js Normal file
View File

@ -0,0 +1,625 @@
window.AlphaXCharts = (function () {
var cssInjected = false;
function injectCss() {
if (cssInjected) return;
cssInjected = true;
var style = document.createElement("style");
style.textContent = [
".ax-chart{position:relative;width:100%;height:100%;min-height:220px;outline:none;touch-action:pan-y}",
".ax-chart canvas{display:block;width:100%;height:100%}",
".ax-tooltip{position:absolute;z-index:4;min-width:172px;max-width:240px;padding:10px 11px;border:1px solid rgba(148,163,184,.32);border-radius:14px;background:rgba(255,255,255,.94);box-shadow:0 18px 42px rgba(15,23,42,.16);backdrop-filter:blur(10px);font-size:12px;color:var(--slate,#4b5563);pointer-events:none;transform:translate(-50%,-112%);opacity:0;transition:opacity .12s ease}",
".ax-tooltip.show{opacity:1}",
".ax-tip-date{font-weight:950;color:var(--ink,#111827);margin-bottom:6px}",
".ax-tip-row{display:flex;justify-content:space-between;gap:14px;line-height:1.55}",
".ax-tip-row b{color:var(--ink,#111827)}",
".ax-tip-row .pos{color:var(--green,#00b473)}",
".ax-tip-row .neg{color:var(--red,#e53e3e)}",
".ax-hint{position:absolute;right:10px;bottom:8px;border:1px solid rgba(148,163,184,.24);background:rgba(255,255,255,.72);border-radius:999px;padding:4px 8px;color:var(--stone,#8e91a0);font-size:10px;font-weight:850;pointer-events:none}"
].join("");
document.head.appendChild(style);
}
function esc(v) {
return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
return {"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c];
});
}
function fmt(v, d) {
v = Number(v || 0);
return v.toLocaleString(undefined, {maximumFractionDigits: d == null ? 2 : d, minimumFractionDigits: 0});
}
function color(name, fallback) {
var value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function cls(v) {
v = Number(v || 0);
return v > 0 ? "pos" : v < 0 ? "neg" : "";
}
function renderEquity(container, payload) {
injectCss();
var points = (payload && payload.points) || [];
if (!container) return;
if (container._axDestroy) {
container._axDestroy();
container._axDestroy = null;
}
if (!points.length) {
container.innerHTML = '<div class="empty">暂无收益曲线数据</div>';
return;
}
container.innerHTML = '<div class="ax-chart" tabindex="0" aria-label="可交互权益曲线,移动鼠标查看每日明细,点击可锁定"><canvas></canvas><div class="ax-tooltip" aria-hidden="true"></div><div class="ax-hint">hover 查看明细</div></div>';
var root = container.querySelector(".ax-chart");
var canvas = root.querySelector("canvas");
var ctx = canvas.getContext("2d");
var tip = root.querySelector(".ax-tooltip");
var locked = false;
var activeIdx = points.length - 1;
var ro = null;
var equities = points.map(function (p) { return Number(p.equity_usdt || 0); });
var pnls = points.map(function (p) { return Number(p.daily_pnl_usdt || 0); });
var minEq = Math.min.apply(null, equities);
var maxEq = Math.max.apply(null, equities);
if (maxEq === minEq) { maxEq += 1; minEq -= 1; }
var maxAbs = Math.max.apply(null, [1].concat(pnls.map(function (v) { return Math.abs(v); })));
function metrics() {
var rect = root.getBoundingClientRect();
var w = Math.max(320, rect.width || container.clientWidth || 960);
var h = Math.max(220, rect.height || container.clientHeight || 260);
var padL = 4, padR = 4, top = 22, barBase = h - 36, lineH = Math.max(96, barBase - top - 52);
var chartW = w - padL - padR;
return {w: w, h: h, padL: padL, padR: padR, top: top, barBase: barBase, lineH: lineH, chartW: chartW};
}
function x(i, m) {
return m.padL + (points.length === 1 ? m.chartW / 2 : i * m.chartW / (points.length - 1));
}
function y(v, m) {
return m.top + (maxEq - v) * m.lineH / (maxEq - minEq);
}
function resizeCanvas(m) {
var dpr = Math.max(1, window.devicePixelRatio || 1);
var bw = Math.round(m.w * dpr);
var bh = Math.round(m.h * dpr);
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = m.w + "px";
canvas.style.height = m.h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function drawGrid(m) {
ctx.strokeStyle = "rgba(148,163,184,.25)";
ctx.lineWidth = 1;
[m.top, m.top + m.lineH / 2, m.top + m.lineH].forEach(function (gy) {
ctx.beginPath();
ctx.moveTo(m.padL, gy);
ctx.lineTo(m.w - m.padR, gy);
ctx.stroke();
});
}
function drawBars(m) {
var barW = Math.max(3, m.chartW / points.length * .55);
points.forEach(function (p, i) {
var v = Number(p.daily_pnl_usdt || 0);
var bh = Math.abs(v) / maxAbs * 44;
var bx = x(i, m) - barW / 2;
var by = v >= 0 ? m.barBase - bh : m.barBase;
ctx.fillStyle = v >= 0 ? "rgba(0,180,115,.45)" : "rgba(229,62,62,.42)";
ctx.fillRect(bx, by, barW, Math.max(1, bh));
});
}
function drawEquity(m) {
var blue = color("--blue", "#4262ff");
var area = ctx.createLinearGradient(0, m.top, 0, m.barBase);
area.addColorStop(0, "rgba(66,98,255,.16)");
area.addColorStop(1, "rgba(66,98,255,.015)");
ctx.beginPath();
ctx.moveTo(x(0, m), m.barBase);
points.forEach(function (p, i) { ctx.lineTo(x(i, m), y(Number(p.equity_usdt || 0), m)); });
ctx.lineTo(x(points.length - 1, m), m.barBase);
ctx.closePath();
ctx.fillStyle = area;
ctx.fill();
ctx.beginPath();
points.forEach(function (p, i) {
var px = x(i, m), py = y(Number(p.equity_usdt || 0), m);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.strokeStyle = blue;
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.stroke();
}
function drawLabels(m) {
ctx.fillStyle = color("--stone", "#8e91a0");
ctx.font = "800 11px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(String(points[0].date || ""), m.padL + 6, m.h - 14);
ctx.fillText("权益 " + fmt(minEq, 0) + "U - " + fmt(maxEq, 0) + "U", m.padL + 6, 14);
ctx.textAlign = "right";
ctx.fillText(String(points[points.length - 1].date || ""), m.w - m.padR - 6, m.h - 14);
ctx.fillText("点击锁定 · Esc 取消", m.w - m.padR - 6, 14);
}
function drawFocus(m) {
if (activeIdx == null) return;
var p = points[activeIdx];
var px = x(activeIdx, m);
var py = y(Number(p.equity_usdt || 0), m);
ctx.save();
ctx.strokeStyle = "rgba(15,23,42,.38)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(px, m.top);
ctx.lineTo(px, m.barBase);
ctx.moveTo(m.padL, py);
ctx.lineTo(m.w - m.padR, py);
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(px, py, 5, 0, Math.PI * 2);
ctx.fillStyle = color("--canvas", "#fff");
ctx.fill();
ctx.strokeStyle = color("--blue", "#4262ff");
ctx.lineWidth = 3;
ctx.stroke();
placeTip(p, px, py, m);
}
function render() {
var m = metrics();
resizeCanvas(m);
ctx.clearRect(0, 0, m.w, m.h);
drawGrid(m);
drawBars(m);
drawEquity(m);
drawFocus(m);
drawLabels(m);
}
function nearestFromEvent(ev) {
var rect = canvas.getBoundingClientRect();
var sx = ev.clientX - rect.left;
var m = metrics();
var idx = Math.round((sx - m.padL) / Math.max(1, m.chartW) * (points.length - 1));
return Math.max(0, Math.min(points.length - 1, idx));
}
function placeTip(p, px, py, m) {
tip.innerHTML =
'<div class="ax-tip-date">' + esc(p.date || "--") + '</div>' +
'<div class="ax-tip-row"><span>账户权益</span><b>' + fmt(p.equity_usdt, 2) + 'U</b></div>' +
'<div class="ax-tip-row"><span>每日收益</span><b class="' + cls(p.daily_pnl_usdt) + '">' + (Number(p.daily_pnl_usdt || 0) > 0 ? "+" : "") + fmt(p.daily_pnl_usdt, 2) + 'U</b></div>' +
'<div class="ax-tip-row"><span>累计收益</span><b class="' + cls(p.total_pnl_usdt) + '">' + (Number(p.total_pnl_usdt || 0) > 0 ? "+" : "") + fmt(p.total_pnl_usdt, 2) + 'U</b></div>';
tip.style.left = Math.max(96, Math.min(m.w - 96, px)) + "px";
tip.style.top = Math.max(42, py) + "px";
tip.classList.add("show");
}
function hide() {
if (locked) return;
activeIdx = null;
tip.classList.remove("show");
render();
}
root.addEventListener("mousemove", function (ev) {
if (locked) return;
activeIdx = nearestFromEvent(ev);
render();
});
root.addEventListener("mouseleave", hide);
root.addEventListener("click", function (ev) {
activeIdx = nearestFromEvent(ev);
locked = !locked;
render();
});
root.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") {
locked = false;
hide();
}
});
if (window.ResizeObserver) {
ro = new ResizeObserver(render);
ro.observe(root);
} else {
window.addEventListener("resize", render);
}
render();
root._axDestroy = function () {
if (ro) ro.disconnect();
window.removeEventListener("resize", render);
};
container._axDestroy = root._axDestroy;
}
function priceDecimals(ref) {
ref = Math.abs(Number(ref || 0));
if (ref >= 1000) return 1;
if (ref >= 100) return 2;
if (ref >= 1) return 4;
if (ref >= 0.01) return 6;
return 8;
}
function fmtPrice(v, decimals) {
v = Number(v || 0);
if (!Number.isFinite(v)) return "--";
return v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals == null ? priceDecimals(v) : decimals
});
}
function candleTime(c) {
var t = c && (c.time || c.timestamp || c.open_time || c.openTime || c.t);
if (typeof t === "string") {
var parsed = Date.parse(t);
return Number.isFinite(parsed) ? parsed : 0;
}
t = Number(t || 0);
return t > 0 && t < 100000000000 ? t * 1000 : t;
}
function fmtCandleDate(t) {
if (!t) return "--";
var d = new Date(t);
if (Number.isNaN(d.getTime())) return "--";
var mm = String(d.getMonth() + 1).padStart(2, "0");
var dd = String(d.getDate()).padStart(2, "0");
var hh = String(d.getHours()).padStart(2, "0");
var mi = String(d.getMinutes()).padStart(2, "0");
return mm + "-" + dd + " " + hh + ":" + mi;
}
function roundedRect(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r || 0, w / 2, h / 2));
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
}
function renderKline(container, payload) {
injectCss();
if (!container) return;
if (container._axDestroy) {
container._axDestroy();
container._axDestroy = null;
}
var candles = ((payload && payload.candles) || []).slice(-60).map(function (c) {
return {
time: candleTime(c),
open: Number(c.open || 0),
high: Number(c.high || 0),
low: Number(c.low || 0),
close: Number(c.close || 0),
volume: Number(c.volume || 0)
};
}).filter(function (c) {
return c.high > 0 && c.low > 0 && c.close > 0;
});
if (candles.length < 5) {
container.innerHTML = '<div class="chart-loading">K线数据不足</div>';
return;
}
var symbol = (payload && payload.symbol) || "";
var entryPrice = Number((payload && payload.entryPrice) || 0);
var stopLoss = Number((payload && payload.stopLoss) || 0);
var tp1 = Number((payload && payload.tp1) || 0);
var recTime = payload && payload.recTime ? Date.parse(payload.recTime) : 0;
var tp1Time = payload && payload.tp1Time ? Date.parse(payload.tp1Time) : 0;
var slTime = payload && payload.slTime ? Date.parse(payload.slTime) : 0;
var actionStatus = (payload && payload.actionStatus) || "";
var refPrice = Number((payload && payload.refPrice) || entryPrice || candles[candles.length - 1].close || 0);
var decimals = priceDecimals(refPrice || entryPrice || candles[candles.length - 1].close);
container.innerHTML = '<div class="ax-chart ax-kline" tabindex="0" aria-label="可交互K线图移动鼠标查看K线明细点击可锁定"><canvas></canvas><div class="ax-tooltip" aria-hidden="true"></div><div class="ax-hint">hover 查看K线</div></div>';
var root = container.querySelector(".ax-chart");
var canvas = root.querySelector("canvas");
var ctx = canvas.getContext("2d");
var tip = root.querySelector(".ax-tooltip");
var locked = false;
var activeIdx = null;
var ro = null;
var minPrice = Infinity, maxPrice = -Infinity, maxVol = 1;
candles.forEach(function (c) {
minPrice = Math.min(minPrice, c.low);
maxPrice = Math.max(maxPrice, c.high);
maxVol = Math.max(maxVol, c.volume || 0);
});
[entryPrice, stopLoss, tp1].forEach(function (p) {
if (p > 0) {
minPrice = Math.min(minPrice, p);
maxPrice = Math.max(maxPrice, p);
}
});
var padding = Math.max((maxPrice - minPrice) * 0.06, maxPrice * 0.001);
minPrice -= padding;
maxPrice += padding;
if (maxPrice <= minPrice) maxPrice = minPrice + 1;
function metrics() {
var rect = root.getBoundingClientRect();
var w = Math.max(300, rect.width || container.clientWidth || 360);
var h = Math.max(190, rect.height || container.clientHeight || 200);
var padL = 4, padR = 4, padT = 12, padB = 20, volH = 32;
var chartH = Math.max(108, h - padT - padB - volH);
var chartW = Math.max(1, w - padL - padR);
return {w: w, h: h, padL: padL, padR: padR, padT: padT, padB: padB, volH: volH, chartH: chartH, chartW: chartW};
}
function resizeCanvas(m) {
var dpr = Math.max(1, window.devicePixelRatio || 1);
var bw = Math.round(m.w * dpr);
var bh = Math.round(m.h * dpr);
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = m.w + "px";
canvas.style.height = m.h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function x(i, m) {
var step = m.chartW / candles.length;
return m.padL + step * i + step / 2;
}
function y(price, m) {
return m.padT + (maxPrice - price) * m.chartH / (maxPrice - minPrice);
}
function nearestIndexFromTime(ts) {
if (!ts) return -1;
var best = -1, diff = Infinity;
candles.forEach(function (c, i) {
var d = Math.abs(c.time - ts);
if (d < diff) {
diff = d;
best = i;
}
});
return best;
}
function drawGrid(m) {
ctx.strokeStyle = "rgba(148,163,184,.20)";
ctx.lineWidth = 1;
ctx.font = "800 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.fillStyle = color("--stone", "#8e91a0");
ctx.textBaseline = "middle";
ctx.textAlign = "right";
for (var i = 0; i <= 3; i++) {
var gy = m.padT + m.chartH * i / 3;
var p = maxPrice - (maxPrice - minPrice) * i / 3;
ctx.beginPath();
ctx.moveTo(m.padL, gy);
ctx.lineTo(m.w - m.padR, gy);
ctx.stroke();
ctx.fillText(fmtPrice(p, decimals), m.w - m.padR - 4, gy + 8);
}
}
function drawCandles(m) {
var step = m.chartW / candles.length;
var cw = Math.max(2, Math.min(9, step * 0.62));
candles.forEach(function (c, i) {
var up = c.close >= c.open;
var cx = x(i, m);
var x0 = cx - cw / 2;
var yo = y(c.open, m), yc = y(c.close, m), yh = y(c.high, m), yl = y(c.low, m);
ctx.strokeStyle = up ? "rgba(0,180,115,.95)" : "rgba(229,62,62,.92)";
ctx.fillStyle = up ? "rgba(0,180,115,.86)" : "rgba(229,62,62,.84)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, yh);
ctx.lineTo(cx, yl);
ctx.stroke();
ctx.fillRect(x0, Math.min(yo, yc), cw, Math.max(1, Math.abs(yc - yo)));
var vh = Math.max(1, (c.volume || 0) / maxVol * (m.volH - 4));
ctx.fillStyle = up ? "rgba(0,180,115,.24)" : "rgba(229,62,62,.20)";
ctx.fillRect(x0, m.h - m.padB - vh, cw, vh);
});
}
function drawPriceMarker(m, value, label, markerColor) {
if (!(value > 0)) return;
var py = y(value, m);
ctx.save();
ctx.strokeStyle = markerColor;
ctx.globalAlpha = 0.75;
ctx.lineWidth = 1;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(m.padL, py);
ctx.lineTo(m.w - m.padR, py);
ctx.stroke();
ctx.restore();
var text = label + " " + fmtPrice(value, decimals);
ctx.font = "900 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
var tw = ctx.measureText(text).width + 10;
ctx.fillStyle = "rgba(255,255,255,.88)";
ctx.strokeStyle = markerColor;
ctx.lineWidth = 1;
ctx.beginPath();
roundedRect(ctx, m.padL + 4, py - 9, tw, 18, 9);
ctx.fill();
ctx.stroke();
ctx.fillStyle = markerColor;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(text, m.padL + 9, py);
}
function drawEventMarker(m, ts, label, markerColor, price) {
var idx = nearestIndexFromTime(ts);
if (idx < 0) return;
var px = x(idx, m);
var baseY = m.h - m.padB - m.volH + 14;
ctx.save();
ctx.strokeStyle = markerColor;
ctx.globalAlpha = 0.78;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(px, y(candles[idx].low, m) + 3);
ctx.lineTo(px, baseY);
ctx.stroke();
ctx.restore();
ctx.font = "950 12px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = markerColor;
ctx.fillText(label, px, baseY + 9);
if (price > 0) {
ctx.font = "850 9px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.fillText("$" + fmtPrice(price, decimals), px, baseY + 22);
}
}
function drawLabels(m) {
ctx.fillStyle = color("--stone", "#8e91a0");
ctx.font = "850 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(fmtCandleDate(candles[0].time), m.padL + 6, m.h - 10);
ctx.textAlign = "right";
ctx.fillText(fmtCandleDate(candles[candles.length - 1].time), m.w - m.padR - 6, m.h - 10);
}
function drawFocus(m) {
if (activeIdx == null) return;
var c = candles[activeIdx];
var px = x(activeIdx, m);
var py = y(c.close, m);
ctx.save();
ctx.strokeStyle = "rgba(15,23,42,.35)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(px, m.padT);
ctx.lineTo(px, m.h - m.padB);
ctx.moveTo(m.padL, py);
ctx.lineTo(m.w - m.padR, py);
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(px, py, 4, 0, Math.PI * 2);
ctx.fillStyle = color("--canvas", "#fff");
ctx.fill();
ctx.strokeStyle = color("--blue", "#4262ff");
ctx.lineWidth = 2;
ctx.stroke();
placeTip(c, px, py, m);
}
function render() {
var m = metrics();
resizeCanvas(m);
ctx.clearRect(0, 0, m.w, m.h);
drawGrid(m);
drawPriceMarker(m, tp1, "TP", color("--green", "#00b473"));
drawPriceMarker(m, entryPrice, actionStatus === "等回踩" ? "回踩" : "入场", color("--blue", "#4262ff"));
drawPriceMarker(m, stopLoss, "SL", color("--red", "#e53e3e"));
drawCandles(m);
if (recTime) drawEventMarker(m, recTime, actionStatus === "等回踩" ? "△" : "▲", actionStatus === "等回踩" ? "#8e91a0" : color("--blue", "#4262ff"), entryPrice);
if (tp1Time) drawEventMarker(m, tp1Time, "✓", color("--green", "#00b473"), tp1);
if (slTime) drawEventMarker(m, slTime, "×", color("--red", "#e53e3e"), stopLoss);
drawFocus(m);
drawLabels(m);
}
function nearestFromEvent(ev) {
var rect = canvas.getBoundingClientRect();
var sx = ev.clientX - rect.left;
var m = metrics();
var idx = Math.floor((sx - m.padL) / Math.max(1, m.chartW) * candles.length);
return Math.max(0, Math.min(candles.length - 1, idx));
}
function placeTip(c, px, py, m) {
var pct = c.open ? (c.close / c.open - 1) * 100 : 0;
tip.innerHTML =
'<div class="ax-tip-date">' + esc(symbol ? symbol + " · " : "") + esc(fmtCandleDate(c.time)) + '</div>' +
'<div class="ax-tip-row"><span>开</span><b>$' + fmtPrice(c.open, decimals) + '</b></div>' +
'<div class="ax-tip-row"><span>高 / 低</span><b>$' + fmtPrice(c.high, decimals) + ' / $' + fmtPrice(c.low, decimals) + '</b></div>' +
'<div class="ax-tip-row"><span>收</span><b class="' + cls(pct) + '">$' + fmtPrice(c.close, decimals) + ' (' + (pct > 0 ? "+" : "") + pct.toFixed(2) + '%)</b></div>' +
'<div class="ax-tip-row"><span>成交量</span><b>' + fmt(c.volume, 0) + '</b></div>';
tip.style.left = Math.max(96, Math.min(m.w - 96, px)) + "px";
tip.style.top = Math.max(42, py) + "px";
tip.classList.add("show");
}
function hide() {
if (locked) return;
activeIdx = null;
tip.classList.remove("show");
render();
}
root.addEventListener("mousemove", function (ev) {
if (locked) return;
activeIdx = nearestFromEvent(ev);
render();
});
root.addEventListener("mouseleave", hide);
root.addEventListener("click", function (ev) {
activeIdx = nearestFromEvent(ev);
locked = !locked;
render();
});
root.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") {
locked = false;
hide();
}
});
if (window.ResizeObserver) {
ro = new ResizeObserver(render);
ro.observe(root);
} else {
window.addEventListener("resize", render);
}
render();
root._axDestroy = function () {
if (ro) ro.disconnect();
window.removeEventListener("resize", render);
};
container._axDestroy = root._axDestroy;
}
return { renderEquity: renderEquity, renderKline: renderKline };
})();

View File

@ -117,6 +117,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block extra_script %} {% block extra_script %}
<script src="/static/chart_widgets.js"></script>
<script> <script>
var LIMIT=50,openOffset=0,openTotal=0,EVENT_LIMIT=80,eventOffset=0,eventTotal=0; var LIMIT=50,openOffset=0,openTotal=0,EVENT_LIMIT=80,eventOffset=0,eventTotal=0;
function $(id){return document.getElementById(id)} function $(id){return document.getElementById(id)}
@ -151,7 +152,7 @@ function renderPerformance(d){var points=d.points||[];if(!points.length){$('perf
'<span class="perf-pill '+(ret>=0?'pos':'neg')+'">总收益率 '+(ret>0?'+':'')+fmt(ret,2)+'%</span>', '<span class="perf-pill '+(ret>=0?'pos':'neg')+'">总收益率 '+(ret>0?'+':'')+fmt(ret,2)+'%</span>',
'<span class="perf-pill">最大回撤 '+fmt(dd,2)+'%</span>', '<span class="perf-pill">最大回撤 '+fmt(dd,2)+'%</span>',
'<span class="perf-pill '+(pnl>=0?'pos':'neg')+'">总收益 '+(pnl>0?'+':'')+fmt(pnl,2)+'U</span>' '<span class="perf-pill '+(pnl>=0?'pos':'neg')+'">总收益 '+(pnl>0?'+':'')+fmt(pnl,2)+'U</span>'
].join('');var w=960,h=260,pad=38,top=22,lineH=150,barBase=224;var equities=points.map(function(p){return Number(p.equity_usdt||0)}),pnls=points.map(function(p){return Number(p.daily_pnl_usdt||0)});var minEq=Math.min.apply(null,equities),maxEq=Math.max.apply(null,equities);if(maxEq===minEq){maxEq+=1;minEq-=1}function x(i){return pad+(points.length===1?0:i*(w-pad*2)/(points.length-1))}function y(v){return top+(maxEq-v)*lineH/(maxEq-minEq)}var line=points.map(function(p,i){return x(i).toFixed(1)+','+y(Number(p.equity_usdt||0)).toFixed(1)}).join(' ');var area=pad+','+barBase+' '+line+' '+x(points.length-1).toFixed(1)+','+barBase;var maxAbs=Math.max.apply(null,[1].concat(pnls.map(function(v){return Math.abs(v)})));var barW=Math.max(3,(w-pad*2)/points.length*.55);var bars=points.map(function(p,i){var v=Number(p.daily_pnl_usdt||0),bh=Math.abs(v)/maxAbs*44,bx=x(i)-barW/2,by=v>=0?barBase-bh:barBase;return '<rect class="'+(v>=0?'bar-pos':'bar-neg')+'" x="'+bx.toFixed(1)+'" y="'+by.toFixed(1)+'" width="'+barW.toFixed(1)+'" height="'+Math.max(1,bh).toFixed(1)+'"><title>'+esc(p.date)+' 每日收益 '+(v>0?'+':'')+fmt(v,2)+'U</title></rect>'}).join('');var first=points[0],last=points[points.length-1];var grid=[top,top+lineH/2,top+lineH].map(function(gy){return '<line class="chart-grid" x1="'+pad+'" x2="'+(w-pad)+'" y1="'+gy+'" y2="'+gy+'"></line>'}).join('');$('performanceChart').innerHTML='<svg viewBox="0 0 '+w+' '+h+'" role="img" aria-label="策略交易每日收益与权益曲线">'+grid+'<polygon class="equity-area" points="'+area+'"></polygon>'+bars+'<polyline class="equity-line" points="'+line+'"></polyline><text class="chart-label" x="'+pad+'" y="248">'+esc(first.date)+'</text><text class="chart-label" text-anchor="end" x="'+(w-pad)+'" y="248">'+esc(last.date)+'</text><text class="chart-label" x="'+pad+'" y="14">权益 '+fmt(minEq,0)+'U - '+fmt(maxEq,0)+'U</text><text class="chart-label" text-anchor="end" x="'+(w-pad)+'" y="14">柱状图:每日实现收益</text></svg>'} ].join('');AlphaXCharts.renderEquity($('performanceChart'),d)}
async function loadOrders(){$('orderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending');renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}} async function loadOrders(){$('orderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending');renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}
function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><td colspan="11" class="empty">暂无等待触价的策略挂单</td></tr>';return}$('orderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0);return '<tr>'+ function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><td colspan="11" class="empty">暂无等待触价的策略挂单</td></tr>';return}$('orderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0);return '<tr>'+
'<td><div class="sym">'+esc(x.symbol)+'</div><div class="muted">#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'</div></td>'+ '<td><div class="sym">'+esc(x.symbol)+'</div><div class="muted">#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'</div></td>'+