commit d5b1593b4d2cee8e967368fffd26a4669ce651f0
parent 290f1f1670932964082754b7fbe1dadc68102afc
Author: ea <ea@ea.contact>
Date: Sun, 24 May 2026 14:43:05 +0000
frontend stuff
Diffstat:
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…</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;
}