|
|
<!doctype html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="utf-8"/> |
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/> |
|
|
<title>MPTrading</title> |
|
|
<style> |
|
|
:root{ |
|
|
--bg:#0b1020; --panel:#111a33; --panel2:#0f1730; --text:#e7eaf3; |
|
|
--muted:#aab2d5; --line:#233055; --green:#22c55e; --red:#ef4444; --yellow:#f59e0b; --blue:#60a5fa; |
|
|
} |
|
|
*{ box-sizing:border-box; } |
|
|
body{ margin:0; font-family:system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background:var(--bg); color:var(--text); } |
|
|
|
|
|
header{ |
|
|
height:52px; display:flex; align-items:center; justify-content:space-between; |
|
|
padding:0 14px; border-bottom:1px solid var(--line); background:rgba(0,0,0,0.15); |
|
|
gap:10px; |
|
|
} |
|
|
.leftHead{ display:flex; align-items:center; gap:12px; min-width: 0; } |
|
|
.title{ font-weight:800; letter-spacing:0.3px; white-space:nowrap; } |
|
|
.status{ font-size:12px; color:var(--muted); display:flex; align-items:center; gap:8px; white-space:nowrap; } |
|
|
.dot{ width:10px; height:10px; border-radius:999px; background:#6b7280; } |
|
|
.dot.connected{ background:var(--green); } |
|
|
.dot.connecting{ background:var(--yellow); } |
|
|
.dot.error{ background:var(--red); } |
|
|
|
|
|
.tabs{ display:flex; gap:8px; flex-wrap:wrap; } |
|
|
.tabBtn{ |
|
|
font:inherit; border-radius:10px; border:1px solid var(--line); |
|
|
background:#0c1430; color:var(--text); padding:8px 10px; cursor:pointer; |
|
|
font-size:13px; |
|
|
} |
|
|
.tabBtn.active{ background:rgba(96,165,250,0.14); border-color:rgba(96,165,250,0.55); } |
|
|
|
|
|
main{ padding:12px; height: calc(100vh - 52px); } |
|
|
.tabPanel{ display:none; height:100%; } |
|
|
.tabPanel.active{ display:block; } |
|
|
|
|
|
.gridMain{ |
|
|
display:grid; |
|
|
grid-template-columns: 1.35fr 1fr; |
|
|
gap:12px; |
|
|
height:100%; |
|
|
min-height:0; |
|
|
} |
|
|
.gridSecondary{ |
|
|
display:grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap:12px; |
|
|
height:100%; |
|
|
min-height:0; |
|
|
} |
|
|
|
|
|
.card{ |
|
|
background:var(--panel); border:1px solid var(--line); border-radius:10px; |
|
|
overflow:hidden; display:flex; flex-direction:column; min-height:0; |
|
|
} |
|
|
.card h2{ |
|
|
margin:0; padding:10px 12px; font-size:13px; color:var(--muted); |
|
|
text-transform:uppercase; letter-spacing:0.08em; |
|
|
border-bottom:1px solid var(--line); background:var(--panel2); |
|
|
} |
|
|
.content{ padding:12px; overflow:auto; min-height:0; } |
|
|
|
|
|
.grid3{ display:grid; grid-template-columns: 1fr 1fr 1fr; gap:10px; } |
|
|
.grid2{ display:grid; grid-template-columns: 1fr 1fr; gap:10px; } |
|
|
|
|
|
.kpi{ padding:10px; border:1px solid var(--line); border-radius:10px; background:rgba(255,255,255,0.02); } |
|
|
.kpi .label{ color:var(--muted); font-size:12px; } |
|
|
.kpi .value{ font-size:18px; font-weight:800; margin-top:4px; } |
|
|
.kpi .sub{ color:var(--muted); font-size:12px; margin-top:4px; } |
|
|
|
|
|
.row{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; } |
|
|
input, button, select{ |
|
|
font:inherit; border-radius:10px; border:1px solid var(--line); |
|
|
background:#0c1430; color:var(--text); padding:10px 12px; |
|
|
} |
|
|
input{ width:130px; } |
|
|
button{ cursor:pointer; } |
|
|
button.buy{ border-color:rgba(34,197,94,0.5); } |
|
|
button.sell{ border-color:rgba(239,68,68,0.5); } |
|
|
button.buy:hover{ background:rgba(34,197,94,0.12); } |
|
|
button.sell:hover{ background:rgba(239,68,68,0.12); } |
|
|
button.secondary:hover{ background:rgba(255,255,255,0.06); } |
|
|
|
|
|
.warn{ border:1px solid rgba(245,158,11,0.45); background:rgba(245,158,11,0.07); border-radius:10px; padding:10px; } |
|
|
.danger{ border:1px solid rgba(239,68,68,0.55); background:rgba(239,68,68,0.07); border-radius:10px; padding:10px; } |
|
|
.muted{ color:var(--muted); } |
|
|
.small{ font-size:12px; color:var(--muted); } |
|
|
.mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; } |
|
|
|
|
|
canvas{ width:100%; height:240px; background:rgba(0,0,0,0.10); border:1px solid rgba(35,48,85,0.8); border-radius:10px; display:block; } |
|
|
#equityCanvas{ height:240px; } |
|
|
|
|
|
table{ width:100%; border-collapse:collapse; font-size:13px; } |
|
|
th, td{ text-align:left; padding:8px 6px; border-bottom:1px solid rgba(35,48,85,0.6); vertical-align:top; } |
|
|
th{ color:var(--muted); font-weight:700; } |
|
|
td.right, th.right{ text-align:right; } |
|
|
.pill{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid var(--line); } |
|
|
.pill.buy{ color:var(--green); border-color:rgba(34,197,94,0.5); } |
|
|
.pill.sell{ color:var(--red); border-color:rgba(239,68,68,0.5); } |
|
|
|
|
|
@media (max-width: 1000px){ |
|
|
.gridMain{ grid-template-columns: 1fr; height:auto; } |
|
|
.gridSecondary{ grid-template-columns: 1fr; height:auto; } |
|
|
main{ height:auto; } |
|
|
canvas{ height:200px; } |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
|
|
|
<body> |
|
|
<header> |
|
|
<div class="leftHead"> |
|
|
<div class="title">MPTrading</div> |
|
|
<div class="tabs"> |
|
|
<button class="tabBtn active" id="tabBtnTrade" type="button">Trading</button> |
|
|
<button class="tabBtn" id="tabBtnStats" type="button">Stats</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="status"> |
|
|
<span id="dot" class="dot"></span> |
|
|
<span id="statusText">Disconnected</span> |
|
|
<span class="small" id="clientIdText"></span> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<main> |
|
|
|
|
|
<section class="tabPanel active" id="tabTrade"> |
|
|
<div class="gridMain"> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>Price & Chart</h2> |
|
|
<div class="content"> |
|
|
<div class="grid3"> |
|
|
<div class="kpi"> |
|
|
<div class="label">Day / Price</div> |
|
|
<div class="value" id="kpiPrice">--</div> |
|
|
<div class="sub"><span class="muted">Day:</span> <span id="kpiDay">--</span></div> |
|
|
</div> |
|
|
<div class="kpi"> |
|
|
<div class="label">Indicators</div> |
|
|
<div class="sub"><span class="muted">MA20:</span> <span id="kpiMA20">--</span></div> |
|
|
<div class="sub"><span class="muted">MA50:</span> <span id="kpiMA50">--</span></div> |
|
|
<div class="sub"><span class="muted">RSI14:</span> <span id="kpiRSI">--</span></div> |
|
|
</div> |
|
|
<div class="kpi"> |
|
|
<div class="label">Latest news</div> |
|
|
<div id="newsText" class="sub">No news yet.</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div style="height:10px"></div> |
|
|
<canvas id="priceCanvas" width="1200" height="440"></canvas> |
|
|
<div class="small muted" style="margin-top:8px;"> |
|
|
Price (white) · MA20 (blue) · MA50 (yellow). |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="card"> |
|
|
<h2>Trading</h2> |
|
|
<div class="content"> |
|
|
<div class="grid2"> |
|
|
<div class="kpi"> |
|
|
<div class="label">Cash / Equity</div> |
|
|
<div class="sub"><span class="muted">Cash:</span> <span id="kpiCash">--</span></div> |
|
|
<div class="sub"><span class="muted">Equity:</span> <span id="kpiEquity">--</span></div> |
|
|
<div class="sub"><span class="muted">ROI:</span> <span id="kpiRoi">--</span></div> |
|
|
</div> |
|
|
|
|
|
<div class="kpi"> |
|
|
<div class="label">Position</div> |
|
|
<div class="sub"><span class="muted">Shares:</span> <span id="kpiPos">--</span></div> |
|
|
<div class="sub"><span class="muted">Avg cost:</span> <span id="kpiAvg">--</span></div> |
|
|
<div class="sub"><span class="muted">Break-even:</span> <span id="kpiBE">--</span></div> |
|
|
<div class="sub"><span class="muted">Unrealized P&L:</span> <span id="kpiUPnL">--</span></div> |
|
|
</div> |
|
|
|
|
|
<div class="kpi"> |
|
|
<div class="label">Margin</div> |
|
|
<div class="sub"><span class="muted">Max leverage:</span> <span class="mono" id="kpiLevMax">--</span></div> |
|
|
<div class="sub"><span class="muted">Used:</span> <span class="mono" id="kpiMarginUsed">--</span></div> |
|
|
<div class="sub"><span class="muted">Utilization:</span> <span class="mono" id="kpiMarginPct">--</span></div> |
|
|
<div class="sub"><span class="muted">Borrow fee (short):</span> <span class="mono" id="kpiBorrowFee">--</span></div> |
|
|
</div> |
|
|
|
|
|
<div class="kpi"> |
|
|
<div class="label">Stops</div> |
|
|
<div class="row" style="margin-top:8px;"> |
|
|
<label class="small muted">Stop</label> |
|
|
<input id="stopLoss" type="number" placeholder="price" step="0.01"/> |
|
|
<label class="small muted">TP</label> |
|
|
<input id="takeProfit" type="number" placeholder="price" step="0.01"/> |
|
|
</div> |
|
|
<div class="row" style="margin-top:8px;"> |
|
|
<button class="secondary" id="setStopsBtn" type="button">Set</button> |
|
|
<button class="secondary" id="clearStopsBtn" type="button">Clear</button> |
|
|
</div> |
|
|
<div class="sub"><span class="muted">Active SL:</span> <span id="kpiSL">--</span> · <span class="muted">TP:</span> <span id="kpiTP">--</span></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div style="height:12px"></div> |
|
|
|
|
|
<div id="warnBox" class="warn" style="display:none;"> |
|
|
<div class="small"><b>Margin warning</b>: utilization is high. Reduce exposure.</div> |
|
|
</div> |
|
|
<div id="liqBox" class="danger" style="display:none;"> |
|
|
<div class="small"><b>Liquidated</b>: equity is below zero. Trading disabled.</div> |
|
|
</div> |
|
|
|
|
|
<div style="height:12px"></div> |
|
|
|
|
|
<div class="row"> |
|
|
<label class="small muted">Qty</label> |
|
|
<input id="qty" type="number" value="100" min="1" step="1"/> |
|
|
<button class="buy" id="buyBtn" type="button">Buy (B)</button> |
|
|
<button class="sell" id="sellBtn" type="button">Sell (S)</button> |
|
|
<button class="secondary" id="closeBtn" type="button">Close pos (C)</button> |
|
|
<button class="secondary" id="resetBtn" type="button" title="Reset local portfolio only">Reset</button> |
|
|
</div> |
|
|
|
|
|
<div class="small muted" style="margin-top:10px;"> |
|
|
Shorting enabled. Leverage-limited. Keyboard: B/S/C, and 1..5 set quick quantities. |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
|
|
|
<section class="tabPanel" id="tabStats"> |
|
|
<div class="gridSecondary"> |
|
|
<div class="card"> |
|
|
<h2>Leaderboard</h2> |
|
|
<div class="content"> |
|
|
<table> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Name</th> |
|
|
<th class="right">Equity</th> |
|
|
<th class="right">ROI %</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="lbBody"> |
|
|
<tr><td colspan="3" class="muted">Waiting for ticks…</td></tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>Equity curve</h2> |
|
|
<div class="content"> |
|
|
<canvas id="equityCanvas" width="1200" height="320"></canvas> |
|
|
<div class="small muted" style="margin-top:8px;">Equity curve (last 200 points).</div> |
|
|
|
|
|
<div style="height:10px"></div> |
|
|
|
|
|
<div class="kpi"> |
|
|
<div class="label">Performance (session)</div> |
|
|
<div class="sub"><span class="muted">Trades:</span> <span id="mTrades">0</span> · <span class="muted">Win rate:</span> <span id="mWinRate">--</span></div> |
|
|
<div class="sub"><span class="muted">Avg win:</span> <span id="mAvgWin">--</span> · <span class="muted">Avg loss:</span> <span id="mAvgLoss">--</span></div> |
|
|
<div class="sub"><span class="muted">Best:</span> <span id="mBest">--</span> · <span class="muted">Worst:</span> <span id="mWorst">--</span></div> |
|
|
<div class="sub"><span class="muted">Sharpe (est):</span> <span id="mSharpe">--</span></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="card" style="grid-column: 1 / -1;"> |
|
|
<h2>Trade history</h2> |
|
|
<div class="content"> |
|
|
<table> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Action</th> |
|
|
<th class="right">Qty</th> |
|
|
<th class="right">Price</th> |
|
|
<th class="right">Day</th> |
|
|
<th class="right">Realized P&L</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="tradesBody"> |
|
|
<tr><td colspan="5" class="muted">No trades yet.</td></tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
</main> |
|
|
|
|
|
<script> |
|
|
|
|
|
const INITIAL_CASH = 10000; |
|
|
const COMMISSION = 5; |
|
|
|
|
|
const MAX_LEVERAGE = 3.0; |
|
|
const BORROW_RATE_PER_DAY = 0.0002; |
|
|
|
|
|
const PRICE_WINDOW = 120; |
|
|
const EQUITY_WINDOW = 200; |
|
|
|
|
|
|
|
|
const state = { |
|
|
ws: null, |
|
|
wsStatus: "DISCONNECTED", |
|
|
clientId: "user_" + Math.random().toString(36).slice(2, 7), |
|
|
|
|
|
market: [], |
|
|
day: 0, |
|
|
price: 0, |
|
|
|
|
|
cash: INITIAL_CASH, |
|
|
shares: 0, |
|
|
avgCost: 0, |
|
|
|
|
|
equity: INITIAL_CASH, |
|
|
roi: 0, |
|
|
upnl: 0, |
|
|
|
|
|
stopLoss: null, |
|
|
takeProfit: null, |
|
|
|
|
|
equityCurve: [], |
|
|
tradeLog: [], |
|
|
openLots: [], |
|
|
|
|
|
leaderboard: [], |
|
|
|
|
|
liquidated: false, |
|
|
liquidationDay: null |
|
|
}; |
|
|
|
|
|
|
|
|
const $ = (id) => document.getElementById(id); |
|
|
|
|
|
function fmtMoney(x){ return Number.isFinite(x) ? x.toLocaleString(undefined,{maximumFractionDigits:0}) : "--"; } |
|
|
function fmtPrice(x){ return Number.isFinite(x) ? x.toFixed(2) : "--"; } |
|
|
function fmtPct(x){ return Number.isFinite(x) ? (x.toFixed(2) + "%") : "--"; } |
|
|
|
|
|
function setStatus(status){ |
|
|
state.wsStatus = status; |
|
|
const dot = $("dot"); |
|
|
const txt = $("statusText"); |
|
|
|
|
|
dot.className = "dot"; |
|
|
if (status === "CONNECTED") dot.classList.add("connected"); |
|
|
else if (status === "CONNECTING") dot.classList.add("connecting"); |
|
|
else if (status === "ERROR") dot.classList.add("error"); |
|
|
|
|
|
txt.textContent = status[0] + status.slice(1).toLowerCase(); |
|
|
$("clientIdText").textContent = "· " + state.clientId; |
|
|
} |
|
|
|
|
|
function escapeHtml(s){ |
|
|
return String(s).replace(/[&<>"']/g, (c) => ({ |
|
|
"&":"&","<":"<",">":">",'"':""","'":"'" |
|
|
}[c])); |
|
|
} |
|
|
|
|
|
|
|
|
function setTab(which){ |
|
|
const tTrade = $("tabTrade"), tStats = $("tabStats"); |
|
|
const bTrade = $("tabBtnTrade"), bStats = $("tabBtnStats"); |
|
|
|
|
|
if (which === "trade"){ |
|
|
tTrade.classList.add("active"); |
|
|
tStats.classList.remove("active"); |
|
|
bTrade.classList.add("active"); |
|
|
bStats.classList.remove("active"); |
|
|
} else { |
|
|
tStats.classList.add("active"); |
|
|
tTrade.classList.remove("active"); |
|
|
bStats.classList.add("active"); |
|
|
bTrade.classList.remove("active"); |
|
|
} |
|
|
} |
|
|
$("tabBtnTrade").addEventListener("click", () => setTab("trade")); |
|
|
$("tabBtnStats").addEventListener("click", () => setTab("stats")); |
|
|
|
|
|
|
|
|
function sma(values, period){ |
|
|
if (values.length < period) return null; |
|
|
let sum = 0; |
|
|
for (let i = values.length - period; i < values.length; i++) sum += values[i]; |
|
|
return sum / period; |
|
|
} |
|
|
|
|
|
function rsi14(values, period=14){ |
|
|
if (values.length < period + 1) return null; |
|
|
let gains = 0, losses = 0; |
|
|
for (let i = values.length - period; i < values.length; i++){ |
|
|
const chg = values[i] - values[i - 1]; |
|
|
if (chg >= 0) gains += chg; |
|
|
else losses -= chg; |
|
|
} |
|
|
const avgGain = gains / period; |
|
|
const avgLoss = losses / period; |
|
|
if (avgLoss === 0) return 100; |
|
|
const rs = avgGain / avgLoss; |
|
|
return 100 - (100 / (1 + rs)); |
|
|
} |
|
|
|
|
|
|
|
|
function drawLineChart(canvas, series, opts){ |
|
|
const ctx = canvas.getContext("2d"); |
|
|
const w = canvas.width, h = canvas.height; |
|
|
ctx.clearRect(0,0,w,h); |
|
|
ctx.fillStyle = "rgba(0,0,0,0.08)"; |
|
|
ctx.fillRect(0,0,w,h); |
|
|
if (!series || series.length < 2) return; |
|
|
|
|
|
const pad = 30; |
|
|
const xs = series.map(p => p.x); |
|
|
const ys = series.map(p => p.y); |
|
|
|
|
|
const xmin = Math.min(...xs), xmax = Math.max(...xs); |
|
|
const ymin = Math.min(...ys), ymax = Math.max(...ys); |
|
|
const yr = (ymax - ymin) || 1; |
|
|
const xr = (xmax - xmin) || 1; |
|
|
|
|
|
ctx.strokeStyle = "rgba(170,178,213,0.12)"; |
|
|
ctx.lineWidth = 1; |
|
|
for (let i=0;i<=5;i++){ |
|
|
const yy = pad + (h-2*pad) * (i/5); |
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(pad, yy); |
|
|
ctx.lineTo(w-pad, yy); |
|
|
ctx.stroke(); |
|
|
} |
|
|
|
|
|
const toX = (x) => pad + (w-2*pad) * ((x - xmin)/xr); |
|
|
const toY = (y) => h - pad - (h-2*pad) * ((y - ymin)/yr); |
|
|
|
|
|
ctx.strokeStyle = opts.color || "#ffffff"; |
|
|
ctx.lineWidth = opts.width || 2; |
|
|
ctx.beginPath(); |
|
|
for (let i=0;i<series.length;i++){ |
|
|
const px = toX(series[i].x); |
|
|
const py = toY(series[i].y); |
|
|
if (i===0) ctx.moveTo(px,py); |
|
|
else ctx.lineTo(px,py); |
|
|
} |
|
|
ctx.stroke(); |
|
|
|
|
|
if (opts.overlays){ |
|
|
for (const ov of opts.overlays){ |
|
|
if (!ov.series || ov.series.length < 2) continue; |
|
|
ctx.strokeStyle = ov.color || "#60a5fa"; |
|
|
ctx.lineWidth = ov.width || 2; |
|
|
ctx.beginPath(); |
|
|
for (let i=0;i<ov.series.length;i++){ |
|
|
const px = toX(ov.series[i].x); |
|
|
const py = toY(ov.series[i].y); |
|
|
if (i===0) ctx.moveTo(px,py); |
|
|
else ctx.lineTo(px,py); |
|
|
} |
|
|
ctx.stroke(); |
|
|
} |
|
|
} |
|
|
|
|
|
ctx.fillStyle = "rgba(170,178,213,0.8)"; |
|
|
ctx.font = "18px ui-monospace, Menlo, Consolas"; |
|
|
ctx.fillText(opts.label || "", pad, 22); |
|
|
} |
|
|
|
|
|
|
|
|
function exposure(){ return Math.abs(state.shares) * state.price; } |
|
|
function requiredMargin(){ return exposure() / MAX_LEVERAGE; } |
|
|
function marginUtil(){ if (state.equity <= 0) return 1; return requiredMargin() / state.equity; } |
|
|
|
|
|
function applyBorrowFeeOneDay(){ |
|
|
if (state.shares < 0){ |
|
|
const fee = BORROW_RATE_PER_DAY * exposure(); |
|
|
state.cash -= fee; |
|
|
} |
|
|
} |
|
|
|
|
|
function breakEvenPrice(){ |
|
|
if (state.shares === 0) return null; |
|
|
return state.avgCost; |
|
|
} |
|
|
|
|
|
function recalc(){ |
|
|
applyBorrowFeeOneDay(); |
|
|
|
|
|
state.upnl = (state.price - state.avgCost) * state.shares; |
|
|
state.equity = state.cash + state.shares * state.price; |
|
|
state.roi = ((state.equity - INITIAL_CASH) / INITIAL_CASH) * 100; |
|
|
|
|
|
if (!state.liquidated && state.equity <= 0){ |
|
|
state.liquidated = true; |
|
|
state.liquidationDay = state.day; |
|
|
|
|
|
$("liqBox").style.display = "block"; |
|
|
$("buyBtn").disabled = true; |
|
|
$("sellBtn").disabled = true; |
|
|
$("closeBtn").disabled = true; |
|
|
$("setStopsBtn").disabled = true; |
|
|
$("clearStopsBtn").disabled = true; |
|
|
$("qty").disabled = true; |
|
|
$("stopLoss").disabled = true; |
|
|
$("takeProfit").disabled = true; |
|
|
} |
|
|
|
|
|
$("warnBox").style.display = (!state.liquidated && marginUtil() >= 0.80) ? "block" : "none"; |
|
|
|
|
|
state.equityCurve.push({ day: state.day, equity: state.equity }); |
|
|
if (state.equityCurve.length > 5000) state.equityCurve.shift(); |
|
|
|
|
|
if (!state.liquidated && state.shares !== 0){ |
|
|
if (state.stopLoss !== null){ |
|
|
if (state.shares > 0 && state.price <= state.stopLoss) closePosition("STOP"); |
|
|
if (state.shares < 0 && state.price >= state.stopLoss) closePosition("STOP"); |
|
|
} |
|
|
if (state.takeProfit !== null){ |
|
|
if (state.shares > 0 && state.price >= state.takeProfit) closePosition("TP"); |
|
|
if (state.shares < 0 && state.price <= state.takeProfit) closePosition("TP"); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function addLot(qty, price){ state.openLots.push({ qty, price }); } |
|
|
|
|
|
function consumeLots(closeQty, closePrice){ |
|
|
let remaining = Math.abs(closeQty); |
|
|
let realized = 0; |
|
|
|
|
|
for (let i=0; i<state.openLots.length && remaining>0; ){ |
|
|
const lot = state.openLots[i]; |
|
|
const lotAbs = Math.abs(lot.qty); |
|
|
const take = Math.min(lotAbs, remaining); |
|
|
|
|
|
if (lot.qty > 0) realized += (closePrice - lot.price) * take; |
|
|
else realized += (lot.price - closePrice) * take; |
|
|
|
|
|
const newAbs = lotAbs - take; |
|
|
if (newAbs === 0) state.openLots.splice(i,1); |
|
|
else { |
|
|
lot.qty = (lot.qty > 0) ? newAbs : -newAbs; |
|
|
i++; |
|
|
} |
|
|
remaining -= take; |
|
|
} |
|
|
return realized; |
|
|
} |
|
|
|
|
|
|
|
|
function canPlaceTrade(tradeShares){ |
|
|
if (state.liquidated) return false; |
|
|
if (!Number.isFinite(state.price) || state.price <= 0) return false; |
|
|
|
|
|
const px = state.price; |
|
|
const newShares = state.shares + tradeShares; |
|
|
const newExposure = Math.abs(newShares) * px; |
|
|
const req = newExposure / MAX_LEVERAGE; |
|
|
|
|
|
if (state.equity <= 0) return false; |
|
|
if (state.equity < req) return false; |
|
|
return true; |
|
|
} |
|
|
|
|
|
function trade(action, qty){ |
|
|
qty = Math.floor(Number(qty)); |
|
|
if (!Number.isFinite(qty) || qty <= 0) return; |
|
|
if (state.liquidated) return; |
|
|
|
|
|
const tradeShares = (action === "BUY") ? qty : -qty; |
|
|
if (!canPlaceTrade(tradeShares)) return; |
|
|
|
|
|
const px = state.price; |
|
|
|
|
|
const cashDelta = -(tradeShares * px) - COMMISSION; |
|
|
|
|
|
let realized = 0; |
|
|
const prevShares = state.shares; |
|
|
const newShares = prevShares + tradeShares; |
|
|
const prevDir = Math.sign(prevShares); |
|
|
const tradeDir = Math.sign(tradeShares); |
|
|
|
|
|
if (prevShares !== 0 && prevDir !== tradeDir){ |
|
|
realized = consumeLots(tradeShares, px); |
|
|
} |
|
|
|
|
|
state.cash += cashDelta; |
|
|
|
|
|
if (newShares === 0){ |
|
|
state.shares = 0; |
|
|
state.avgCost = 0; |
|
|
state.openLots = []; |
|
|
} else if (prevShares === 0){ |
|
|
state.shares = newShares; |
|
|
state.avgCost = px; |
|
|
addLot(tradeShares, px); |
|
|
} else { |
|
|
const sameDir = (prevShares > 0 && newShares > 0) || (prevShares < 0 && newShares < 0); |
|
|
|
|
|
if (sameDir && prevDir === tradeDir){ |
|
|
addLot(tradeShares, px); |
|
|
const totAbs = state.openLots.reduce((s,l)=>s+Math.abs(l.qty),0); |
|
|
const wavg = state.openLots.reduce((s,l)=>s+Math.abs(l.qty)*l.price,0) / (totAbs || 1); |
|
|
state.shares = newShares; |
|
|
state.avgCost = wavg; |
|
|
} else if (!sameDir){ |
|
|
state.shares = newShares; |
|
|
|
|
|
if (Math.sign(newShares) === 0){ |
|
|
state.avgCost = 0; |
|
|
state.openLots = []; |
|
|
} else if (Math.sign(newShares) !== prevDir){ |
|
|
state.openLots = [{ qty: newShares, price: px }]; |
|
|
state.avgCost = px; |
|
|
} else { |
|
|
const totAbs = state.openLots.reduce((s,l)=>s+Math.abs(l.qty),0); |
|
|
const wavg = totAbs ? (state.openLots.reduce((s,l)=>s+Math.abs(l.qty)*l.price,0) / totAbs) : 0; |
|
|
state.avgCost = wavg; |
|
|
} |
|
|
} else { |
|
|
state.shares = newShares; |
|
|
} |
|
|
} |
|
|
|
|
|
state.tradeLog.push({ action, qty, price: px, day: state.day, realized: realized - COMMISSION }); |
|
|
if (state.tradeLog.length > 5000) state.tradeLog.shift(); |
|
|
|
|
|
recalc(); |
|
|
renderAll(); |
|
|
} |
|
|
|
|
|
function closePosition(reason="CLOSE"){ |
|
|
if (state.liquidated) return; |
|
|
if (state.shares === 0) return; |
|
|
|
|
|
const qty = Math.abs(state.shares); |
|
|
const action = (state.shares > 0) ? "SELL" : "BUY"; |
|
|
trade(action, qty); |
|
|
const last = state.tradeLog[state.tradeLog.length - 1]; |
|
|
if (last) last.action = reason; |
|
|
} |
|
|
|
|
|
|
|
|
function renderLeaderboard(){ |
|
|
const lb = $("lbBody"); |
|
|
lb.innerHTML = ""; |
|
|
if (!state.leaderboard || !state.leaderboard.length){ |
|
|
lb.innerHTML = `<tr><td colspan="3" class="muted">Waiting for ticks…</td></tr>`; |
|
|
return; |
|
|
} |
|
|
for (const p of state.leaderboard.slice(0,50)){ |
|
|
const tr = document.createElement("tr"); |
|
|
const roiStyle = p.roi >= 0 ? `style="color:var(--green)"` : `style="color:var(--red)"`; |
|
|
tr.innerHTML = ` |
|
|
<td>${escapeHtml(p.name)}</td> |
|
|
<td class="right mono">${fmtMoney(p.equity)}</td> |
|
|
<td class="right mono" ${roiStyle}>${fmtPct(p.roi)}</td> |
|
|
`; |
|
|
lb.appendChild(tr); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderTrades(){ |
|
|
const tb = $("tradesBody"); |
|
|
tb.innerHTML = ""; |
|
|
if (!state.tradeLog.length){ |
|
|
tb.innerHTML = `<tr><td colspan="5" class="muted">No trades yet.</td></tr>`; |
|
|
return; |
|
|
} |
|
|
for (const t of state.tradeLog.slice().reverse().slice(0,250)){ |
|
|
const tr = document.createElement("tr"); |
|
|
const side = (t.action === "BUY") ? `<span class="pill buy">BUY</span>` : |
|
|
(t.action === "SELL") ? `<span class="pill sell">SELL</span>` : |
|
|
`<span class="pill">${escapeHtml(t.action)}</span>`; |
|
|
const pnlStyle = t.realized >= 0 ? `style="color:var(--green)"` : `style="color:var(--red)"`; |
|
|
tr.innerHTML = ` |
|
|
<td>${side}</td> |
|
|
<td class="right mono">${t.qty}</td> |
|
|
<td class="right mono">${fmtPrice(t.price)}</td> |
|
|
<td class="right mono">${t.day}</td> |
|
|
<td class="right mono" ${pnlStyle}>${fmtMoney(t.realized)}</td> |
|
|
`; |
|
|
tb.appendChild(tr); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderMetrics(){ |
|
|
const realized = state.tradeLog.map(t => t.realized); |
|
|
const n = realized.length; |
|
|
$("mTrades").textContent = n; |
|
|
|
|
|
if (!n){ |
|
|
$("mWinRate").textContent = "--"; |
|
|
$("mAvgWin").textContent = "--"; |
|
|
$("mAvgLoss").textContent = "--"; |
|
|
$("mBest").textContent = "--"; |
|
|
$("mWorst").textContent = "--"; |
|
|
$("mSharpe").textContent = "--"; |
|
|
return; |
|
|
} |
|
|
|
|
|
const wins = realized.filter(x => x > 0); |
|
|
const losses = realized.filter(x => x < 0); |
|
|
|
|
|
const winRate = (wins.length / n) * 100; |
|
|
const avgWin = wins.length ? wins.reduce((a,b)=>a+b,0)/wins.length : 0; |
|
|
const avgLoss = losses.length ? losses.reduce((a,b)=>a+b,0)/losses.length : 0; |
|
|
const best = Math.max(...realized); |
|
|
const worst = Math.min(...realized); |
|
|
|
|
|
$("mWinRate").textContent = fmtPct(winRate); |
|
|
$("mAvgWin").textContent = fmtMoney(avgWin); |
|
|
$("mAvgLoss").textContent = fmtMoney(avgLoss); |
|
|
$("mBest").textContent = fmtMoney(best); |
|
|
$("mWorst").textContent = fmtMoney(worst); |
|
|
|
|
|
const eq = state.equityCurve.slice(-300).map(p => p.equity); |
|
|
if (eq.length < 3){ $("mSharpe").textContent = "--"; return; } |
|
|
|
|
|
const rets = []; |
|
|
for (let i=1;i<eq.length;i++){ |
|
|
const r = (eq[i] - eq[i-1]) / Math.max(1e-9, eq[i-1]); |
|
|
rets.push(r); |
|
|
} |
|
|
const mean = rets.reduce((a,b)=>a+b,0)/rets.length; |
|
|
const varr = rets.reduce((s,x)=>s+(x-mean)*(x-mean),0)/Math.max(1, rets.length-1); |
|
|
const sd = Math.sqrt(varr); |
|
|
const sharpe = sd === 0 ? 0 : (mean / sd) * Math.sqrt(252); |
|
|
$("mSharpe").textContent = sharpe.toFixed(2); |
|
|
} |
|
|
|
|
|
function renderKPIs(){ |
|
|
$("kpiDay").textContent = state.day; |
|
|
$("kpiPrice").textContent = fmtPrice(state.price); |
|
|
|
|
|
$("kpiCash").textContent = fmtMoney(state.cash); |
|
|
$("kpiEquity").textContent = fmtMoney(state.equity); |
|
|
|
|
|
const roiEl = $("kpiRoi"); |
|
|
roiEl.textContent = fmtPct(state.roi); |
|
|
roiEl.style.color = (state.roi >= 0) ? "var(--green)" : "var(--red)"; |
|
|
|
|
|
$("kpiPos").textContent = state.shares.toLocaleString(); |
|
|
$("kpiAvg").textContent = fmtPrice(state.avgCost); |
|
|
|
|
|
const be = breakEvenPrice(); |
|
|
$("kpiBE").textContent = (be === null) ? "--" : fmtPrice(be); |
|
|
|
|
|
const up = $("kpiUPnL"); |
|
|
up.textContent = fmtMoney(state.upnl); |
|
|
up.style.color = (state.upnl >= 0) ? "var(--green)" : "var(--red)"; |
|
|
|
|
|
$("kpiLevMax").textContent = MAX_LEVERAGE.toFixed(1) + "x"; |
|
|
$("kpiMarginUsed").textContent = fmtMoney(requiredMargin()); |
|
|
$("kpiMarginPct").textContent = fmtPct(marginUtil() * 100); |
|
|
|
|
|
const borrowFee = (state.shares < 0) ? (BORROW_RATE_PER_DAY * exposure()) : 0; |
|
|
$("kpiBorrowFee").textContent = fmtMoney(borrowFee) + " / day"; |
|
|
|
|
|
$("kpiSL").textContent = (state.stopLoss === null) ? "--" : fmtPrice(state.stopLoss); |
|
|
$("kpiTP").textContent = (state.takeProfit === null) ? "--" : fmtPrice(state.takeProfit); |
|
|
} |
|
|
|
|
|
function renderCharts(){ |
|
|
if (!state.market.length) return; |
|
|
|
|
|
const closes = state.market.map(p => p.close); |
|
|
|
|
|
const start = Math.max(0, state.day - PRICE_WINDOW + 1); |
|
|
const end = state.day + 1; |
|
|
const windowData = state.market.slice(start, end); |
|
|
|
|
|
const priceSeries = windowData.map(p => ({ x: p.i, y: p.close })); |
|
|
|
|
|
const ma20 = []; |
|
|
const ma50 = []; |
|
|
for (let i=start;i<end;i++){ |
|
|
const slice = closes.slice(0, i+1); |
|
|
const s20 = sma(slice, 20); |
|
|
const s50 = sma(slice, 50); |
|
|
if (s20 !== null) ma20.push({ x: i, y: s20 }); |
|
|
if (s50 !== null) ma50.push({ x: i, y: s50 }); |
|
|
} |
|
|
|
|
|
drawLineChart($("priceCanvas"), priceSeries, { |
|
|
label: "PRICE", |
|
|
color: "#ffffff", |
|
|
overlays: [ |
|
|
{ series: ma20, color: "#60a5fa", width: 2 }, |
|
|
{ series: ma50, color: "#f59e0b", width: 2 } |
|
|
] |
|
|
}); |
|
|
|
|
|
const eqWin = state.equityCurve.slice(-EQUITY_WINDOW); |
|
|
const eqSeries = eqWin.map(p => ({ x: p.day, y: p.equity })); |
|
|
drawLineChart($("equityCanvas"), eqSeries, { label: "EQUITY", color: "#22c55e", overlays: [] }); |
|
|
|
|
|
const slice = closes.slice(0, state.day+1); |
|
|
const s20 = sma(slice, 20); |
|
|
const s50 = sma(slice, 50); |
|
|
const rsi = rsi14(slice, 14); |
|
|
$("kpiMA20").textContent = (s20 === null) ? "--" : fmtPrice(s20); |
|
|
$("kpiMA50").textContent = (s50 === null) ? "--" : fmtPrice(s50); |
|
|
$("kpiRSI").textContent = (rsi === null) ? "--" : rsi.toFixed(1); |
|
|
} |
|
|
|
|
|
function renderAll(){ |
|
|
renderKPIs(); |
|
|
renderCharts(); |
|
|
renderLeaderboard(); |
|
|
renderTrades(); |
|
|
renderMetrics(); |
|
|
} |
|
|
|
|
|
|
|
|
$("setStopsBtn").addEventListener("click", () => { |
|
|
if (state.liquidated) return; |
|
|
const sl = ($("stopLoss").value || "").trim(); |
|
|
const tp = ($("takeProfit").value || "").trim(); |
|
|
state.stopLoss = (sl === "") ? null : Number(sl); |
|
|
state.takeProfit = (tp === "") ? null : Number(tp); |
|
|
renderKPIs(); |
|
|
}); |
|
|
|
|
|
$("clearStopsBtn").addEventListener("click", () => { |
|
|
state.stopLoss = null; |
|
|
state.takeProfit = null; |
|
|
$("stopLoss").value = ""; |
|
|
$("takeProfit").value = ""; |
|
|
renderKPIs(); |
|
|
}); |
|
|
|
|
|
|
|
|
function qtyVal(){ return Number($("qty").value); } |
|
|
|
|
|
$("buyBtn").addEventListener("click", () => trade("BUY", qtyVal())); |
|
|
$("sellBtn").addEventListener("click", () => trade("SELL", qtyVal())); |
|
|
$("closeBtn").addEventListener("click", () => closePosition("CLOSE")); |
|
|
|
|
|
$("resetBtn").addEventListener("click", () => { |
|
|
state.cash = INITIAL_CASH; |
|
|
state.shares = 0; |
|
|
state.avgCost = 0; |
|
|
state.equity = INITIAL_CASH; |
|
|
state.roi = 0; |
|
|
state.upnl = 0; |
|
|
state.stopLoss = null; |
|
|
state.takeProfit = null; |
|
|
state.tradeLog = []; |
|
|
state.openLots = []; |
|
|
state.equityCurve = []; |
|
|
state.liquidated = false; |
|
|
state.liquidationDay = null; |
|
|
|
|
|
$("liqBox").style.display = "none"; |
|
|
$("buyBtn").disabled = false; |
|
|
$("sellBtn").disabled = false; |
|
|
$("closeBtn").disabled = false; |
|
|
$("setStopsBtn").disabled = false; |
|
|
$("clearStopsBtn").disabled = false; |
|
|
$("qty").disabled = false; |
|
|
$("stopLoss").disabled = false; |
|
|
$("takeProfit").disabled = false; |
|
|
|
|
|
$("stopLoss").value = ""; |
|
|
$("takeProfit").value = ""; |
|
|
|
|
|
recalc(); |
|
|
renderAll(); |
|
|
}); |
|
|
|
|
|
document.addEventListener("keydown", (e) => { |
|
|
if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return; |
|
|
const k = e.key.toLowerCase(); |
|
|
if (k === "b") trade("BUY", qtyVal()); |
|
|
if (k === "s") trade("SELL", qtyVal()); |
|
|
if (k === "c") closePosition("CLOSE"); |
|
|
if (k === "1") $("qty").value = "10"; |
|
|
if (k === "2") $("qty").value = "50"; |
|
|
if (k === "3") $("qty").value = "100"; |
|
|
if (k === "4") $("qty").value = "250"; |
|
|
if (k === "5") $("qty").value = "500"; |
|
|
}); |
|
|
|
|
|
$("qty").addEventListener("input", () => { |
|
|
let v = Math.floor(Number($("qty").value)); |
|
|
if (!Number.isFinite(v) || v < 1) v = 1; |
|
|
if (v > 1000000) v = 1000000; |
|
|
$("qty").value = String(v); |
|
|
}); |
|
|
|
|
|
|
|
|
function connectWS(){ |
|
|
const isLocalhost = (location.hostname === "localhost" || location.hostname === "127.0.0.1"); |
|
|
const protocol = isLocalhost ? "ws" : "wss"; |
|
|
const wsUrl = `${protocol}://${location.host}/ws/${state.clientId}`; |
|
|
|
|
|
setStatus("CONNECTING"); |
|
|
const ws = new WebSocket(wsUrl); |
|
|
state.ws = ws; |
|
|
|
|
|
ws.onmessage = (ev) => { |
|
|
let msg; |
|
|
try { msg = JSON.parse(ev.data); } catch { return; } |
|
|
|
|
|
if (msg.type === "INIT") { |
|
|
state.market = Array.isArray(msg.payload?.market) ? msg.payload.market : []; |
|
|
state.day = Number(msg.payload?.startDay ?? 0); |
|
|
state.price = Number(state.market?.[state.day]?.close ?? 0); |
|
|
|
|
|
recalc(); |
|
|
setStatus("CONNECTED"); |
|
|
renderAll(); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (msg.type === "TICK") { |
|
|
const d = Number(msg.payload?.day); |
|
|
if (Number.isFinite(d)) state.day = d; |
|
|
const px = Number(state.market?.[state.day]?.close ?? state.price); |
|
|
if (Number.isFinite(px)) state.price = px; |
|
|
|
|
|
state.leaderboard = Array.isArray(msg.payload?.leaderboard) ? msg.payload.leaderboard : state.leaderboard; |
|
|
|
|
|
recalc(); |
|
|
renderAll(); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (msg.type === "NEWS") { |
|
|
const txt = msg.payload?.text ?? ""; |
|
|
const day = msg.payload?.day ?? state.day; |
|
|
$("newsText").textContent = txt ? (`[Day ${day}] ${txt}`) : "No news yet."; |
|
|
} |
|
|
}; |
|
|
|
|
|
ws.onerror = () => setStatus("ERROR"); |
|
|
ws.onclose = () => setStatus("DISCONNECTED"); |
|
|
} |
|
|
|
|
|
|
|
|
setInterval(() => { |
|
|
if (state.liquidated) return; |
|
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return; |
|
|
|
|
|
state.ws.send(JSON.stringify({ |
|
|
type: "UPDATE_EQUITY", |
|
|
payload: { name: state.clientId, equity: state.equity, roi: state.roi } |
|
|
})); |
|
|
}, 1000); |
|
|
|
|
|
|
|
|
setStatus("DISCONNECTED"); |
|
|
renderAll(); |
|
|
connectWS(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|