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.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.db.schema import init_db
@ -45,6 +46,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="AlphaX Agent", lifespan=lifespan)
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(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: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-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; }
/* ===== ENTRY PLAN ===== */
@ -339,6 +340,7 @@
{% endblock %}
{% block extra_script %}
<script src="/static/chart_widgets.js"></script>
<script>
function drawPin(){ return null; }
var curTab = 'live';
@ -841,7 +843,22 @@ function loadOneKline(container) {
var slTime = container.dataset.slTime||'';
var actionStatus = container.dataset.actionStatus || container.dataset.status || '';
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.dataset.klineLoaded = '1';
delete container.dataset.klineLoading;
@ -902,61 +919,6 @@ function switchKlineInterval(btn) {
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 ======
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>
{% endblock %}
{% block extra_script %}
<script src="/static/chart_widgets.js"></script>
<script>
var LIMIT=50,openOffset=0,openTotal=0,EVENT_LIMIT=80,eventOffset=0,eventTotal=0;
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">最大回撤 '+fmt(dd,2)+'%</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>'}}
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>'+