| <!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> |
|
|