commit b0a575581ab922dfa106f724ff42995a32a902c6
Author: ea <ea@ea.contact>
Date: Sun, 24 May 2026 13:23:33 +0000
init
Diffstat:
| A | Cargo.toml | | | 15 | +++++++++++++++ |
| A | frontend/index.html | | | 427 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/data.rs | | | 34 | ++++++++++++++++++++++++++++++++++ |
| A | src/handlers.rs | | | 145 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/main.rs | | | 43 | +++++++++++++++++++++++++++++++++++++++++++ |
| A | src/session.rs | | | 42 | ++++++++++++++++++++++++++++++++++++++++++ |
6 files changed, 706 insertions(+), 0 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "trtr"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+axum = "0.8"
+tokio = { version = "1", features = ["full"] }
+tower-http = { version = "0.6", features = ["compression-gzip"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+csv = "1.3"
+uuid = { version = "1", features = ["v4"] }
+rand = "0.9"
+dashmap = "6"
diff --git a/frontend/index.html b/frontend/index.html
@@ -0,0 +1,427 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>BTC/USD Simulator</title>
+<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
+<style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ body {
+ background: #0f0f1a;
+ color: #e0e0e0;
+ font-family: 'Segoe UI', system-ui, sans-serif;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ }
+
+ header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 18px;
+ background: #16162a;
+ border-bottom: 1px solid #2a2a4a;
+ flex-shrink: 0;
+ }
+
+ header h1 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #f0b429;
+ letter-spacing: 0.5px;
+ }
+
+ #new-session-btn {
+ background: #2a2a4a;
+ color: #a0a0c0;
+ border: 1px solid #3a3a5a;
+ border-radius: 6px;
+ padding: 6px 14px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.15s;
+ }
+ #new-session-btn:hover { background: #3a3a5a; color: #e0e0e0; }
+
+ .main {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+ }
+
+ #chart-container {
+ flex: 1;
+ min-width: 0;
+ position: relative;
+ }
+
+ #chart {
+ width: 100%;
+ height: 100%;
+ }
+
+ #loading-overlay {
+ position: absolute;
+ inset: 0;
+ background: #0f0f1a;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+ color: #606080;
+ 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;
+ }
+
+ .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;
+ border-top: 1px solid #2a2a4a;
+ display: flex;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+
+ #next-btn {
+ background: #2a2a6a;
+ color: #a0a0e0;
+ border: 1px solid #4a4a8a;
+ border-radius: 6px;
+ padding: 9px 36px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+ }
+ #next-btn:hover:not(:disabled) { background: #3a3a8a; color: #e0e0ff; }
+ #next-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+</style>
+</head>
+<body>
+
+<header>
+ <h1>BTC/USD Simulator</h1>
+ <button id="new-session-btn">New Session</button>
+</header>
+
+<div class="main">
+ <div id="chart-container">
+ <div id="chart"></div>
+ <div id="loading-overlay">Loading data…</div>
+ </div>
+
+ <div class="sidebar">
+ <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="trade-row">
+ <div class="slider-label">
+ <span>Buy %</span>
+ <span id="buy-pct-label">100%</span>
+ </div>
+ <input type="range" id="buy-pct" min="1" max="100" value="100">
+ <button class="btn btn-buy" id="buy-btn" disabled>BUY</button>
+ </div>
+
+ <div class="trade-row">
+ <div class="slider-label">
+ <span>Sell %</span>
+ <span id="sell-pct-label">100%</span>
+ </div>
+ <input type="range" id="sell-pct" min="1" max="100" value="100">
+ <button class="btn btn-sell" id="sell-btn" disabled>SELL</button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<footer>
+ <button id="next-btn" disabled>▶▶ Next Candle</button>
+</footer>
+
+<script>
+ let sessionId = null;
+ let chart = null;
+ let candleSeries = null;
+ let currentPrice = 0;
+
+ // ── Chart init ────────────────────────────────────────────────────────────
+
+ function initChart() {
+ const container = document.getElementById('chart');
+ chart = LightweightCharts.createChart(container, {
+ width: container.clientWidth,
+ height: container.clientHeight,
+ layout: {
+ background: { color: '#0f0f1a' },
+ textColor: '#a0a0b0',
+ },
+ grid: {
+ vertLines: { color: '#1e1e36' },
+ horzLines: { color: '#1e1e36' },
+ },
+ crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
+ rightPriceScale: { borderColor: '#2a2a4a' },
+ timeScale: {
+ borderColor: '#2a2a4a',
+ timeVisible: true,
+ secondsVisible: false,
+ },
+ });
+
+ candleSeries = chart.addCandlestickSeries({
+ upColor: '#26a69a',
+ downColor: '#ef5350',
+ borderVisible: false,
+ wickUpColor: '#26a69a',
+ wickDownColor: '#ef5350',
+ });
+
+ window.addEventListener('resize', () => {
+ chart.applyOptions({
+ width: container.clientWidth,
+ height: container.clientHeight,
+ });
+ });
+ }
+
+ // ── Session ───────────────────────────────────────────────────────────────
+
+ async function startSession() {
+ setEnabled(false);
+ document.getElementById('loading-overlay').style.display = 'flex';
+
+ try {
+ const res = await fetch('/api/session/new', { method: 'POST' });
+ const data = await res.json();
+ sessionId = data.session_id;
+ localStorage.setItem('btc_sim_session', sessionId);
+
+ const lwcCandles = data.candles.map(c => ({
+ time: c.ts,
+ open: c.open,
+ high: c.high,
+ low: c.low,
+ close: c.close,
+ }));
+ candleSeries.setData(lwcCandles);
+ chart.timeScale().fitContent();
+
+ currentPrice = data.candles[data.candles.length - 1].close;
+ updatePortfolio(data.usd, data.btc, data.pnl, currentPrice);
+ setEnabled(true);
+ } catch (err) {
+ document.getElementById('loading-overlay').textContent = 'Failed to connect to server.';
+ return;
+ }
+
+ document.getElementById('loading-overlay').style.display = 'none';
+ }
+
+ // ── Portfolio display ─────────────────────────────────────────────────────
+
+ function fmt(n, decimals = 2) {
+ return n.toLocaleString('en-US', {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+ }
+
+ function updatePortfolio(usd, btc, pnl, price) {
+ 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;
+ }
+
+ // ── Sliders ───────────────────────────────────────────────────────────────
+
+ 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 pctId = action === 'buy' ? 'buy-pct' : 'sell-pct';
+ const pct = parseInt(document.getElementById(pctId).value, 10);
+
+ const res = await fetch('/api/trade', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ session_id: sessionId, action, pct }),
+ });
+ const data = await res.json();
+ currentPrice = data.price;
+ updatePortfolio(data.usd, data.btc, data.pnl, data.price);
+ }
+
+ 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 () => {
+ const res = await fetch('/api/next', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ session_id: sessionId }),
+ });
+
+ if (res.status === 409) {
+ document.getElementById('next-btn').textContent = 'End of data';
+ document.getElementById('next-btn').disabled = true;
+ return;
+ }
+
+ const data = await res.json();
+ const c = data.candle;
+ candleSeries.update({ time: c.ts, open: c.open, high: c.high, low: c.low, close: c.close });
+ chart.timeScale().scrollToRealTime();
+ currentPrice = c.close;
+ updatePortfolio(data.usd, data.btc, data.pnl, c.close);
+ });
+
+ // ── New session button ────────────────────────────────────────────────────
+
+ document.getElementById('new-session-btn').addEventListener('click', startSession);
+
+ // ── Boot ──────────────────────────────────────────────────────────────────
+
+ initChart();
+ startSession();
+</script>
+</body>
+</html>
diff --git a/src/data.rs b/src/data.rs
@@ -0,0 +1,34 @@
+use serde::Serialize;
+
+#[derive(Clone, Serialize)]
+pub struct Candle {
+ pub ts: i64,
+ pub open: f64,
+ pub high: f64,
+ pub low: f64,
+ pub close: f64,
+ pub volume: f64,
+}
+
+pub struct AppData {
+ pub candles: Vec<Candle>,
+}
+
+pub fn load_csv(path: &str) -> Result<Vec<Candle>, Box<dyn std::error::Error + Send + Sync>> {
+ let mut rdr = csv::Reader::from_path(path)?;
+ let mut candles = Vec::with_capacity(8_000_000);
+
+ for result in rdr.records() {
+ let rec = result?;
+ let ts = rec[0].parse::<f64>()? as i64;
+ let open = rec[1].parse::<f64>()?;
+ let high = rec[2].parse::<f64>()?;
+ let low = rec[3].parse::<f64>()?;
+ let close = rec[4].parse::<f64>()?;
+ let volume = rec[5].parse::<f64>()?;
+ candles.push(Candle { ts, open, high, low, close, volume });
+ }
+
+ candles.shrink_to_fit();
+ Ok(candles)
+}
diff --git a/src/handlers.rs b/src/handlers.rs
@@ -0,0 +1,145 @@
+use std::sync::Arc;
+
+use axum::{
+ Json,
+ extract::State,
+ http::StatusCode,
+ response::{Html, IntoResponse},
+};
+use dashmap::DashMap;
+use rand::rng;
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use crate::{data::AppData, session};
+
+#[derive(Clone)]
+pub struct AppState {
+ pub data: Arc<AppData>,
+ pub sessions: Arc<DashMap<String, session::SessionState>>,
+}
+
+// ── /api/session/new ────────────────────────────────────────────────────────
+
+#[derive(Serialize)]
+pub struct NewSessionResponse {
+ session_id: String,
+ candles: Vec<crate::data::Candle>,
+ usd: f64,
+ btc: f64,
+ pnl: f64,
+}
+
+pub async fn new_session(State(state): State<AppState>) -> 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 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,
+ })
+}
+
+// ── /api/next ───────────────────────────────────────────────────────────────
+
+#[derive(Deserialize)]
+pub struct SessionIdBody {
+ pub session_id: String,
+}
+
+#[derive(Serialize)]
+pub struct NextCandleResponse {
+ candle: crate::data::Candle,
+ usd: f64,
+ btc: f64,
+ pnl: f64,
+}
+
+pub async fn next_candle(
+ 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();
+ };
+
+ let next = s.current_index + 1;
+ if next >= state.data.candles.len() {
+ return (StatusCode::CONFLICT, Json(serde_json::json!({"error": "end_of_data"}))).into_response();
+ }
+
+ 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;
+ drop(s);
+
+ Json(NextCandleResponse { candle, usd, btc, pnl }).into_response()
+}
+
+// ── /api/trade ──────────────────────────────────────────────────────────────
+
+#[derive(Deserialize)]
+pub struct TradeBody {
+ session_id: String,
+ action: String,
+ pct: u8,
+}
+
+#[derive(Serialize)]
+pub struct TradeResponse {
+ usd: f64,
+ btc: f64,
+ pnl: f64,
+ price: f64,
+}
+
+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;
+
+ match body.action.as_str() {
+ "buy" => session::execute_buy(&mut s, pct, price),
+ "sell" => session::execute_sell(&mut s, pct, price),
+ _ => {
+ drop(s);
+ return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid_action"}))).into_response();
+ }
+ }
+
+ let pnl = session::compute_pnl(&s, price);
+ let usd = s.usd;
+ let btc = s.btc;
+ drop(s);
+
+ Json(TradeResponse { usd, btc, pnl, price }).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
@@ -0,0 +1,43 @@
+mod data;
+mod handlers;
+mod session;
+
+use std::sync::Arc;
+use std::time::Instant;
+
+use axum::routing::{get, post};
+use dashmap::DashMap;
+
+#[tokio::main]
+async fn main() {
+ println!("Loading CSV data, please wait...");
+ let t = Instant::now();
+
+ let candles = tokio::task::spawn_blocking(|| {
+ data::load_csv("data/btc/btcusd_1-min_data.csv").expect("Failed to load CSV")
+ })
+ .await
+ .unwrap();
+
+ println!(
+ "Loaded {} candles in {:.1}s. Starting server on http://0.0.0.0:3000",
+ candles.len(),
+ t.elapsed().as_secs_f64()
+ );
+
+ let state = handlers::AppState {
+ data: Arc::new(data::AppData { candles }),
+ sessions: Arc::new(DashMap::new()),
+ };
+
+ let app = axum::Router::new()
+ .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))
+ .with_state(state)
+ .layer(tower_http::compression::CompressionLayer::new());
+
+ let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
+ axum::serve(listener, app).await.unwrap();
+}
diff --git a/src/session.rs b/src/session.rs
@@ -0,0 +1,42 @@
+use rand::Rng;
+
+pub struct SessionState {
+ pub current_index: usize,
+ pub usd: f64,
+ pub btc: f64,
+ pub initial_usd: f64,
+}
+
+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,
+ }
+ }
+}
+
+pub fn random_start_index(total: usize, rng: &mut impl Rng) -> usize {
+ let max = total.saturating_sub(5100);
+ 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, pct: f64, price: f64) {
+ let spend = state.usd * (pct / 100.0);
+ let btc_gained = spend / price;
+ 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;
+ state.usd += usd_gained;
+}