trtr

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

commit d5b1593b4d2cee8e967368fffd26a4669ce651f0
parent 290f1f1670932964082754b7fbe1dadc68102afc
Author: ea <ea@ea.contact>
Date:   Sun, 24 May 2026 14:43:05 +0000

frontend stuff

Diffstat:
Mfrontend/index.html | 397++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/handlers.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/session.rs | 12++++++------
3 files changed, 462 insertions(+), 41 deletions(-)

diff --git a/frontend/index.html b/frontend/index.html @@ -34,6 +34,24 @@ letter-spacing: 0.5px; } + .header-controls { + display: flex; + align-items: center; + gap: 6px; + } + + .period-select { + background: #1e1e36; + color: #a0a0c0; + border: 1px solid #3a3a5a; + border-radius: 6px; + padding: 5px 8px; + font-size: 0.8rem; + cursor: pointer; + } + .period-select:focus { outline: none; border-color: #f0b429; } + .period-select option { background: #1e1e36; } + #new-session-btn { background: #2a2a4a; color: #a0a0c0; @@ -287,18 +305,111 @@ 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; } </style> </head> <body> <header> <h1>BTC/USD Simulator</h1> - <button id="new-session-btn">New Session</button> + <div class="header-controls"> + <select id="year-select" class="period-select"> + <option value="">Any year</option> + <option value="2012">2012</option> + <option value="2013">2013</option> + <option value="2014">2014</option> + <option value="2015">2015</option> + <option value="2016">2016</option> + <option value="2017">2017</option> + <option value="2018">2018</option> + <option value="2019">2019</option> + <option value="2020">2020</option> + <option value="2021">2021</option> + <option value="2022">2022</option> + <option value="2023">2023</option> + <option value="2024">2024</option> + <option value="2025">2025</option> + <option value="2026">2026</option> + </select> + <select id="month-select" class="period-select" disabled> + <option value="">Any month</option> + <option value="1">Jan</option> + <option value="2">Feb</option> + <option value="3">Mar</option> + <option value="4">Apr</option> + <option value="5">May</option> + <option value="6">Jun</option> + <option value="7">Jul</option> + <option value="8">Aug</option> + <option value="9">Sep</option> + <option value="10">Oct</option> + <option value="11">Nov</option> + <option value="12">Dec</option> + </select> + <button id="new-session-btn">New Session</button> + </div> </header> <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> <div id="loading-overlay">Loading data&hellip;</div> </div> @@ -327,7 +438,7 @@ <h2>Trade</h2> <div class="fee-row"> - <label>Buy fee</label> + <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"> @@ -337,26 +448,39 @@ <div class="trade-row"> <div class="slider-label"> - <span>Buy %</span> + <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>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> - <button id="clear-lines-btn" class="remove-btn" style="font-size:0.68rem;padding:2px 6px">Clear</button> + <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> @@ -373,13 +497,15 @@ let chart = null; let candleSeries = null; let currentPrice = 0; + let usdBalance = 10000; + let btcBalance = 0; // ── Trade lines ─────────────────────────────────────────────────────────── const tradeLines = []; let tradeLineId = 0; - function addTradeLine(action, price) { + function addTradeLine(action, price, feePct, btcAcquired = 0) { const color = action === 'buy' ? '#26a69a' : '#ef5350'; const priceLine = candleSeries.createPriceLine({ price, @@ -389,46 +515,225 @@ axisLabelVisible: true, title: action.toUpperCase(), }); - tradeLines.push({ id: ++tradeLineId, action, price, priceLine }); + + 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 idx = tradeLines.findIndex(t => t.id === id); - if (idx === -1) return; - candleSeries.removePriceLine(tradeLines[idx].priceLine); - tradeLines.splice(idx, 1); + 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); + 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 + if (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) { - const color = t.action === 'buy' ? '#26a69a' : '#ef5350'; + 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>` + - `<button class="remove-btn" data-id="${t.id}">✕</button>`; + (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 btn = e.target.closest('.remove-btn'); - if (btn) removeTradeLine(Number(btn.dataset.id)); + 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() { @@ -471,13 +776,29 @@ // ── Session ─────────────────────────────────────────────────────────────── + document.getElementById('year-select').addEventListener('change', e => { + const monthSel = document.getElementById('month-select'); + monthSel.disabled = !e.target.value; + if (!e.target.value) monthSel.value = ''; + }); + async function startSession() { setEnabled(false); document.getElementById('loading-overlay').style.display = 'flex'; - if (candleSeries) clearTradeLines(); + if (candleSeries) { clearTradeLines(); clearCurrentBreakEven(); } + + const year = document.getElementById('year-select').value; + const month = document.getElementById('month-select').value; + const body = {}; + if (year) body.year = parseInt(year, 10); + if (month) body.month = parseInt(month, 10); try { - const res = await fetch('/api/session/new', { method: 'POST' }); + const res = await fetch('/api/session/new', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); const data = await res.json(); sessionId = data.session_id; localStorage.setItem('btc_sim_session', sessionId); @@ -494,6 +815,7 @@ 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.'; @@ -513,6 +835,8 @@ } 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); @@ -528,7 +852,25 @@ document.getElementById('next-btn').disabled = !enabled; } - // ── Sliders ─────────────────────────────────────────────────────────────── + // ── 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 + '%'; @@ -540,19 +882,27 @@ // ── Trade ───────────────────────────────────────────────────────────────── async function trade(action) { - const pctId = action === 'buy' ? 'buy-pct' : 'sell-pct'; - const pct = parseInt(document.getElementById(pctId).value, 10); 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({ session_id: sessionId, action, pct, fee_pct }), + 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); + addTradeLine(action, data.price, fee_pct, btcAcquired); + syncCurrentBreakEven(); } document.getElementById('buy-btn').addEventListener('click', () => trade('buy')); @@ -579,6 +929,7 @@ chart.timeScale().scrollToRealTime(); currentPrice = c.close; updatePortfolio(data.usd, data.btc, data.pnl, c.close); + syncCurrentBreakEven(); }); // ── New session button ──────────────────────────────────────────────────── diff --git a/src/handlers.rs b/src/handlers.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse}, }; use dashmap::DashMap; -use rand::rng; +use rand::{Rng, rng}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -21,6 +21,12 @@ pub struct AppState { // ── /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, @@ -30,12 +36,66 @@ pub struct NewSessionResponse { pnl: f64, } -pub async fn new_session(State(state): State<AppState>) -> impl IntoResponse { +fn is_leap(y: i32) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +fn month_days(y: i32, m: u32) -> i64 { + match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => if is_leap(y) { 29 } else { 28 }, + _ => 30, + } +} + +// 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 { + days += if is_leap(y) { 366 } else { 365 }; + } + for m in 1..month { + days += month_days(year, m); + } + days * 86400 +} + +fn candle_range(candles: &[crate::data::Candle], year: i32, month: Option<u32>) -> (usize, usize) { + let lo_ts = period_start_ts(year, month.unwrap_or(1)); + let hi_ts = match month { + Some(12) | None => period_start_ts(year + 1, 1), + Some(m) => period_start_ts(year, m + 1), + }; + let lo = candles.partition_point(|c| c.ts < lo_ts); + let hi = candles.partition_point(|c| c.ts < hi_ts); + (lo, hi) +} + +pub async fn new_session( + State(state): State<AppState>, + Json(req): Json<NewSessionRequest>, +) -> impl IntoResponse { let total = state.data.candles.len(); let mut rng = rng(); - let start = session::random_start_index(total, &mut rng); - let s = session::SessionState::new(start); + let start = match req.year { + None => session::random_start_index(total, &mut rng), + Some(year) => { + let (lo, hi) = candle_range(&state.data.candles, year, req.month); + if hi.saturating_sub(lo) < 100 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "not_enough_data"})), + ) + .into_response(); + } + let max = hi.saturating_sub(100).min(total.saturating_sub(5100)).max(lo); + rng.random_range(lo..=max) + } + }; + + 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); @@ -49,6 +109,7 @@ pub async fn new_session(State(state): State<AppState>) -> impl IntoResponse { btc: 0.0, pnl, }) + .into_response() } // ── /api/next ─────────────────────────────────────────────────────────────── @@ -95,7 +156,8 @@ pub async fn next_candle( pub struct TradeBody { session_id: String, action: String, - pct: u8, + pct: Option<f64>, + qty: Option<f64>, fee_pct: f64, } @@ -111,20 +173,28 @@ pub async fn trade( State(state): State<AppState>, Json(body): Json<TradeBody>, ) -> impl IntoResponse { - if body.pct == 0 || body.pct > 100 { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "pct_out_of_range"}))).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 pct = body.pct as f64; + let fee = body.fee_pct.clamp(0.0, 100.0); match body.action.as_str() { - "buy" => session::execute_buy(&mut s, pct, price, body.fee_pct.clamp(0.0, 100.0)), - "sell" => session::execute_sell(&mut s, pct, price), + "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(); diff --git a/src/session.rs b/src/session.rs @@ -27,16 +27,16 @@ 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, pct: f64, price: f64, fee_pct: f64) { - let spend = state.usd * (pct / 100.0); +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, pct: f64, price: f64) { - let btc_sold = state.btc * (pct / 100.0); - let usd_gained = btc_sold * price; - state.btc -= btc_sold; +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; }