commit 81ff3fb462f65ccb351e9bd18640f0319080968e
parent f8f07f6a390c6c7b1b89698a028a182b19631490
Author: ea <ea@ea.contact>
Date: Fri, 29 May 2026 12:18:41 +0000
New API
Diffstat:
| M | frontend/index.html | | | 724 | +++++++------------------------------------------------------------------------ |
| M | src/handlers.rs | | | 156 | ++++++++++++++++++++++++++++++++++++++++---------------------------------------- |
| M | src/main.rs | | | 3 | ++- |
| M | src/session.rs | | | 46 | ++++++++++++++++++++++++---------------------- |
| A | swagger.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…</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&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