trtr

Trading simulator and techanalysis gym
git clone https://git.ea.contact/trtr
Log | Files | Refs | README

commit 81ff3fb462f65ccb351e9bd18640f0319080968e
parent f8f07f6a390c6c7b1b89698a028a182b19631490
Author: ea <ea@ea.contact>
Date:   Fri, 29 May 2026 12:18:41 +0000

New API

Diffstat:
Mfrontend/index.html | 724+++++++------------------------------------------------------------------------
Msrc/handlers.rs | 156++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/main.rs | 3++-
Msrc/session.rs | 46++++++++++++++++++++++++----------------------
Aswagger.yaml | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 518 insertions(+), 765 deletions(-)

diff --git a/frontend/index.html b/frontend/index.html @@ -140,6 +140,10 @@ } #help-close:hover { color: #c0c0e0; } + .pnl-positive { color: #26a69a; } + .pnl-negative { color: #ef5350; } + .pnl-zero { color: #e0e0e0; } + .main { display: flex; flex: 1; @@ -169,87 +173,6 @@ z-index: 10; } - .sidebar { - width: 220px; - flex-shrink: 0; - background: #16162a; - border-left: 1px solid #2a2a4a; - display: flex; - flex-direction: column; - padding: 16px; - gap: 20px; - } - - .portfolio { - display: flex; - flex-direction: column; - gap: 8px; - } - - .portfolio h2 { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 1px; - color: #505070; - margin-bottom: 2px; - } - - .stat { - display: flex; - flex-direction: column; - gap: 2px; - } - - .stat label { - font-size: 0.68rem; - color: #606080; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .stat span { - font-size: 0.95rem; - font-weight: 600; - font-variant-numeric: tabular-nums; - } - - .pnl-positive { color: #26a69a; } - .pnl-negative { color: #ef5350; } - .pnl-zero { color: #e0e0e0; } - - .controls { - display: flex; - flex-direction: column; - gap: 14px; - } - - .controls h2 { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 1px; - color: #505070; - margin-bottom: 2px; - } - - .trade-row { - display: flex; - flex-direction: column; - gap: 6px; - } - - .slider-label { - display: flex; - justify-content: space-between; - font-size: 0.72rem; - color: #808090; - } - - input[type=range] { - width: 100%; - accent-color: #f0b429; - cursor: pointer; - } - .fee-row { display: flex; align-items: center; @@ -275,23 +198,6 @@ } .fee-input:focus { outline: none; border-color: #f0b429; } - .btn { - width: 100%; - padding: 9px 0; - border: none; - border-radius: 6px; - font-size: 0.85rem; - font-weight: 700; - cursor: pointer; - letter-spacing: 0.5px; - transition: opacity 0.15s; - } - .btn:disabled { opacity: 0.4; cursor: not-allowed; } - .btn:not(:disabled):hover { opacity: 0.85; } - - .btn-buy { background: #26a69a; color: #0f0f1a; } - .btn-sell { background: #ef5350; color: #fff; } - footer { padding: 10px 18px; background: #16162a; @@ -363,130 +269,6 @@ letter-spacing: 0.5px; } - .trades-section { - display: flex; - flex-direction: column; - gap: 6px; - min-height: 0; - } - - .trades-section h2 { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 1px; - color: #505070; - flex-shrink: 0; - } - - #trades-list { - display: flex; - flex-direction: column; - gap: 4px; - overflow-y: auto; - max-height: 180px; - } - - #trades-list:empty::after { - content: 'No trades yet'; - font-size: 0.72rem; - color: #404060; - padding: 2px 0; - } - - .trade-line-item { - display: flex; - align-items: center; - gap: 6px; - background: #1e1e36; - border-radius: 4px; - padding: 4px 6px; - } - - .trade-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - } - - .trade-price { - flex: 1; - font-size: 0.78rem; - font-variant-numeric: tabular-nums; - color: #c0c0d0; - } - - .remove-btn { - background: none; - border: none; - color: #505070; - cursor: pointer; - font-size: 0.75rem; - padding: 1px 3px; - border-radius: 3px; - line-height: 1; - transition: color 0.1s; - flex-shrink: 0; - } - .remove-btn:hover { color: #ef5350; } - - .mode-toggle { - display: flex; - border: 1px solid #3a3a5a; - border-radius: 4px; - overflow: hidden; - margin: 0 auto; - } - - .mode-btn { - background: none; - border: none; - color: #505070; - font-size: 0.65rem; - padding: 2px 6px; - cursor: pointer; - transition: background 0.1s, color 0.1s; - } - .mode-btn.active { background: #3a3a5a; color: #e0e0e0; } - .mode-btn:not(.active):hover { color: #a0a0c0; } - - .qty-input { - width: 100%; - background: #1e1e36; - border: 1px solid #3a3a5a; - border-radius: 4px; - color: #e0e0e0; - font-size: 0.82rem; - padding: 4px 7px; - } - .qty-input:focus { outline: none; border-color: #f0b429; } - - .trade-line-item { - cursor: pointer; - transition: background 0.1s; - } - .trade-line-item:hover { background: #2a2a46; } - - .trade-line-child { - padding-left: 18px; - background: #181830; - } - .trade-line-child:hover { background: #20203a; } - - .sell-from-buy-btn { - background: #2a1010; - border: 1px solid #5a2020; - color: #ef5350; - border-radius: 3px; - font-size: 0.62rem; - font-weight: 700; - padding: 1px 5px; - cursor: pointer; - flex-shrink: 0; - transition: background 0.1s; - } - .sell-from-buy-btn:hover { background: #4a1818; } - /* ── Gym Mode ────────────────────────────────────────────────────────── */ @@ -644,84 +426,10 @@ <div class="main"> <div id="chart-container"> <div id="chart"></div> - <svg id="be-overlay" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible;z-index:1"></svg> <canvas id="gym-canvas"></canvas> <div id="loading-overlay">Loading data&hellip;</div> </div> - <div class="sidebar" id="sim-sidebar" style="display:none"> - <div class="portfolio"> - <h2>Portfolio</h2> - <div class="stat"> - <label>USD</label> - <span id="usd-val">$10,000.00</span> - </div> - <div class="stat"> - <label>BTC</label> - <span id="btc-val">0.00000000</span> - </div> - <div class="stat"> - <label>P&amp;L</label> - <span id="pnl-val" class="pnl-zero">$0.00</span> - </div> - <div class="stat"> - <label>Price</label> - <span id="price-val">—</span> - </div> - </div> - - <div class="controls"> - <h2>Trade</h2> - - <div class="fee-row"> - <label>Fee</label> - <span style="display:flex;align-items:center;gap:3px"> - <input type="number" id="fee-input" class="fee-input" - value="0.10" min="0" max="100" step="0.01"> - <span style="font-size:0.78rem;color:#606080">%</span> - </span> - </div> - - <div class="trade-row"> - <div class="slider-label"> - <span>Buy</span> - <span class="mode-toggle"> - <button class="mode-btn active" id="buy-mode-pct">%</button> - <button class="mode-btn" id="buy-mode-qty">USD</button> - </span> - <span id="buy-pct-label">100%</span> - </div> - <input type="range" id="buy-pct" min="1" max="100" value="100"> - <input type="number" id="buy-qty" class="qty-input" style="display:none" placeholder="USD amount" min="0" step="0.01"> - <button class="btn btn-buy" id="buy-btn" disabled>BUY</button> - </div> - - <div class="trade-row"> - <div class="slider-label"> - <span>Sell</span> - <span class="mode-toggle"> - <button class="mode-btn active" id="sell-mode-pct">%</button> - <button class="mode-btn" id="sell-mode-qty">BTC</button> - </span> - <span id="sell-pct-label">100%</span> - </div> - <input type="range" id="sell-pct" min="1" max="100" value="100"> - <input type="number" id="sell-qty" class="qty-input" style="display:none" placeholder="BTC amount" min="0" step="any"> - <button class="btn btn-sell" id="sell-btn" disabled>SELL</button> - </div> - - <div class="trades-section"> - <div style="display:flex;align-items:center;justify-content:space-between"> - <h2>Trade Lines</h2> - <div style="display:flex;gap:4px;align-items:center"> - <button id="auto-remove-btn" class="mode-btn" title="Auto-remove buy+sell pair after selling">auto-rm</button> - <button id="clear-lines-btn" class="remove-btn" style="font-size:0.68rem;padding:2px 6px">Clear</button> - </div> - </div> - <div id="trades-list"></div> - </div> - </div> - </div> <div id="gym-sidebar"> <div class="gym-section"> <h2>Drawing Tools</h2> @@ -800,244 +508,8 @@ let chart = null; let candleSeries = null; let currentPrice = 0; - let usdBalance = 10000; - let btcBalance = 0; let gymLastLogical = 0; - // ── Trade lines ─────────────────────────────────────────────────────────── - - const tradeLines = []; - let tradeLineId = 0; - - function addTradeLine(action, price, feePct, btcAcquired = 0) { - const color = action === 'buy' ? '#26a69a' : '#ef5350'; - const priceLine = candleSeries.createPriceLine({ - price, - color, - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Dashed, - axisLabelVisible: true, - title: action.toUpperCase(), - }); - - let breakEvenPrice = null; - let breakEvenLine = null; - if (action === 'buy' && feePct > 0) { - const k = 1 - feePct / 100; - breakEvenPrice = price / (k * k); - breakEvenLine = candleSeries.createPriceLine({ - price: breakEvenPrice, - color: '#26a69a', - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Dotted, - axisLabelVisible: false, - title: '', - }); - } - - const id = ++tradeLineId; - tradeLines.push({ - id, action, price, priceLine, breakEvenPrice, breakEvenLine, - btcAcquired, // BTC gained (buys only) - attachedSellId: null, // id of the paired sell entry - parentBuyId: null, // id of the parent buy (attached sells only) - }); - renderTradeLines(); - return id; - } - - function removeTradeLine(id) { - const t = tradeLines.find(x => x.id === id); - if (!t) return; - const partnerId = t.attachedSellId ?? t.parentBuyId ?? null; - - function dropEntry(entry) { - candleSeries.removePriceLine(entry.priceLine); - if (entry.breakEvenLine) candleSeries.removePriceLine(entry.breakEvenLine); - tradeLines.splice(tradeLines.findIndex(x => x.id === entry.id), 1); - } - - dropEntry(t); - if (partnerId !== null) { - const partner = tradeLines.find(x => x.id === partnerId); - if (partner) dropEntry(partner); - } - renderTradeLines(); - } - - function clearTradeLines() { - for (const t of tradeLines) { - candleSeries.removePriceLine(t.priceLine); - if (t.breakEvenLine) candleSeries.removePriceLine(t.breakEvenLine); - } - tradeLines.length = 0; - renderTradeLines(); - } - - // ── Break-even overlay ──────────────────────────────────────────────────── - - const SVG_NS = 'http://www.w3.org/2000/svg'; - let currentBreakEvenLine = null; - let currentBreakEvenPrice = 0; - - function syncCurrentBreakEven() { - if (!candleSeries) return; - const feePct = parseFloat(document.getElementById('fee-input').value) || 0; - if (feePct <= 0 || currentPrice <= 0) { - if (currentBreakEvenLine) { - candleSeries.removePriceLine(currentBreakEvenLine); - currentBreakEvenLine = null; - } - currentBreakEvenPrice = 0; - return; - } - const k = 1 - feePct / 100; - currentBreakEvenPrice = currentPrice / (k * k); - if (!currentBreakEvenLine) { - currentBreakEvenLine = candleSeries.createPriceLine({ - price: currentBreakEvenPrice, - color: '#505070', - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Dotted, - axisLabelVisible: false, - title: '', - }); - } else { - currentBreakEvenLine.applyOptions({ price: currentBreakEvenPrice }); - } - } - - function clearCurrentBreakEven() { - if (currentBreakEvenLine) { - candleSeries.removePriceLine(currentBreakEvenLine); - currentBreakEvenLine = null; - } - currentBreakEvenPrice = 0; - } - - function svgArrow(svg, x, y1, y2, color, opacity) { - const line = document.createElementNS(SVG_NS, 'line'); - line.setAttribute('x1', x); line.setAttribute('y1', Math.round(y1)); - line.setAttribute('x2', x); line.setAttribute('y2', Math.round(y2)); - line.setAttribute('stroke', color); - line.setAttribute('stroke-width', '1.5'); - line.setAttribute('stroke-opacity', opacity); - svg.appendChild(line); - const aw = 4, ah = 7, dir = y2 < y1 ? 1 : -1; - const poly = document.createElementNS(SVG_NS, 'polygon'); - poly.setAttribute('points', `${x},${y2} ${x-aw},${y2+dir*ah} ${x+aw},${y2+dir*ah}`); - poly.setAttribute('fill', color); - poly.setAttribute('fill-opacity', opacity); - svg.appendChild(poly); - } - - function drawBreakEvenOverlay() { - const svg = document.getElementById('be-overlay'); - while (svg.firstChild) svg.removeChild(svg.firstChild); - - if (candleSeries) { - const W = svg.clientWidth; - - // green arrows for individual buy trade lines - for (const t of tradeLines) { - if (t.action !== 'buy' || t.breakEvenPrice === null) continue; - const y1 = candleSeries.priceToCoordinate(t.price); - const y2 = candleSeries.priceToCoordinate(t.breakEvenPrice); - if (y1 !== null && y2 !== null) svgArrow(svg, W - 58, y1, y2, '#26a69a', '0.45'); - } - - // grey arrow for current-price break-even (sim mode only) - if (!gymMode && currentBreakEvenPrice > 0 && currentPrice > 0) { - const y1 = candleSeries.priceToCoordinate(currentPrice); - const y2 = candleSeries.priceToCoordinate(currentBreakEvenPrice); - if (y1 !== null && y2 !== null) svgArrow(svg, W - 72, y1, y2, '#606070', '0.5'); - } - } - - requestAnimationFrame(drawBreakEvenOverlay); - } - - requestAnimationFrame(drawBreakEvenOverlay); - - document.getElementById('fee-input').addEventListener('input', syncCurrentBreakEven); - - function renderTradeLines() { - const list = document.getElementById('trades-list'); - list.innerHTML = ''; - for (const t of tradeLines) { - if (t.parentBuyId !== null) continue; // rendered as child under its buy - - const isBuy = t.action === 'buy'; - const color = isBuy ? '#26a69a' : '#ef5350'; - const item = document.createElement('div'); - item.className = 'trade-line-item'; - item.dataset.id = t.id; - const showSell = isBuy && t.attachedSellId === null; - item.innerHTML = - `<span class="trade-dot" style="background:${color}"></span>` + - `<span class="trade-price">$${fmt(t.price)}</span>` + - (showSell ? `<button class="sell-from-buy-btn" data-buy-id="${t.id}">SELL</button>` : ''); - list.appendChild(item); - - if (isBuy && t.attachedSellId !== null) { - const sell = tradeLines.find(s => s.id === t.attachedSellId); - if (sell) { - const child = document.createElement('div'); - child.className = 'trade-line-item trade-line-child'; - child.dataset.id = t.id; // clicking child removes the whole pair via the buy id - child.innerHTML = - `<span class="trade-dot" style="background:#ef5350"></span>` + - `<span class="trade-price">$${fmt(sell.price)}</span>`; - list.appendChild(child); - } - } - } - } - - document.getElementById('trades-list').addEventListener('click', e => { - const sellBtn = e.target.closest('.sell-from-buy-btn'); - if (sellBtn) { sellFromBuy(Number(sellBtn.dataset.buyId)); return; } - const item = e.target.closest('.trade-line-item'); - if (item) removeTradeLine(Number(item.dataset.id)); - }); - - document.getElementById('clear-lines-btn').addEventListener('click', clearTradeLines); - - let autoRemoveAttached = false; - document.getElementById('auto-remove-btn').addEventListener('click', () => { - autoRemoveAttached = !autoRemoveAttached; - document.getElementById('auto-remove-btn').classList.toggle('active', autoRemoveAttached); - }); - - async function sellFromBuy(buyId) { - const buy = tradeLines.find(t => t.id === buyId); - if (!buy || buy.btcAcquired <= 0 || buy.attachedSellId !== null) return; - - const fee_pct = parseFloat(document.getElementById('fee-input').value) || 0; - const res = await fetch('/api/trade', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, action: 'sell', qty: buy.btcAcquired, fee_pct }), - }); - if (!res.ok) return; - const data = await res.json(); - - currentPrice = data.price; - updatePortfolio(data.usd, data.btc, data.pnl, data.price); - - const sellId = addTradeLine('sell', data.price, fee_pct, 0); - const sell = tradeLines.find(t => t.id === sellId); - sell.parentBuyId = buyId; - buy.attachedSellId = sellId; - - if (autoRemoveAttached) { - removeTradeLine(buyId); - } else { - renderTradeLines(); - } - syncCurrentBreakEven(); - } - // ── Chart init ──────────────────────────────────────────────────────────── function initChart() { @@ -1090,11 +562,12 @@ stopAutoPlay(); setEnabled(false); document.getElementById('loading-overlay').style.display = 'flex'; - if (candleSeries) { clearTradeLines(); clearCurrentBreakEven(); } - gymPrediction = null; - gymClosedPredictions.length = 0; + gymActivePrediction = null; + gymResolvedPredictions.length = 0; + gymDrawings.length = 0; gymScore = 0; - if (gymMode) { updatePredictionInfo(); updateGymScore(); } + updatePredictionInfo(); + updateGymScore(); const year = document.getElementById('year-select').value; const month = document.getElementById('month-select').value; @@ -1124,8 +597,6 @@ gymLastLogical = lwcCandles.length - 1; currentPrice = data.candles[data.candles.length - 1].close; - updatePortfolio(data.usd, data.btc, data.pnl, currentPrice); - syncCurrentBreakEven(); setEnabled(true); } catch (err) { document.getElementById('loading-overlay').textContent = 'Failed to connect to server.'; @@ -1135,7 +606,7 @@ document.getElementById('loading-overlay').style.display = 'none'; } - // ── Portfolio display ───────────────────────────────────────────────────── + // ── Helpers ─────────────────────────────────────────────────────────────── function fmt(n, decimals = 2) { return n.toLocaleString('en-US', { @@ -1144,81 +615,11 @@ }); } - function updatePortfolio(usd, btc, pnl, price) { - usdBalance = usd; - btcBalance = btc; - document.getElementById('usd-val').textContent = '$' + fmt(usd); - document.getElementById('btc-val').textContent = fmt(btc, 8); - document.getElementById('price-val').textContent = '$' + fmt(price); - - const pnlEl = document.getElementById('pnl-val'); - pnlEl.textContent = (pnl >= 0 ? '+' : '') + '$' + fmt(pnl); - pnlEl.className = pnl > 0 ? 'pnl-positive' : pnl < 0 ? 'pnl-negative' : 'pnl-zero'; - } - function setEnabled(enabled) { - document.getElementById('buy-btn').disabled = !enabled; - document.getElementById('sell-btn').disabled = !enabled; document.getElementById('next-btn').disabled = !enabled; document.getElementById('auto-play-btn').disabled = !enabled; } - // ── Sliders + mode toggle ───────────────────────────────────────────────── - - let buyMode = 'pct'; - let sellMode = 'pct'; - - function setMode(side, mode) { - if (side === 'buy') buyMode = mode; else sellMode = mode; - const isPct = mode === 'pct'; - document.getElementById(`${side}-pct`).style.display = isPct ? '' : 'none'; - document.getElementById(`${side}-pct-label`).style.display = isPct ? '' : 'none'; - document.getElementById(`${side}-qty`).style.display = isPct ? 'none' : ''; - document.getElementById(`${side}-mode-pct`).classList.toggle('active', isPct); - document.getElementById(`${side}-mode-qty`).classList.toggle('active', !isPct); - } - - document.getElementById('buy-mode-pct').addEventListener('click', () => setMode('buy', 'pct')); - document.getElementById('buy-mode-qty').addEventListener('click', () => setMode('buy', 'qty')); - document.getElementById('sell-mode-pct').addEventListener('click', () => setMode('sell', 'pct')); - document.getElementById('sell-mode-qty').addEventListener('click', () => setMode('sell', 'qty')); - - document.getElementById('buy-pct').addEventListener('input', e => { - document.getElementById('buy-pct-label').textContent = e.target.value + '%'; - }); - document.getElementById('sell-pct').addEventListener('input', e => { - document.getElementById('sell-pct-label').textContent = e.target.value + '%'; - }); - - // ── Trade ───────────────────────────────────────────────────────────────── - - async function trade(action) { - const fee_pct = parseFloat(document.getElementById('fee-input').value) || 0; - const mode = action === 'buy' ? buyMode : sellMode; - const payload = { session_id: sessionId, action, fee_pct }; - if (mode === 'pct') { - payload.pct = parseFloat(document.getElementById(`${action}-pct`).value); - } else { - payload.qty = parseFloat(document.getElementById(`${action}-qty`).value) || 0; - } - - const prevBtc = btcBalance; - const res = await fetch('/api/trade', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await res.json(); - const btcAcquired = action === 'buy' ? Math.max(0, data.btc - prevBtc) : 0; - currentPrice = data.price; - updatePortfolio(data.usd, data.btc, data.pnl, data.price); - addTradeLine(action, data.price, fee_pct, btcAcquired); - syncCurrentBreakEven(); - } - - document.getElementById('buy-btn').addEventListener('click', () => trade('buy')); - document.getElementById('sell-btn').addEventListener('click', () => trade('sell')); - // ── Next candle ─────────────────────────────────────────────────────────── document.getElementById('next-btn').addEventListener('click', async () => { @@ -1240,15 +641,8 @@ chart.timeScale().scrollToRealTime(); gymLastLogical++; currentPrice = c.close; - updatePortfolio(data.usd, data.btc, data.pnl, c.close); - syncCurrentBreakEven(); - if (gymMode) { - if (gymPrediction && gymLastLogical >= gymPrediction.logTarget) { - resolveGymPrediction(c.close); - } else { - updatePredictionInfo(); - } - } + if (data.resolved) handleResolvedPrediction(data.resolved); + else updatePredictionInfo(); }); // ── Auto-play ───────────────────────────────────────────────────────────── @@ -1288,15 +682,8 @@ chart.timeScale().scrollToRealTime(); gymLastLogical++; currentPrice = c.close; - updatePortfolio(data.usd, data.btc, data.pnl, c.close); - syncCurrentBreakEven(); - if (gymMode) { - if (gymPrediction && gymLastLogical >= gymPrediction.logTarget) { - resolveGymPrediction(c.close); - } else { - updatePredictionInfo(); - } - } + if (data.resolved) handleResolvedPrediction(data.resolved); + else updatePredictionInfo(); if (autoPlayActive) { autoPlayTimer = setTimeout(autoPlayStep, 120); @@ -1334,10 +721,10 @@ let gymMode = true; let gymFastForwarding = false; - let gymActiveTool = null; // null = no tool, chart pans/zooms freely + let gymActiveTool = null; const gymDrawings = []; - let gymPrediction = null; // { logTarget, entryPrice, direction: 'long'|'short' } - const gymClosedPredictions = []; // { logTarget, entryPrice, closePrice, direction, profit } + let gymActivePrediction = null; // { logTarget, entryPrice, direction } + const gymResolvedPredictions = []; let gymScore = 0; let gymIsDrawing = false; let gymCurrentPoints = []; @@ -1450,13 +837,13 @@ function updatePredictionInfo() { const el = document.getElementById('prediction-info'); const ffBtn = document.getElementById('ff-to-position-btn'); - if (!gymPrediction) { + if (!gymActivePrediction) { el.textContent = 'No prediction set'; el.classList.remove('has-pred'); if (ffBtn) ffBtn.disabled = true; return; } - const { logTarget, entryPrice, direction } = gymPrediction; + const { logTarget, entryPrice, direction } = gymActivePrediction; const barsLeft = Math.max(0, logTarget - gymLastLogical); const dir = direction === 'long' ? '▲ LONG' : '▼ SHORT'; const col = direction === 'long' ? '#26a69a' : '#ef5350'; @@ -1475,21 +862,18 @@ el.style.cssText += ';font-size:1.1rem;font-weight:700;font-variant-numeric:tabular-nums'; } - function resolveGymPrediction(closePrice) { - if (!gymPrediction) return; - const { logTarget, entryPrice, direction } = gymPrediction; - const feePct = parseFloat(document.getElementById('gym-fee-input').value) || 0; - const f = feePct / 100; - // fees applied twice (open + close), compounded - const feeFactor = (1 - f) * (1 - f); - const grossPct = direction === 'long' - ? (closePrice - entryPrice) / entryPrice - : (entryPrice - closePrice) / entryPrice; - const profit = grossPct * feeFactor * 1000; - if (gymClosedPredictions.length >= 10) gymClosedPredictions.shift(); - gymClosedPredictions.push({ logTarget, entryPrice, closePrice, direction, profit }); - gymScore += profit; - gymPrediction = null; + function handleResolvedPrediction(resolved) { + const logTarget = gymActivePrediction ? gymActivePrediction.logTarget : gymLastLogical; + if (gymResolvedPredictions.length >= 10) gymResolvedPredictions.shift(); + gymResolvedPredictions.push({ + logTarget, + entryPrice: resolved.entry_price, + closePrice: resolved.close_price, + direction: resolved.direction, + profit: resolved.profit, + }); + gymScore += resolved.profit; + gymActivePrediction = null; updatePredictionInfo(); updateGymScore(); } @@ -1572,12 +956,12 @@ if (!gymMode) return; // Active prediction marker - if (gymPrediction) { - drawPredictionMarker(ctx, gymPrediction, null); + if (gymActivePrediction) { + drawPredictionMarker(ctx, gymActivePrediction, null); } - // Closed prediction markers - for (const cp of gymClosedPredictions) { + // Resolved prediction markers + for (const cp of gymResolvedPredictions) { drawPredictionMarker(ctx, cp, cp.profit); } @@ -1662,19 +1046,25 @@ }); document.getElementById('clear-prediction-btn').addEventListener('click', () => { - gymPrediction = null; + if (!gymActivePrediction || !sessionId) return; + fetch('/api/predict/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }), + }); + gymActivePrediction = null; updatePredictionInfo(); }); async function gymFastForwardToClose() { - if (!gymPrediction || gymFastForwarding) return; + if (!gymActivePrediction || gymFastForwarding) return; stopAutoPlay(); gymFastForwarding = true; document.getElementById('ff-to-position-btn').disabled = true; document.getElementById('next-btn').disabled = true; document.getElementById('auto-play-btn').disabled = true; - while (gymPrediction && gymLastLogical < gymPrediction.logTarget) { + while (gymActivePrediction && gymLastLogical < gymActivePrediction.logTarget) { const res = await fetch('/api/next', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1693,11 +1083,9 @@ chart.timeScale().scrollToRealTime(); gymLastLogical++; currentPrice = c.close; - updatePortfolio(data.usd, data.btc, data.pnl, c.close); - syncCurrentBreakEven(); - if (gymPrediction && gymLastLogical >= gymPrediction.logTarget) { - resolveGymPrediction(c.close); + if (data.resolved) { + handleResolvedPrediction(data.resolved); break; } else { updatePredictionInfo(); @@ -1746,14 +1134,22 @@ const y = e.clientY - r.top; const { logical, price } = pxToChart(x, y); if (logical == null || price == null) return; - if (Math.round(logical) > gymLastLogical) { - gymPrediction = { - logTarget: Math.round(logical), - entryPrice: currentPrice, - direction: price > currentPrice ? 'long' : 'short', + const barsAhead = Math.round(logical) - gymLastLogical; + if (barsAhead <= 0) return; + const direction = price > currentPrice ? 'long' : 'short'; + const feePct = parseFloat(document.getElementById('gym-fee-input').value) || 0; + fetch('/api/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, bars_ahead: barsAhead, direction, fee_pct: feePct }), + }).then(r => r.json()).then(data => { + gymActivePrediction = { + logTarget: gymLastLogical + barsAhead, + entryPrice: data.entry_price, + direction, }; updatePredictionInfo(); - } + }); }, true); // ── Boot ────────────────────────────────────────────────────────────────── diff --git a/src/handlers.rs b/src/handlers.rs @@ -11,7 +11,7 @@ use rand::{Rng, rng}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{data::AppData, session}; +use crate::{data::AppData, session::{self, ActivePrediction, Direction}}; #[derive(Clone)] pub struct AppState { @@ -19,22 +19,7 @@ pub struct AppState { pub sessions: Arc<DashMap<String, session::SessionState>>, } -// ── /api/session/new ──────────────────────────────────────────────────────── - -#[derive(Deserialize, Default)] -pub struct NewSessionRequest { - pub year: Option<i32>, - pub month: Option<u32>, -} - -#[derive(Serialize)] -pub struct NewSessionResponse { - session_id: String, - candles: Vec<crate::data::Candle>, - usd: f64, - btc: f64, - pnl: f64, -} +// ── helpers ─────────────────────────────────────────────────────────────────── fn is_leap(y: i32) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 @@ -49,7 +34,6 @@ fn month_days(y: i32, m: u32) -> i64 { } } -// Unix timestamp of the first second of the given year+month (UTC). fn period_start_ts(year: i32, month: u32) -> i64 { let mut days: i64 = 0; for y in 1970..year { @@ -72,6 +56,20 @@ fn candle_range(candles: &[crate::data::Candle], year: i32, month: Option<u32>) (lo, hi) } +// ── /api/session/new ────────────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] +pub struct NewSessionRequest { + pub year: Option<i32>, + pub month: Option<u32>, +} + +#[derive(Serialize)] +pub struct NewSessionResponse { + session_id: String, + candles: Vec<crate::data::Candle>, +} + pub async fn new_session( State(state): State<AppState>, Json(req): Json<NewSessionRequest>, @@ -87,8 +85,7 @@ pub async fn new_session( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "not_enough_data"})), - ) - .into_response(); + ).into_response(); } let max = hi.saturating_sub(100).min(total.saturating_sub(5100)).max(lo); rng.random_range(lo..=max) @@ -97,22 +94,13 @@ pub async fn new_session( let s = session::SessionState::new(start); let candles = state.data.candles[start..=s.current_index].to_vec(); - let pnl = session::compute_pnl(&s, candles.last().unwrap().close); - let id = Uuid::new_v4().to_string(); state.sessions.insert(id.clone(), s); - Json(NewSessionResponse { - session_id: id, - candles, - usd: 10_000.0, - btc: 0.0, - pnl, - }) - .into_response() + Json(NewSessionResponse { session_id: id, candles }).into_response() } -// ── /api/next ─────────────────────────────────────────────────────────────── +// ── /api/next ───────────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct SessionIdBody { @@ -120,11 +108,18 @@ pub struct SessionIdBody { } #[derive(Serialize)] +pub struct ResolvedPrediction { + entry_price: f64, + close_price: f64, + direction: Direction, + fee_pct: f64, + profit: f64, +} + +#[derive(Serialize)] pub struct NextCandleResponse { candle: crate::data::Candle, - usd: f64, - btc: f64, - pnl: f64, + resolved: Option<ResolvedPrediction>, } pub async fn next_candle( @@ -142,74 +137,79 @@ pub async fn next_candle( s.current_index = next; let candle = state.data.candles[next].clone(); - let pnl = session::compute_pnl(&s, candle.close); - let usd = s.usd; - let btc = s.btc; + + let resolved = if s.prediction.as_ref().map_or(false, |p| next >= p.target_index) { + let pred = s.prediction.take().unwrap(); + let profit = session::resolve_prediction(&pred, candle.close); + Some(ResolvedPrediction { + entry_price: pred.entry_price, + close_price: candle.close, + direction: pred.direction, + fee_pct: pred.fee_pct, + profit, + }) + } else { + None + }; drop(s); - Json(NextCandleResponse { candle, usd, btc, pnl }).into_response() + Json(NextCandleResponse { candle, resolved }).into_response() } -// ── /api/trade ────────────────────────────────────────────────────────────── +// ── /api/predict ────────────────────────────────────────────────────────────── #[derive(Deserialize)] -pub struct TradeBody { +pub struct PredictBody { session_id: String, - action: String, - pct: Option<f64>, - qty: Option<f64>, + bars_ahead: u32, + direction: Direction, fee_pct: f64, } #[derive(Serialize)] -pub struct TradeResponse { - usd: f64, - btc: f64, - pnl: f64, - price: f64, +pub struct PredictResponse { + entry_price: f64, } -pub async fn trade( +pub async fn predict( State(state): State<AppState>, - Json(body): Json<TradeBody>, + Json(body): Json<PredictBody>, ) -> impl IntoResponse { + if body.bars_ahead == 0 { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "bars_ahead_must_be_positive"}))).into_response(); + } + let Some(mut s) = state.sessions.get_mut(&body.session_id) else { return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "session_not_found"}))).into_response(); }; - let price = state.data.candles[s.current_index].close; - let fee = body.fee_pct.clamp(0.0, 100.0); + let entry_price = state.data.candles[s.current_index].close; + s.prediction = Some(ActivePrediction { + target_index: s.current_index + body.bars_ahead as usize, + entry_price, + direction: body.direction, + fee_pct: body.fee_pct.clamp(0.0, 100.0), + }); + drop(s); - match body.action.as_str() { - "buy" => { - let amount = match body.qty { - Some(q) => q.max(0.0), - None => s.usd * body.pct.unwrap_or(100.0).clamp(0.0, 100.0) / 100.0, - }; - session::execute_buy(&mut s, amount, price, fee); - } - "sell" => { - let amount = match body.qty { - Some(q) => q.max(0.0), - None => s.btc * body.pct.unwrap_or(100.0).clamp(0.0, 100.0) / 100.0, - }; - session::execute_sell(&mut s, amount, price, fee); - } - _ => { - drop(s); - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid_action"}))).into_response(); - } - } + Json(PredictResponse { entry_price }).into_response() +} - let pnl = session::compute_pnl(&s, price); - let usd = s.usd; - let btc = s.btc; - drop(s); +// ── /api/predict/cancel ─────────────────────────────────────────────────────── - Json(TradeResponse { usd, btc, pnl, price }).into_response() +pub async fn cancel_predict( + State(state): State<AppState>, + Json(body): Json<SessionIdBody>, +) -> impl IntoResponse { + let Some(mut s) = state.sessions.get_mut(&body.session_id) else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "session_not_found"}))).into_response(); + }; + s.prediction = None; + drop(s); + Json(serde_json::json!({})).into_response() } -// ── / ──────────────────────────────────────────────────────────────────────── +// ── / ───────────────────────────────────────────────────────────────────────── pub async fn serve_html() -> impl IntoResponse { Html(include_str!("../frontend/index.html")) diff --git a/src/main.rs b/src/main.rs @@ -34,7 +34,8 @@ async fn main() { .route("/", get(handlers::serve_html)) .route("/api/session/new", post(handlers::new_session)) .route("/api/next", post(handlers::next_candle)) - .route("/api/trade", post(handlers::trade)) + .route("/api/predict", post(handlers::predict)) + .route("/api/predict/cancel", post(handlers::cancel_predict)) .with_state(state) .layer(tower_http::compression::CompressionLayer::new()); diff --git a/src/session.rs b/src/session.rs @@ -1,19 +1,30 @@ use rand::Rng; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + Long, + Short, +} + +pub struct ActivePrediction { + pub target_index: usize, + pub entry_price: f64, + pub direction: Direction, + pub fee_pct: f64, +} pub struct SessionState { pub current_index: usize, - pub usd: f64, - pub btc: f64, - pub initial_usd: f64, + pub prediction: Option<ActivePrediction>, } impl SessionState { pub fn new(start_index: usize) -> Self { Self { current_index: start_index + 99, - usd: 10_000.0, - btc: 0.0, - initial_usd: 10_000.0, + prediction: None, } } } @@ -23,20 +34,11 @@ pub fn random_start_index(total: usize, rng: &mut impl Rng) -> usize { rng.random_range(0..=max) } -pub fn compute_pnl(state: &SessionState, current_price: f64) -> f64 { - (state.usd + state.btc * current_price) - state.initial_usd -} - -pub fn execute_buy(state: &mut SessionState, usd_spend: f64, price: f64, fee_pct: f64) { - let spend = usd_spend.min(state.usd).max(0.0); - let btc_gained = (spend / price) * (1.0 - fee_pct / 100.0); - state.usd -= spend; - state.btc += btc_gained; -} - -pub fn execute_sell(state: &mut SessionState, btc_sell: f64, price: f64, fee_pct: f64) { - let sold = btc_sell.min(state.btc).max(0.0); - let usd_gained = sold * price * (1.0 - fee_pct / 100.0); - state.btc -= sold; - state.usd += usd_gained; +pub fn resolve_prediction(pred: &ActivePrediction, close_price: f64) -> f64 { + let gross_pct = match pred.direction { + Direction::Long => (close_price - pred.entry_price) / pred.entry_price, + Direction::Short => (pred.entry_price - close_price) / pred.entry_price, + }; + let fee_factor = (1.0 - pred.fee_pct / 100.0).powi(2); + gross_pct * fee_factor * 100.0 } diff --git a/swagger.yaml b/swagger.yaml @@ -0,0 +1,354 @@ +openapi: 3.0.3 +info: + title: BTC/USD Prediction Game API + description: > + Serves sequential 1-minute BTC/USD candles from a randomly selected window + of real Bitstamp data. Clients place a directional prediction (long/short) + at a fixed future bar, advance the chart one candle at a time, and receive + the resolved profit/loss when the target bar is reached. + + Profit formula: `gross_pct × (1 − fee_pct/100)² × 100` + where `gross_pct = (close − entry) / entry` for longs and + `(entry − close) / entry` for shorts. The notional position size is $100. + version: 1.0.0 + +servers: + - url: http://localhost:3000 + +paths: + /: + get: + summary: Serve the frontend + description: Returns the single-page HTML application. + responses: + '200': + description: HTML page + content: + text/html: + schema: + type: string + + /api/session/new: + post: + summary: Start a new session + description: > + Picks a random starting position in the dataset (optionally constrained + to a year and/or month) and returns the first 100 candles of that window. + Any existing prediction on a previous session is not carried over — + sessions are independent. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewSessionRequest' + examples: + random: + summary: Fully random start + value: {} + year_only: + summary: Random start within 2021 + value: { year: 2021 } + year_and_month: + summary: Random start within March 2020 + value: { year: 2020, month: 3 } + responses: + '200': + description: Session created + content: + application/json: + schema: + $ref: '#/components/schemas/NewSessionResponse' + '400': + description: Fewer than 100 candles available for the requested period + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: not_enough_data + + /api/next: + post: + summary: Advance by one candle + description: > + Moves the session forward by one candle and returns it. + If the session has an active prediction whose target bar is now reached + (or passed), the prediction is resolved and the result is included in the + response; the session's prediction slot is cleared. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SessionIdBody' + responses: + '200': + description: Next candle (and optionally a resolved prediction) + content: + application/json: + schema: + $ref: '#/components/schemas/NextCandleResponse' + '404': + description: Session not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: session_not_found + '409': + description: No more candles available (end of dataset window) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: end_of_data + + /api/predict: + post: + summary: Place a prediction + description: > + Records a long or short prediction that resolves `bars_ahead` candles + from now. The entry price is the close of the current candle (server-authoritative). + Replaces any existing active prediction on the session. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PredictRequest' + examples: + long_5_bars: + summary: Long prediction 5 bars ahead, 0.1% fee + value: + session_id: "550e8400-e29b-41d4-a716-446655440000" + bars_ahead: 5 + direction: long + fee_pct: 0.10 + short_20_bars: + summary: Short prediction 20 bars ahead, no fee + value: + session_id: "550e8400-e29b-41d4-a716-446655440000" + bars_ahead: 20 + direction: short + fee_pct: 0.0 + responses: + '200': + description: Prediction accepted; returns the server-side entry price + content: + application/json: + schema: + $ref: '#/components/schemas/PredictResponse' + '400': + description: bars_ahead is zero + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: bars_ahead_must_be_positive + '404': + description: Session not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: session_not_found + + /api/predict/cancel: + post: + summary: Cancel the active prediction + description: Clears the active prediction on the session. No-op if there is none. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SessionIdBody' + responses: + '200': + description: Prediction cleared + content: + application/json: + schema: + type: object + example: {} + '404': + description: Session not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: session_not_found + +components: + schemas: + + Candle: + type: object + required: [ts, open, high, low, close, volume] + properties: + ts: + type: integer + format: int64 + description: Unix timestamp in seconds (UTC) + example: 1609459200 + open: + type: number + format: double + example: 29300.50 + high: + type: number + format: double + example: 29450.00 + low: + type: number + format: double + example: 29280.10 + close: + type: number + format: double + example: 29400.75 + volume: + type: number + format: double + example: 12.345678 + + Direction: + type: string + enum: [long, short] + description: > + `long` — profit when close > entry; + `short` — profit when close < entry. + + ResolvedPrediction: + type: object + required: [entry_price, close_price, direction, fee_pct, profit] + properties: + entry_price: + type: number + format: double + description: Close price at the time the prediction was placed + example: 29400.75 + close_price: + type: number + format: double + description: Close price of the target candle + example: 29850.00 + direction: + $ref: '#/components/schemas/Direction' + fee_pct: + type: number + format: double + description: Fee percentage that was applied + example: 0.10 + profit: + type: number + format: double + description: > + Dollar P&L on a $100 notional position: + `gross_pct × (1 − fee_pct/100)² × 100` + example: 1.53 + + NewSessionRequest: + type: object + properties: + year: + type: integer + description: Constrain the start to this calendar year (2012–2026) + example: 2021 + month: + type: integer + minimum: 1 + maximum: 12 + description: Constrain the start to this month within `year` (requires year) + example: 6 + + NewSessionResponse: + type: object + required: [session_id, candles] + properties: + session_id: + type: string + format: uuid + description: Opaque session identifier; include in all subsequent requests + example: "550e8400-e29b-41d4-a716-446655440000" + candles: + type: array + description: Initial 100 candles of the session window + items: + $ref: '#/components/schemas/Candle' + + SessionIdBody: + type: object + required: [session_id] + properties: + session_id: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + + NextCandleResponse: + type: object + required: [candle, resolved] + properties: + candle: + $ref: '#/components/schemas/Candle' + resolved: + description: > + Present when this candle reached (or passed) the prediction's target + bar; `null` otherwise. + oneOf: + - $ref: '#/components/schemas/ResolvedPrediction' + - type: 'null' + + PredictRequest: + type: object + required: [session_id, bars_ahead, direction, fee_pct] + properties: + session_id: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + bars_ahead: + type: integer + format: int32 + minimum: 1 + description: How many candles from now the prediction should resolve + example: 10 + direction: + $ref: '#/components/schemas/Direction' + fee_pct: + type: number + format: double + minimum: 0 + maximum: 100 + description: Round-trip fee percentage (applied on open and close) + example: 0.10 + + PredictResponse: + type: object + required: [entry_price] + properties: + entry_price: + type: number + format: double + description: Server-authoritative close price used as the entry for this prediction + example: 29400.75 + + Error: + type: object + required: [error] + properties: + error: + type: string + enum: + - not_enough_data + - session_not_found + - end_of_data + - bars_ahead_must_be_positive + example: session_not_found