trtr

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

commit b0a575581ab922dfa106f724ff42995a32a902c6
Author: ea <ea@ea.contact>
Date:   Sun, 24 May 2026 13:23:33 +0000

init

Diffstat:
ACargo.toml | 15+++++++++++++++
Afrontend/index.html | 427+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/data.rs | 34++++++++++++++++++++++++++++++++++
Asrc/handlers.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/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&hellip;</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&amp;L</label> + <span id="pnl-val" class="pnl-zero">$0.00</span> + </div> + <div class="stat"> + <label>Price</label> + <span id="price-val">—</span> + </div> + </div> + + <div class="controls"> + <h2>Trade</h2> + + <div class="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>&#9654;&#9654; 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; +}