From 708fd3f38e63adab158820ce711cf4686c5ae1be Mon Sep 17 00:00:00 2001 From: Michael Yuan Date: Sun, 15 Mar 2026 01:44:27 -0700 Subject: [PATCH 1/4] feat: add backtest CLI for historical price simulation with forward PnL analysis Add a new `backtest` binary that simulates trades at historical dates and computes forward PnL at +1/+2/+4/+7 days using Yahoo Finance and CoinGecko data. Supports spot and perp trades with persistent portfolio state, leverage settings, and SEC filing date filtering. No API keys or wallet required. Signed-off-by: Michael Yuan Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 4 + README.md | 171 +++++- examples/backtest/covid_crash_hedge.sh | 67 ++ examples/backtest/ftx_crypto_contagion.sh | 77 +++ examples/backtest/nvda_earnings_alpha.sh | 69 +++ examples/backtest/ukraine_oil_shock.sh | 77 +++ src/backtest.rs | 683 +++++++++++++++++++++ src/bin/backtest.rs | 712 ++++++++++++++++++++++ src/commands/quote.rs | 4 +- src/commands/report.rs | 35 +- src/lib.rs | 1 + tests/backtest/balance.sh | 72 +++ tests/backtest/buy_spot.sh | 81 +++ tests/backtest/e2e_backtest.sh | 215 +++++++ tests/backtest/news_stub.sh | 41 ++ tests/backtest/perp_buy.sh | 70 +++ tests/backtest/perp_sell.sh | 58 ++ tests/backtest/quote_btc.sh | 53 ++ tests/backtest/quote_commodity.sh | 47 ++ tests/backtest/quote_stock.sh | 47 ++ tests/backtest/report_list.sh | 49 ++ tests/backtest/reset.sh | 69 +++ tests/backtest/sell_spot.sh | 59 ++ tests/helpers.sh | 5 +- 24 files changed, 2732 insertions(+), 34 deletions(-) create mode 100755 examples/backtest/covid_crash_hedge.sh create mode 100755 examples/backtest/ftx_crypto_contagion.sh create mode 100755 examples/backtest/nvda_earnings_alpha.sh create mode 100755 examples/backtest/ukraine_oil_shock.sh create mode 100644 src/backtest.rs create mode 100644 src/bin/backtest.rs create mode 100755 tests/backtest/balance.sh create mode 100755 tests/backtest/buy_spot.sh create mode 100755 tests/backtest/e2e_backtest.sh create mode 100755 tests/backtest/news_stub.sh create mode 100755 tests/backtest/perp_buy.sh create mode 100755 tests/backtest/perp_sell.sh create mode 100755 tests/backtest/quote_btc.sh create mode 100755 tests/backtest/quote_commodity.sh create mode 100755 tests/backtest/quote_stock.sh create mode 100755 tests/backtest/report_list.sh create mode 100755 tests/backtest/reset.sh create mode 100755 tests/backtest/sell_spot.sh diff --git a/Cargo.toml b/Cargo.toml index f10ffac..ce1b0f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ path = "src/bin/polymarket.rs" name = "okx" path = "src/bin/okx.rs" +[[bin]] +name = "backtest" +path = "src/bin/backtest.rs" + [dependencies] clap = { version = "4", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/README.md b/README.md index 9d6de0c..642c9fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # fintool -A suite of Rust CLI tools for agentic trading and market intelligence. Each exchange has its own dedicated binary — **`hyperliquid`**, **`binance`**, **`coinbase`**, **`okx`**, **`polymarket`** — plus a shared **`fintool`** for exchange-agnostic market intelligence (quotes, news, SEC filings). Supports crypto, stocks, commodities, and prediction markets. All CLIs support `--json` mode for scripting and agent integration. +A suite of Rust CLI tools for agentic trading and market intelligence. Each exchange has its own dedicated binary — **`hyperliquid`**, **`binance`**, **`coinbase`**, **`okx`**, **`polymarket`** — plus a shared **`fintool`** for exchange-agnostic market intelligence (quotes, news, SEC filings) and **`backtest`** for historical price simulation with forward PnL analysis. Supports crypto, stocks, commodities, and prediction markets. All CLIs support `--json` mode for scripting and agent integration. **Support us!** * If you are a human: https://payme.secondstate.io/?pid=9692fe22-62ed-413c-9e93-b57e20bab24b @@ -20,6 +20,7 @@ A suite of Rust CLI tools for agentic trading and market intelligence. Each exch - [Open and close perp positions](#open-and-close-perp-positions) - [Commodity perp on Hyperliquid (USDT0 conversion)](#commodity-perp-on-hyperliquid-usdt0-conversion) - [Prediction market trading (Polymarket)](#prediction-market-trading-polymarket) + - [Backtesting trading strategies](#backtesting-trading-strategies) - [Configuration](#configuration) - [Config Options](#config-options) - [What Needs Configuration](#what-needs-configuration) @@ -30,6 +31,7 @@ A suite of Rust CLI tools for agentic trading and market intelligence. Each exch - [`coinbase` — Coinbase Exchange](#coinbase--coinbase-exchange) - [`okx` — OKX Exchange](#okx--okx-exchange) - [`polymarket` — Polymarket Prediction Markets](#polymarket--polymarket-prediction-markets) + - [`backtest` — Historical Simulation](#backtest--historical-simulation) - [Common Commands Reference](#common-commands-reference) - [`quote`](#quote-symbol) - [`buy / sell` (spot)](#buy--sell-spot) @@ -77,26 +79,30 @@ Or download pre-built binaries from [Releases](https://github.com/second-state/f | `coinbase` | Spot trading, deposits, withdrawals | Coinbase | | `okx` | Spot + perp trading, deposits, withdrawals, transfers | OKX | | `polymarket` | Prediction market trading, deposits, withdrawals | Polymarket (Polygon) | +| `backtest` | Historical price simulation with forward PnL analysis | None (read-only history) | All CLIs support `--json` mode for programmatic use. See [JSON Mode](#json-mode). ### Exchange Capability Matrix -| Feature | `hyperliquid` | `binance` | `coinbase` | `okx` | `polymarket` | -|---------|---------------|-----------|------------|-------|--------------| -| Spot Trading | buy, sell | buy, sell | buy, sell | buy, sell | — | -| Perpetual Futures | perp buy/sell | perp buy/sell | — | perp buy/sell | — | -| Prediction Markets | — | — | — | — | buy, sell, list, quote | -| Orderbook | spot + perp | spot + perp | spot | spot + perp | — | -| Options | options buy/sell | — | — | — | — | -| Balance | balance | balance | balance | balance | balance | -| Positions | positions | positions | — | positions | positions | -| Orders/Cancel | orders, cancel | orders, cancel | orders, cancel | orders, cancel | — | -| Deposit | deposit | deposit | deposit | deposit | deposit | -| Withdraw | withdraw | withdraw | withdraw | withdraw | withdraw | -| Transfer | transfer | transfer | — | transfer | — | -| Funding Rate | — | — | — | perp funding-rate | — | -| Bridge Status | bridge-status | — | — | — | — | +| Feature | `hyperliquid` | `binance` | `coinbase` | `okx` | `polymarket` | `backtest` | +|---------|---------------|-----------|------------|-------|--------------|------------| +| Spot Trading | buy, sell | buy, sell | buy, sell | buy, sell | — | simulated buy/sell | +| Perpetual Futures | perp buy/sell | perp buy/sell | — | perp buy/sell | — | simulated perp buy/sell | +| Prediction Markets | — | — | — | — | buy, sell, list, quote | — | +| Orderbook | spot + perp | spot + perp | spot | spot + perp | — | — | +| Options | options buy/sell | — | — | — | — | — | +| Balance | balance | balance | balance | balance | balance | simulated | +| Positions | positions | positions | — | positions | positions | simulated | +| Orders/Cancel | orders, cancel | orders, cancel | orders, cancel | orders, cancel | — | — | +| Deposit | deposit | deposit | deposit | deposit | deposit | — | +| Withdraw | withdraw | withdraw | withdraw | withdraw | withdraw | — | +| Transfer | transfer | transfer | — | transfer | — | — | +| Funding Rate | — | — | — | perp funding-rate | — | — | +| Bridge Status | bridge-status | — | — | — | — | — | +| Historical Quote | — | — | — | — | — | quote | +| Forward PnL | — | — | — | — | — | +1d/+2d/+4d/+7d | +| SEC Filings (dated) | — | — | — | — | — | report list/annual/quarterly | ## Quick Guides @@ -294,6 +300,44 @@ polymarket positions polymarket deposit --amount 100 --from base ``` +### Backtesting trading strategies + +Simulate trades at historical dates and see what the PnL would have been. Portfolio state (cash balance, positions) persists across invocations: + +```bash +# Reset portfolio to start fresh +backtest --at 2025-01-15 reset + +# Get the historical price of BTC on a specific date +backtest --at 2025-01-15 quote BTC +backtest --at 2025-01-15 quote AAPL +backtest --at 2025-01-15 quote GOLD + +# Simulate a spot buy — shows PnL at +1, +2, +4, +7 days + portfolio update +backtest --at 2025-01-15 buy BTC --amount 0.01 +backtest --at 2025-01-15 buy AAPL --amount 10 --price 237 + +# Check balance (cash goes negative after buys) +backtest --at 2025-01-15 balance + +# Simulate a sell at profit — cash balance becomes positive +backtest --at 2025-02-15 sell BTC --amount 0.01 --price 105000 + +# Check positions and balance +backtest --at 2025-02-15 positions +backtest --at 2025-02-15 balance + +# Simulate perp trades with leverage +backtest --at 2025-01-15 perp leverage ETH --leverage 5 +backtest --at 2025-01-15 perp buy ETH --amount 0.5 --price 3300 + +# SEC filings available before a date +backtest --at 2024-06-01 report list AAPL +backtest --at 2024-06-01 report annual TSLA +``` + +If `--price` is omitted on buy/sell, the historical close price at the `--at` date is used automatically. No API keys or wallet needed — backtest uses public Yahoo Finance and CoinGecko data. Portfolio state is saved to `~/.fintool/backtest_portfolio.json`. + > **Note:** All CLIs support a `--json` mode for scripting and agent integration — pass a full command as a JSON string and get JSON output. See [JSON Mode](#json-mode) for details. --- @@ -378,6 +422,7 @@ okx_passphrase = "..." | `okx` (trading/balance/deposit/withdraw) | — | — | — | Yes | — | | `polymarket list`, `polymarket quote` | No | — | — | — | — | | `polymarket` (buy/sell/positions/deposit) | Yes | — | — | — | — | +| `backtest` (all commands) | No | No | No | No | No | --- @@ -600,6 +645,46 @@ Prediction market trading on Polygon. --- +### `backtest` — Historical Simulation + +Simulate trades at historical dates with forward PnL analysis. No API keys or wallet needed. + +| Command | Description | +|---------|-------------| +| `backtest --at quote ` | Historical close price on given date | +| `backtest --at buy --amount N [--price P]` | Simulated spot buy with PnL at +1/+2/+4/+7 days | +| `backtest --at sell --amount N [--price P]` | Simulated spot sell with PnL | +| `backtest --at perp buy --amount N --price P` | Simulated perp long with leveraged PnL | +| `backtest --at perp sell --amount N --price P` | Simulated perp short with leveraged PnL | +| `backtest --at perp leverage --leverage N` | Set leverage for PnL calculation | +| `backtest --at news ` | Stub (historical news not available) | +| `backtest --at report annual ` | Latest 10-K filed on or before date | +| `backtest --at report quarterly ` | Latest 10-Q filed on or before date | +| `backtest --at report list ` | SEC filings on or before date | +| `backtest --at balance` | Cash balance, open positions, trade count | +| `backtest --at positions` | Net positions with avg entry price | +| `backtest --at reset` | Clear all trades and positions | + +#### Backtest-Specific Features + +**`--at` date (required):** All commands require `--at YYYY-MM-DD` to anchor the simulation at a historical date. The date must be in the past. + +**Auto-price:** If `--price` is omitted on buy/sell commands, the historical close price at the `--at` date is used automatically. + +**Forward PnL:** After each simulated trade, the CLI fetches actual prices at +1, +2, +4, and +7 calendar days and displays a PnL table showing dollar and percentage gains/losses. Weekends and holidays are handled by using the next available trading day. + +**Persistent portfolio:** Portfolio state (trades, positions, leverage settings) is saved to `~/.fintool/backtest_portfolio.json` and persists across CLI invocations. Cash balance starts at $0, goes negative when buying, and becomes positive when selling for profit. Use `reset` to clear all state. + +**Cash balance:** Computed from spot trades only. Buying subtracts `amount × price`, selling adds `amount × price`. Perp trades do not affect cash balance (margin model). + +**Positions:** Grouped by symbol and trade type (spot/perp). Shows net quantity and weighted average entry price. A position becomes flat when the full quantity is sold. + +**Leverage:** Use `perp leverage` to set leverage before a perp trade. The PnL calculation applies the leverage multiplier. Default is 1x. Leverage settings persist across invocations. + +**Data sources:** Historical prices come from Yahoo Finance (stocks, crypto, commodities, indices) with CoinGecko as fallback for crypto. SEC filings come from EDGAR with date filtering. + +--- + ## Common Commands Reference These commands work the same across exchange CLIs. The only difference is which binary you use. @@ -781,6 +866,15 @@ polymarket withdraw --amount 100 | `quote ` | Market details/prices | `polymarket` | | `buy --outcome O ...` | Buy prediction shares | `polymarket` | | `sell --outcome O ...` | Sell prediction shares | `polymarket` | +| `--at quote ` | Historical close price | `backtest` | +| `--at buy/sell ` | Simulated spot trade + forward PnL | `backtest` | +| `--at perp buy/sell ` | Simulated perp trade + leveraged PnL | `backtest` | +| `--at perp leverage ` | Set leverage for PnL calc | `backtest` | +| `--at report list/annual/quarterly` | SEC filings before date | `backtest` | +| `--at news ` | News stub (unavailable) | `backtest` | +| `--at balance` | Cash balance + positions + trade count | `backtest` | +| `--at positions` | Net positions with avg entry price | `backtest` | +| `--at reset` | Clear all trades and positions | `backtest` | ## Data Sources @@ -803,6 +897,8 @@ polymarket withdraw --amount 100 | Quotes — OKX | OKX Public API `/api/v5/market/ticker` | No | No auth for public endpoints | | Deposit/Withdraw — HyperUnit bridge | HyperUnit API | Wallet private key | ETH, BTC, SOL ↔ Hyperliquid | | Deposit — USDC cross-chain bridge | Across Protocol API | Wallet private key | Ethereum/Base → Arbitrum → HL | +| Historical prices (backtest) | Yahoo Finance Chart API | No | Daily OHLCV bars for stocks, crypto, commodities, indices | +| Historical crypto prices (backtest fallback) | CoinGecko History API | No | Fallback for crypto when Yahoo unavailable | ## JSON Mode @@ -844,6 +940,16 @@ okx --json '{"command":"transfer","asset":"USDT","amount":100,"from":"funding"," polymarket --json '{"command":"list","query":"bitcoin"}' polymarket --json '{"command":"buy","market":"will-btc-hit-100k","outcome":"yes","amount":20,"price":0.50}' polymarket --json '{"command":"positions"}' + +# Backtest historical simulation +backtest --at 2025-01-15 --json '{"command":"reset"}' +backtest --at 2025-01-15 --json '{"command":"quote","symbol":"BTC"}' +backtest --at 2025-01-15 --json '{"command":"buy","symbol":"ETH","amount":0.5}' +backtest --at 2025-01-15 --json '{"command":"balance"}' +backtest --at 2025-01-15 --json '{"command":"positions"}' +backtest --at 2025-01-15 --json '{"command":"perp_buy","symbol":"ETH","amount":0.1,"price":3300}' +backtest --at 2025-01-15 --json '{"command":"perp_leverage","symbol":"ETH","leverage":5}' +backtest --at 2024-06-01 --json '{"command":"report_list","symbol":"AAPL","limit":3}' ``` Errors are returned as JSON too: @@ -956,12 +1062,38 @@ Errors are returned as JSON too: | `deposit` | — | `amount`, `from`, `dry_run` | | `withdraw` | `amount` | `dry_run` | +#### `backtest` + +All commands require `--at YYYY-MM-DD` as a CLI flag (not in the JSON body). + +| `command` | Required fields | Optional fields | +|-----------|----------------|-----------------| +| `quote` | `symbol` | — | +| `news` | `symbol` | — | +| `buy` | `symbol`, `amount` | `price` | +| `sell` | `symbol`, `amount` | `price` | +| `perp_buy` | `symbol`, `amount` | `price`, `close` | +| `perp_sell` | `symbol`, `amount` | `price`, `close` | +| `perp_leverage` | `symbol`, `leverage` | — | +| `report_annual` | `symbol` | `output` | +| `report_quarterly` | `symbol` | `output` | +| `report_list` | `symbol` | `limit` | +| `report_get` | `symbol`, `accession` | `output` | +| `balance` | — | — | +| `positions` | — | — | +| `reset` | — | — | + **Notes:** - `amount` and `price` are numbers (e.g. `0.1`, `2500.00`) - `leverage` is a number (e.g. `10`) - `close` and `dry_run` are booleans (default `false`) - `limit` is a number (default `10`) - `min_end_days` is a number (default `3`) +- If `price` is omitted on `buy`/`sell`/`perp_buy`/`perp_sell`, the historical close price at the `--at` date is used +- Portfolio state persists to `~/.fintool/backtest_portfolio.json`. Use `reset` to clear. +- `balance` returns `cashBalance` (spot only), `positions`, `totalTrades`, `leverageSettings` +- `positions` returns net positions grouped by symbol and type with `avgEntryPrice` and `totalCost` +- Trade output includes a `portfolio` field with updated balance and positions --- @@ -977,7 +1109,8 @@ fintool/ │ │ ├── binance.rs # CLI: Binance exchange │ │ ├── coinbase.rs # CLI: Coinbase exchange │ │ ├── okx.rs # CLI: OKX exchange -│ │ └── polymarket.rs # CLI: Polymarket prediction markets +│ │ ├── polymarket.rs # CLI: Polymarket prediction markets +│ │ └── backtest.rs # CLI: Historical simulation + PnL │ ├── config.rs # Config loading (~/.fintool/config.toml) │ ├── signing.rs # Hyperliquid wallet signing, asset resolution, order execution │ ├── hip3.rs # HIP-3 builder-deployed perps: EIP-712 signing @@ -988,6 +1121,7 @@ fintool/ │ ├── unit.rs # HyperUnit bridge (ETH/BTC/SOL deposit/withdraw) │ ├── polymarket.rs # Polymarket SDK client helpers │ ├── format.rs # Color formatting + number formatting helpers +│ ├── backtest.rs # Historical data providers + simulated portfolio + PnL │ └── commands/ │ ├── quote.rs # Multi-source quotes + LLM enrichment │ ├── news.rs # News via Google News RSS @@ -1010,7 +1144,8 @@ fintool/ │ ├── hyperliquid/ # E2E tests for Hyperliquid │ ├── binance/ # E2E tests for Binance │ ├── okx/ # E2E tests for OKX -│ └── polymarket/ # E2E tests for Polymarket +│ ├── polymarket/ # E2E tests for Polymarket +│ └── backtest/ # E2E tests for backtesting ├── examples/ │ ├── funding_arb/ # Funding rate arbitrage bot │ └── metal_pair/ # Metal pairs trading bot diff --git a/examples/backtest/covid_crash_hedge.sh b/examples/backtest/covid_crash_hedge.sh new file mode 100755 index 0000000..3414e2e --- /dev/null +++ b/examples/backtest/covid_crash_hedge.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# COVID-19 Crash — Flight-to-Safety Hedge +# ======================================== +# +# Date: February 21, 2020 (Friday before the crash accelerated) +# +# Thesis: By late February 2020, COVID-19 had spread to Italy and South +# Korea. The S&P 500 hit its all-time high on Feb 19. A pandemic-driven +# selloff was imminent. The classic hedge: go long gold (safe haven) and +# short equities. This is a dollar-neutral pair: ~$5,000 each side. +# +# Legs: +# 1. Long GOLD — 3 oz at ~$1,645/oz ($4,934 notional) +# 2. Short SP500 — 1.5 units at ~$3,337 ($5,006 notional) +# +# What happened: The S&P 500 fell ~12% over the next 7 trading days +# (the fastest correction from ATH in history). Gold initially held +# steady, then pulled back as margin calls hit. +# +# Result: The short equity leg dominates — this hedge captured the +# crash while the gold leg acts as a stabilizer. +# +# Usage: ./examples/backtest/covid_crash_hedge.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BT="${BACKTEST:-$SCRIPT_DIR/../../target/release/backtest}" + +echo "" +echo "══════════════════════════════════════════════════════════════" +echo " COVID-19 Crash Hedge — February 21, 2020" +echo " Long GOLD + Short S&P 500 (dollar-neutral pair)" +echo "══════════════════════════════════════════════════════════════" +echo "" + +# ── Reset portfolio ──────────────────────────────────────────────── +$BT --at 2020-02-21 reset 2>/dev/null + +# ── Scout: get prices on Feb 21, 2020 ───────────────────────────── +echo "── Scouting prices on 2020-02-21 ──" +echo "" + +GOLD_PRICE=$($BT --at 2020-02-21 --json '{"command":"quote","symbol":"GOLD"}' 2>/dev/null | jq -r '.price') +SP_PRICE=$($BT --at 2020-02-21 --json '{"command":"quote","symbol":"SP500"}' 2>/dev/null | jq -r '.price') + +echo " GOLD: \$$GOLD_PRICE / oz" +echo " SP500: \$$SP_PRICE" +echo "" + +# ── Leg 1: Long gold — safe-haven bid ───────────────────────────── +echo "── Leg 1: Long GOLD (flight to safety) ──" +$BT --at 2020-02-21 buy GOLD --amount 3 --price "$GOLD_PRICE" + +# ── Leg 2: Short S&P 500 — pandemic selloff ─────────────────────── +echo "── Leg 2: Short S&P 500 (equity crash) ──" +$BT --at 2020-02-21 sell SP500 --amount 1.5 --price "$SP_PRICE" + +# ── Portfolio snapshot ───────────────────────────────────────────── +echo "══════════════════════════════════════════════════════════════" +echo " Portfolio Summary" +echo "══════════════════════════════════════════════════════════════" +$BT --at 2020-02-21 balance +$BT --at 2020-02-21 positions + +# ── Cleanup ──────────────────────────────────────────────────────── +$BT --at 2020-02-21 reset > /dev/null 2>&1 diff --git a/examples/backtest/ftx_crypto_contagion.sh b/examples/backtest/ftx_crypto_contagion.sh new file mode 100755 index 0000000..97762cb --- /dev/null +++ b/examples/backtest/ftx_crypto_contagion.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# FTX Collapse — Crypto Contagion Hedge +# ======================================== +# +# Date: November 8, 2022 (Binance announced it would sell its FTT holdings) +# +# Thesis: On Nov 6, CoinDesk reported Alameda's balance sheet was full +# of FTT tokens. On Nov 8, Binance announced it would liquidate its +# ~$530M in FTT, triggering a bank run on FTX. Crypto was about to +# enter a contagion spiral — but it wasn't clear how far traditional +# markets would follow. The trade: short crypto (BTC + ETH) and go +# long gold as a safe-haven hedge in case the contagion spread. +# +# Legs: +# 1. Short BTC — 0.15 BTC at ~$18,500 ($2,775 notional) +# 2. Short ETH — 2.0 ETH at ~$1,340 ($2,680 notional) +# 3. Long GOLD — 3 oz at ~$1,712/oz ($5,136 notional) +# +# Gold is the anchor — roughly sized to match the combined crypto short. +# If crypto crashes and gold holds or rises, both sides win. If crypto +# recovers, the gold leg limits the damage. +# +# What happened: BTC dropped from $18.5k to $15.7k (-15%) in 7 days. +# ETH fell from $1,340 to $1,100 (-18%). Gold was roughly flat, +# providing stability. The crypto short was the main profit driver. +# +# Usage: ./examples/backtest/ftx_crypto_contagion.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BT="${BACKTEST:-$SCRIPT_DIR/../../target/release/backtest}" + +echo "" +echo "══════════════════════════════════════════════════════════════" +echo " FTX Collapse — November 8, 2022" +echo " Short BTC + Short ETH + Long GOLD (contagion hedge)" +echo "══════════════════════════════════════════════════════════════" +echo "" + +# ── Reset portfolio ──────────────────────────────────────────────── +$BT --at 2022-11-08 reset 2>/dev/null + +# ── Scout: get prices on Nov 8, 2022 ────────────────────────────── +echo "── Scouting prices on 2022-11-08 ──" +echo "" + +BTC_PRICE=$($BT --at 2022-11-08 --json '{"command":"quote","symbol":"BTC"}' 2>/dev/null | jq -r '.price') +ETH_PRICE=$($BT --at 2022-11-08 --json '{"command":"quote","symbol":"ETH"}' 2>/dev/null | jq -r '.price') +GOLD_PRICE=$($BT --at 2022-11-08 --json '{"command":"quote","symbol":"GOLD"}' 2>/dev/null | jq -r '.price') + +echo " BTC: \$$BTC_PRICE" +echo " ETH: \$$ETH_PRICE" +echo " GOLD: \$$GOLD_PRICE" +echo "" + +# ── Leg 1: Short BTC — exchange contagion ───────────────────────── +echo "── Leg 1: Short BTC (FTX contagion, forced selling) ──" +$BT --at 2022-11-08 sell BTC --amount 0.15 --price "$BTC_PRICE" + +# ── Leg 2: Short ETH — crypto-wide selloff ──────────────────────── +echo "── Leg 2: Short ETH (correlated crypto drawdown) ──" +$BT --at 2022-11-08 sell ETH --amount 2.0 --price "$ETH_PRICE" + +# ── Leg 3: Long gold — safe-haven anchor ────────────────────────── +echo "── Leg 3: Long GOLD (safe haven, hedge against reversal) ──" +$BT --at 2022-11-08 buy GOLD --amount 3 --price "$GOLD_PRICE" + +# ── Portfolio snapshot ───────────────────────────────────────────── +echo "══════════════════════════════════════════════════════════════" +echo " Portfolio Summary" +echo "══════════════════════════════════════════════════════════════" +$BT --at 2022-11-08 balance +$BT --at 2022-11-08 positions + +# ── Cleanup ──────────────────────────────────────────────────────── +$BT --at 2022-11-08 reset > /dev/null 2>&1 diff --git a/examples/backtest/nvda_earnings_alpha.sh b/examples/backtest/nvda_earnings_alpha.sh new file mode 100755 index 0000000..5f09291 --- /dev/null +++ b/examples/backtest/nvda_earnings_alpha.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# NVIDIA Earnings Blowout — AI Sector Alpha +# ============================================ +# +# Date: May 25, 2023 (day after NVDA's historic Q1 FY24 earnings) +# +# Thesis: On May 24, 2023, NVIDIA reported revenue of $7.19B (vs +# $6.52B expected) and guided Q2 to $11B — 50% above consensus. +# The stock surged 25% after hours. This was the moment the market +# realized AI infrastructure demand was real. The play: capture +# NVDA's alpha while hedging broad market risk. +# +# Legs: +# 1. Long NVDA — 13 shares at ~$379/share ($4,930 notional) +# 2. Short SP500 — 1.2 units at ~$4,151 ($4,981 notional) +# +# This is a classic long/short equity pair: long the winner, short +# the index. If the market rallies, NVDA should rally more. If the +# market drops, NVDA's AI tailwind should cushion the loss. +# +# What happened: NVDA continued climbing from $379 to $400+ over +# the next week as analysts rushed to upgrade. The S&P 500 was +# roughly flat. Pure alpha. +# +# Usage: ./examples/backtest/nvda_earnings_alpha.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BT="${BACKTEST:-$SCRIPT_DIR/../../target/release/backtest}" + +echo "" +echo "══════════════════════════════════════════════════════════════" +echo " NVIDIA AI Earnings — May 25, 2023" +echo " Long NVDA + Short S&P 500 (sector alpha pair)" +echo "══════════════════════════════════════════════════════════════" +echo "" + +# ── Reset portfolio ──────────────────────────────────────────────── +$BT --at 2023-05-25 reset 2>/dev/null + +# ── Scout: get prices on May 25, 2023 ───────────────────────────── +echo "── Scouting prices on 2023-05-25 ──" +echo "" + +NVDA_PRICE=$($BT --at 2023-05-25 --json '{"command":"quote","symbol":"NVDA"}' 2>/dev/null | jq -r '.price') +SP_PRICE=$($BT --at 2023-05-25 --json '{"command":"quote","symbol":"SP500"}' 2>/dev/null | jq -r '.price') + +echo " NVDA: \$$NVDA_PRICE" +echo " SP500: \$$SP_PRICE" +echo "" + +# ── Leg 1: Long NVDA — AI momentum ─────────────────────────────── +echo "── Leg 1: Long NVDA (AI infrastructure demand) ──" +$BT --at 2023-05-25 buy NVDA --amount 13 --price "$NVDA_PRICE" + +# ── Leg 2: Short S&P 500 — market-neutral hedge ────────────────── +echo "── Leg 2: Short S&P 500 (hedge broad market risk) ──" +$BT --at 2023-05-25 sell SP500 --amount 1.2 --price "$SP_PRICE" + +# ── Portfolio snapshot ───────────────────────────────────────────── +echo "══════════════════════════════════════════════════════════════" +echo " Portfolio Summary" +echo "══════════════════════════════════════════════════════════════" +$BT --at 2023-05-25 balance +$BT --at 2023-05-25 positions + +# ── Cleanup ──────────────────────────────────────────────────────── +$BT --at 2023-05-25 reset > /dev/null 2>&1 diff --git a/examples/backtest/ukraine_oil_shock.sh b/examples/backtest/ukraine_oil_shock.sh new file mode 100755 index 0000000..e0f3770 --- /dev/null +++ b/examples/backtest/ukraine_oil_shock.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Russia-Ukraine Invasion — Commodity Supply Shock +# ================================================== +# +# Date: February 24, 2022 (the day Russia invaded Ukraine) +# +# Thesis: Russia is a top-3 global oil producer. A full-scale invasion +# means sanctions, supply disruption, and an energy price spike. At the +# same time, risk assets sell off on geopolitical uncertainty. This is +# a classic macro shock trade: long commodities, short equities. +# +# Legs: +# 1. Long OIL — 35 bbl at ~$92/bbl ($3,220 notional) +# 2. Long GOLD — 2 oz at ~$1,909/oz ($3,818 notional) — war safe haven +# 3. Short SP500 — 1.5 units at ~$4,225 ($6,338 notional) +# +# The short leg is sized larger to roughly balance the two long legs. +# Oil is the directional bet; gold is the defensive anchor. +# +# What happened: Oil surged to $115+ within 2 weeks (eventually $130 +# in March). Gold rallied to $2,050. The S&P 500 dropped ~3% in the +# first week before stabilizing. +# +# Result: Both commodity legs profit on the supply shock, while the +# equity short captures the initial risk-off move. +# +# Usage: ./examples/backtest/ukraine_oil_shock.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BT="${BACKTEST:-$SCRIPT_DIR/../../target/release/backtest}" + +echo "" +echo "══════════════════════════════════════════════════════════════" +echo " Russia-Ukraine Invasion — February 24, 2022" +echo " Long OIL + Long GOLD + Short S&P 500" +echo "══════════════════════════════════════════════════════════════" +echo "" + +# ── Reset portfolio ──────────────────────────────────────────────── +$BT --at 2022-02-24 reset 2>/dev/null + +# ── Scout: get prices on Feb 24, 2022 ───────────────────────────── +echo "── Scouting prices on 2022-02-24 ──" +echo "" + +OIL_PRICE=$($BT --at 2022-02-24 --json '{"command":"quote","symbol":"OIL"}' 2>/dev/null | jq -r '.price') +GOLD_PRICE=$($BT --at 2022-02-24 --json '{"command":"quote","symbol":"GOLD"}' 2>/dev/null | jq -r '.price') +SP_PRICE=$($BT --at 2022-02-24 --json '{"command":"quote","symbol":"SP500"}' 2>/dev/null | jq -r '.price') + +echo " OIL: \$$OIL_PRICE / bbl" +echo " GOLD: \$$GOLD_PRICE / oz" +echo " SP500: \$$SP_PRICE" +echo "" + +# ── Leg 1: Long crude oil — supply disruption ───────────────────── +echo "── Leg 1: Long OIL (supply shock from sanctions) ──" +$BT --at 2022-02-24 buy OIL --amount 35 --price "$OIL_PRICE" + +# ── Leg 2: Long gold — geopolitical safe haven ──────────────────── +echo "── Leg 2: Long GOLD (war premium + safe haven) ──" +$BT --at 2022-02-24 buy GOLD --amount 2 --price "$GOLD_PRICE" + +# ── Leg 3: Short S&P 500 — risk-off selloff ────────────────────── +echo "── Leg 3: Short S&P 500 (risk-off) ──" +$BT --at 2022-02-24 sell SP500 --amount 1.5 --price "$SP_PRICE" + +# ── Portfolio snapshot ───────────────────────────────────────────── +echo "══════════════════════════════════════════════════════════════" +echo " Portfolio Summary" +echo "══════════════════════════════════════════════════════════════" +$BT --at 2022-02-24 balance +$BT --at 2022-02-24 positions + +# ── Cleanup ──────────────────────────────────────────────────────── +$BT --at 2022-02-24 reset > /dev/null 2>&1 diff --git a/src/backtest.rs b/src/backtest.rs new file mode 100644 index 0000000..0756008 --- /dev/null +++ b/src/backtest.rs @@ -0,0 +1,683 @@ +use anyhow::{Context, Result}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use crate::commands::quote::{coingecko_symbol_map, symbol_aliases}; +use crate::format::{color_change, color_pnl}; + +// ── Data types ────────────────────────────────────────────────────────── + +/// A single daily OHLCV bar. +#[derive(Debug, Clone, Serialize)] +pub struct DailyBar { + pub date: NaiveDate, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: f64, +} + +/// A simulated trade record. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimTrade { + pub id: usize, + pub symbol: String, + pub side: TradeSide, + pub amount: f64, + pub price: f64, + pub date: String, + pub trade_type: TradeType, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum TradeSide { + Buy, + Sell, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum TradeType { + Spot, + Perp, +} + +// ── Portfolio ─────────────────────────────────────────────────────────── + +/// A computed position for a (symbol, trade_type) pair. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Position { + pub symbol: String, + pub trade_type: TradeType, + pub net_quantity: f64, + pub avg_entry_price: f64, + pub total_cost: f64, + pub side: String, +} + +/// Portfolio state for backtesting. Persisted to ~/.fintool/backtest_portfolio.json. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Portfolio { + pub trades: Vec, + pub leverage_settings: HashMap, + next_id: usize, +} + +impl Portfolio { + pub fn new() -> Self { + Self { + trades: Vec::new(), + leverage_settings: HashMap::new(), + next_id: 1, + } + } + + pub fn add_trade( + &mut self, + symbol: &str, + side: TradeSide, + amount: f64, + price: f64, + date: NaiveDate, + trade_type: TradeType, + ) -> SimTrade { + let trade = SimTrade { + id: self.next_id, + symbol: symbol.to_uppercase(), + side, + amount, + price, + date: date.to_string(), + trade_type, + }; + self.next_id += 1; + self.trades.push(trade.clone()); + trade + } + + pub fn set_leverage(&mut self, symbol: &str, leverage: u32) { + self.leverage_settings + .insert(symbol.to_uppercase(), leverage); + } + + pub fn get_leverage(&self, symbol: &str) -> u32 { + *self + .leverage_settings + .get(&symbol.to_uppercase()) + .unwrap_or(&1) + } + + /// Load portfolio from disk, or return a fresh one if file doesn't exist. + pub fn load() -> Result { + let path = portfolio_path(); + if !path.exists() { + return Ok(Self::new()); + } + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read portfolio file: {}", path.display()))?; + let portfolio: Portfolio = serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse portfolio file: {}", path.display()))?; + Ok(portfolio) + } + + /// Save portfolio to disk. + pub fn save(&self) -> Result<()> { + let path = portfolio_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(self)?; + std::fs::write(&path, json) + .with_context(|| format!("Failed to write portfolio file: {}", path.display()))?; + Ok(()) + } + + /// Reset the portfolio (clear all trades and leverage, reset next_id). + pub fn reset(&mut self) { + self.trades.clear(); + self.leverage_settings.clear(); + self.next_id = 1; + } + + /// Compute cash balance from spot trades. Buy subtracts, sell adds. + pub fn cash_balance(&self) -> f64 { + let balance: f64 = self + .trades + .iter() + .filter(|t| t.trade_type == TradeType::Spot) + .map(|t| match t.side { + TradeSide::Buy => -(t.amount * t.price), + TradeSide::Sell => t.amount * t.price, + }) + .sum(); + if balance == 0.0 { 0.0 } else { balance } + } + + /// Compute net positions grouped by (symbol, trade_type). + pub fn positions(&self) -> Vec { + let mut groups: BTreeMap<(String, TradeType), Vec<&SimTrade>> = BTreeMap::new(); + for trade in &self.trades { + groups + .entry((trade.symbol.clone(), trade.trade_type)) + .or_default() + .push(trade); + } + + let mut positions = Vec::new(); + for ((symbol, trade_type), trades) in &groups { + let mut net_qty: f64 = 0.0; + let mut total_cost: f64 = 0.0; + // Track long cost basis + let mut long_cost: f64 = 0.0; + let mut long_qty: f64 = 0.0; + // Track short cost basis + let mut short_cost: f64 = 0.0; + let mut short_qty: f64 = 0.0; + + for t in trades { + match t.side { + TradeSide::Buy => { + net_qty += t.amount; + total_cost -= t.amount * t.price; + long_cost += t.amount * t.price; + long_qty += t.amount; + // Reduce short basis if closing a short + if short_qty > 0.0 { + let avg = short_cost / short_qty; + let reduce = t.amount.min(short_qty); + short_cost -= reduce * avg; + short_qty -= reduce; + } + } + TradeSide::Sell => { + net_qty -= t.amount; + total_cost += t.amount * t.price; + short_cost += t.amount * t.price; + short_qty += t.amount; + // Reduce long basis if closing a long + if long_qty > 0.0 { + let avg = long_cost / long_qty; + let reduce = t.amount.min(long_qty); + long_cost -= reduce * avg; + long_qty -= reduce; + } + } + } + } + + let avg_entry = if net_qty > 1e-12 && long_qty > 0.0 { + long_cost / long_qty + } else if net_qty < -1e-12 && short_qty > 0.0 { + short_cost / short_qty + } else { + 0.0 + }; + + let side = if net_qty > 1e-12 { + "long".to_string() + } else if net_qty < -1e-12 { + "short".to_string() + } else { + continue; // skip flat positions + }; + + positions.push(Position { + symbol: symbol.clone(), + trade_type: *trade_type, + net_quantity: net_qty, + avg_entry_price: avg_entry, + total_cost, + side, + }); + } + positions + } + + /// Total number of trades recorded. + pub fn trade_count(&self) -> usize { + self.trades.len() + } +} + +/// Return the portfolio state file path (~/.fintool/backtest_portfolio.json). +fn portfolio_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".fintool") + .join("backtest_portfolio.json") +} + +// ── Yahoo Finance historical data ─────────────────────────────────────── + +/// Resolve a symbol to Yahoo Finance ticker candidates. +fn resolve_yahoo_tickers(symbol: &str) -> Vec { + let upper = symbol.to_uppercase(); + let aliases = symbol_aliases(); + let resolved = aliases + .get(upper.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| upper.clone()); + + // For known crypto symbols, try -USD suffix first + let is_crypto = coingecko_symbol_map().contains_key(upper.as_str()); + if is_crypto { + vec![format!("{}-USD", upper), resolved] + } else { + vec![resolved] + } +} + +/// Fetch daily OHLCV bars from Yahoo Finance for a date range. +pub async fn fetch_yahoo_bars( + symbol: &str, + from: NaiveDate, + to: NaiveDate, +) -> Result> { + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0") + .build()?; + + let tickers = resolve_yahoo_tickers(symbol); + let period1 = from + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp(); + let period2 = to + .and_hms_opt(23, 59, 59) + .unwrap() + .and_utc() + .timestamp(); + + for ticker in &tickers { + let url = format!( + "https://query1.finance.yahoo.com/v8/finance/chart/{}?period1={}&period2={}&interval=1d", + ticker, period1, period2 + ); + let resp = client.get(&url).send().await; + let resp = match resp { + Ok(r) if r.status().is_success() => r, + _ => continue, + }; + let body: Value = match resp.json().await { + Ok(v) => v, + Err(_) => continue, + }; + + let result = &body["chart"]["result"]; + if result.is_null() || !result.is_array() { + continue; + } + let result = &result[0]; + let timestamps = match result["timestamp"].as_array() { + Some(ts) => ts, + None => continue, + }; + let quote = &result["indicators"]["quote"][0]; + let opens = quote["open"].as_array(); + let highs = quote["high"].as_array(); + let lows = quote["low"].as_array(); + let closes = quote["close"].as_array(); + let volumes = quote["volume"].as_array(); + + if opens.is_none() || closes.is_none() { + continue; + } + let opens = opens.unwrap(); + let highs = highs.unwrap(); + let lows = lows.unwrap(); + let closes = closes.unwrap(); + let volumes = volumes.unwrap(); + + let mut bars = Vec::new(); + for i in 0..timestamps.len() { + let ts = timestamps[i].as_i64().unwrap_or(0); + let date = chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| dt.date_naive()) + .unwrap_or(from); + + let open = opens[i].as_f64().unwrap_or(0.0); + let close = closes[i].as_f64().unwrap_or(0.0); + if close == 0.0 { + continue; // skip empty bars + } + + bars.push(DailyBar { + date, + open, + high: highs[i].as_f64().unwrap_or(open), + low: lows[i].as_f64().unwrap_or(open), + close, + volume: volumes[i].as_f64().unwrap_or(0.0), + }); + } + + if !bars.is_empty() { + bars.sort_by_key(|b| b.date); + return Ok(bars); + } + } + anyhow::bail!("No historical data found for '{}' on Yahoo Finance", symbol) +} + +// ── CoinGecko historical data ─────────────────────────────────────────── + +/// Fetch a single historical price from CoinGecko. +pub async fn fetch_coingecko_price(symbol: &str, date: NaiveDate) -> Result { + let map = coingecko_symbol_map(); + let upper = symbol.to_uppercase(); + let id = map + .get(upper.as_str()) + .ok_or_else(|| anyhow::anyhow!("Unknown CoinGecko symbol: {}", symbol))?; + let date_str = date.format("%d-%m-%Y").to_string(); + let url = format!( + "https://api.coingecko.com/api/v3/coins/{}/history?date={}&localization=false", + id, date_str + ); + let client = reqwest::Client::builder() + .user_agent("fintool/0.1") + .build()?; + let resp: Value = client + .get(&url) + .send() + .await? + .json() + .await + .context("Failed to parse CoinGecko response")?; + + resp["market_data"]["current_price"]["usd"] + .as_f64() + .ok_or_else(|| anyhow::anyhow!("No price data from CoinGecko for {} on {}", symbol, date)) +} + +// ── Convenience functions ─────────────────────────────────────────────── + +/// Fetch the historical close price at a specific date. +/// Tries Yahoo Finance first, falls back to CoinGecko for crypto. +pub async fn fetch_price_at_date(symbol: &str, date: NaiveDate) -> Result { + // Fetch a range around the date to handle weekends/holidays + let from = date - chrono::Duration::days(7); + if let Ok(bars) = fetch_yahoo_bars(symbol, from, date).await { + // Find the bar closest to (but not after) the target date + if let Some(bar) = bars.iter().rev().find(|b| b.date <= date) { + return Ok(bar.close); + } + } + // Fallback to CoinGecko + fetch_coingecko_price(symbol, date).await +} + +/// Fetch prices at multiple future offsets for PnL calculation. +/// Returns Vec of (label, target_date, price_if_available). +pub async fn fetch_pnl_prices( + symbol: &str, + trade_date: NaiveDate, +) -> Result)>> { + let offsets: &[(i64, &str)] = &[ + (1, "+1 day"), + (2, "+2 days"), + (4, "+4 days"), + (7, "+7 days"), + ]; + + // Fetch a wide range of bars (trade_date to +10 days buffer for weekends) + let end_date = trade_date + chrono::Duration::days(12); + let bars = fetch_yahoo_bars(symbol, trade_date, end_date).await; + + let mut results = Vec::new(); + for (days, label) in offsets { + let target = trade_date + chrono::Duration::days(*days); + let price = match &bars { + Ok(bars) => { + // Find closest bar on or after target date (handles weekends) + bars.iter() + .find(|b| b.date >= target) + .map(|b| b.close) + } + Err(_) => None, + }; + results.push((label.to_string(), target, price)); + } + Ok(results) +} + +// ── PnL display ───────────────────────────────────────────────────────── + +/// Build PnL JSON for a simulated trade (used by JSON mode). +pub fn build_pnl_json( + trade: &SimTrade, + future_prices: &[(String, NaiveDate, Option)], + leverage: u32, +) -> Value { + let multiplier = if trade.side == TradeSide::Buy { + 1.0 + } else { + -1.0 + }; + + let pnl_entries: Vec = future_prices + .iter() + .map(|(label, date, price)| match price { + Some(p) => { + let raw_pnl = (p - trade.price) * trade.amount * multiplier; + let leveraged_pnl = raw_pnl * leverage as f64; + let entry_cost = trade.amount * trade.price; + let pct = if entry_cost > 0.0 { + (leveraged_pnl / entry_cost) * 100.0 + } else { + 0.0 + }; + json!({ + "offset": label, + "date": date.to_string(), + "price": format!("{:.2}", p), + "pnl": format!("{:.2}", leveraged_pnl), + "pnlPct": format!("{:.2}", pct), + }) + } + None => json!({ + "offset": label, + "date": date.to_string(), + "price": null, + "pnl": null, + "pnlPct": null, + }), + }) + .collect(); + + json!({ + "trade": { + "id": trade.id, + "symbol": trade.symbol, + "side": trade.side, + "amount": trade.amount, + "price": trade.price, + "date": trade.date, + "type": trade.trade_type, + "leverage": leverage, + }, + "pnl": pnl_entries, + }) +} + +/// Build portfolio summary JSON. +pub fn build_portfolio_json(portfolio: &Portfolio) -> Value { + let cash = portfolio.cash_balance(); + let positions = portfolio.positions(); + let pos_json: Vec = positions + .iter() + .map(|p| { + json!({ + "symbol": p.symbol, + "type": p.trade_type, + "side": p.side, + "quantity": p.net_quantity, + "avgEntryPrice": format!("{:.2}", p.avg_entry_price), + }) + }) + .collect(); + json!({ + "cashBalance": format!("{:.2}", cash), + "positions": pos_json, + "totalTrades": portfolio.trade_count(), + }) +} + +/// Print a PnL table for a simulated trade (human-readable CLI mode). +pub fn print_pnl_table( + trade: &SimTrade, + future_prices: &[(String, NaiveDate, Option)], + leverage: u32, +) -> Result<()> { + let multiplier = if trade.side == TradeSide::Buy { + 1.0 + } else { + -1.0 + }; + + // Human-readable output + use colored::Colorize; + + let side_str = match trade.side { + TradeSide::Buy => "BUY", + TradeSide::Sell => "SELL", + }; + let type_str = match trade.trade_type { + TradeType::Spot => "spot", + TradeType::Perp => "perp", + }; + let total = trade.amount * trade.price; + + println!(); + println!( + " {} {} {} {} @ ${:.2} (${:.2} total) on {}{}", + "[BACKTEST]".dimmed(), + type_str, + side_str.bold(), + trade.symbol.cyan(), + trade.price, + total, + trade.date, + if leverage > 1 { + format!(" [{}x leverage]", leverage) + } else { + String::new() + } + ); + println!(); + + // Build table + let header: Vec = future_prices.iter().map(|(l, _, _)| l.clone()).collect(); + let mut dollar_vals = Vec::new(); + let mut pct_vals = Vec::new(); + let mut price_vals = Vec::new(); + + for (_, _, price) in future_prices { + match price { + Some(p) => { + let raw_pnl = (p - trade.price) * trade.amount * multiplier; + let leveraged_pnl = raw_pnl * leverage as f64; + let entry_cost = trade.amount * trade.price; + let pct = if entry_cost > 0.0 { + (leveraged_pnl / entry_cost) * 100.0 + } else { + 0.0 + }; + price_vals.push(format!("${:.2}", p)); + dollar_vals.push(color_pnl(&format!("{:.2}", leveraged_pnl))); + pct_vals.push(color_change(&format!("{:.2}", pct))); + } + None => { + price_vals.push("N/A".to_string()); + dollar_vals.push("N/A".to_string()); + pct_vals.push("N/A".to_string()); + } + } + } + + // Manual table — tabled doesn't handle dynamic columns well + let col_width = 14; + let label_width = 8; + + // Header + print!(" {:col_width$}", h); + } + println!(); + + // Separator + print!(" {:col_width$}", "──────────────"); + } + println!(); + + // Price row + print!(" {:col_width$}", v); + } + println!(); + + // PnL $ row + print!(" {:col_width$}", v); + } + println!(); + + // PnL % row + print!(" {:col_width$}", v); + } + println!(); + println!(); + + Ok(()) +} + +/// Print a portfolio summary (human-readable CLI mode). +pub fn print_portfolio_summary(portfolio: &Portfolio) { + use colored::Colorize; + + let cash = portfolio.cash_balance(); + let positions = portfolio.positions(); + + let cash_str = format!("${:.2}", cash); + let colored_cash = if cash >= 0.0 { + cash_str.green().to_string() + } else { + cash_str.red().to_string() + }; + println!( + " {} Cash balance: {}", + "[PORTFOLIO]".dimmed(), + colored_cash + ); + for p in &positions { + let type_str = match p.trade_type { + TradeType::Spot => "spot", + TradeType::Perp => "perp", + }; + println!( + " {} {} {} {}: {:.4} @ avg ${:.2}", + "[PORTFOLIO]".dimmed(), + type_str, + p.side, + p.symbol.cyan(), + p.net_quantity.abs(), + p.avg_entry_price, + ); + } + println!(); +} diff --git a/src/bin/backtest.rs b/src/bin/backtest.rs new file mode 100644 index 0000000..947a098 --- /dev/null +++ b/src/bin/backtest.rs @@ -0,0 +1,712 @@ +use anyhow::Result; +use chrono::NaiveDate; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use serde::Deserialize; +use serde_json::json; + +use fintool_lib::backtest::{self, Portfolio, TradeSide, TradeType}; +use fintool_lib::commands; + +#[derive(Parser)] +#[command( + name = "backtest", + about = "Backtesting CLI — simulate trades at historical dates with forward PnL analysis" +)] +struct Cli { + /// Historical date to simulate (YYYY-MM-DD format) + #[arg(long)] + at: String, + + #[command(subcommand)] + command: Option, + + /// JSON mode: pass a JSON command string for programmatic use (always outputs JSON). + #[arg(long)] + json: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Get historical price at the backtest date + Quote { + symbol: String, + }, + + /// Show news (stub — historical news not available) + News { + symbol: String, + }, + + /// SEC filings on or before the backtest date + #[command(subcommand)] + Report(ReportCmd), + + /// Simulated spot buy with forward PnL + Buy { + symbol: String, + #[arg(long)] + amount: f64, + #[arg(long)] + price: Option, + }, + + /// Simulated spot sell with forward PnL + Sell { + symbol: String, + #[arg(long)] + amount: f64, + #[arg(long)] + price: Option, + }, + + /// Perpetual futures simulation + #[command(subcommand)] + Perp(PerpCmd), + + /// Show simulated portfolio balance + Balance, + + /// Show simulated open positions + Positions, + + /// Reset simulated portfolio (clear all trades and positions) + Reset, +} + +#[derive(Subcommand)] +enum ReportCmd { + /// Latest 10-K annual filing on or before the backtest date + Annual { + symbol: String, + #[arg(long, short)] + output: Option, + }, + /// Latest 10-Q quarterly filing on or before the backtest date + Quarterly { + symbol: String, + #[arg(long, short)] + output: Option, + }, + /// List recent filings on or before the backtest date + List { + symbol: String, + #[arg(long, default_value = "10")] + limit: usize, + }, + /// Fetch a specific filing by accession number + Get { + symbol: String, + accession: String, + #[arg(long, short)] + output: Option, + }, +} + +#[derive(Subcommand)] +enum PerpCmd { + /// Simulated perp long with forward PnL + Buy { + symbol: String, + #[arg(long)] + amount: f64, + #[arg(long)] + price: Option, + #[arg(long)] + close: bool, + }, + /// Simulated perp short with forward PnL + Sell { + symbol: String, + #[arg(long)] + amount: f64, + #[arg(long)] + price: Option, + #[arg(long)] + close: bool, + }, + /// Set leverage for PnL calculation + Leverage { + symbol: String, + #[arg(long)] + leverage: u32, + }, +} + +// ── JSON mode ─────────────────────────────────────────────────────────── + +fn default_limit() -> usize { + 10 +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +enum JsonCommand { + Quote { + symbol: String, + }, + News { + symbol: String, + }, + ReportAnnual { + symbol: String, + output: Option, + }, + ReportQuarterly { + symbol: String, + output: Option, + }, + ReportList { + symbol: String, + #[serde(default = "default_limit")] + limit: usize, + }, + ReportGet { + symbol: String, + accession: String, + output: Option, + }, + Buy { + symbol: String, + amount: f64, + price: Option, + }, + Sell { + symbol: String, + amount: f64, + price: Option, + }, + PerpBuy { + symbol: String, + amount: f64, + price: Option, + #[serde(default)] + close: bool, + }, + PerpSell { + symbol: String, + amount: f64, + price: Option, + #[serde(default)] + close: bool, + }, + PerpLeverage { + symbol: String, + leverage: u32, + }, + Balance, + Positions, + Reset, +} + +// ── Command handlers ──────────────────────────────────────────────────── + +async fn cmd_quote(symbol: &str, date: NaiveDate, json_output: bool) -> Result<()> { + let price = backtest::fetch_price_at_date(symbol, date).await?; + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "symbol": symbol.to_uppercase(), + "date": date.to_string(), + "price": format!("{:.2}", price), + }))? + ); + } else { + println!(); + println!( + " {} historical price on {}", + symbol.to_uppercase().bold().cyan(), + date + ); + println!(" Price: ${:.2}", price); + println!(); + } + Ok(()) +} + +async fn cmd_news(symbol: &str, _date: NaiveDate, json_output: bool) -> Result<()> { + let msg = "Historical news not available for backtesting. Use `fintool news` for current news."; + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "symbol": symbol.to_uppercase(), + "message": msg, + }))? + ); + } else { + println!("\n {}\n", msg); + } + Ok(()) +} + +async fn cmd_report_list( + symbol: &str, + limit: usize, + date: NaiveDate, + json_output: bool, +) -> Result<()> { + let date_str = date.to_string(); + let (cik, company) = commands::report::resolve_cik(symbol).await?; + let filings = commands::report::get_filings(cik, None, limit, Some(&date_str)).await?; + + if json_output { + println!("{}", serde_json::to_string_pretty(&filings)?); + } else { + println!( + "\n {} recent filings for {} ({}) on or before {}:\n", + limit, + company.bold(), + symbol.to_uppercase(), + date, + ); + println!( + " {:<8} {:<12} {:<12} {}", + "Form", "Filed", "Period", "Accession Number" + ); + println!(" {}", "-".repeat(66)); + for f in &filings { + println!( + " {:<8} {:<12} {:<12} {}", + f.form, f.filing_date, f.report_date, f.accession_number + ); + } + println!(); + } + Ok(()) +} + +async fn cmd_report_annual( + symbol: &str, + output: Option<&str>, + date: NaiveDate, + json_output: bool, +) -> Result<()> { + let date_str = date.to_string(); + let (cik, _company) = commands::report::resolve_cik(symbol).await?; + let filings = commands::report::get_filings(cik, Some("10-K"), 1, Some(&date_str)).await?; + let filing = filings + .first() + .ok_or_else(|| anyhow::anyhow!("No 10-K filing found for {} before {}", symbol, date))?; + commands::report::get(symbol, &filing.accession_number, output, json_output).await +} + +async fn cmd_report_quarterly( + symbol: &str, + output: Option<&str>, + date: NaiveDate, + json_output: bool, +) -> Result<()> { + let date_str = date.to_string(); + let (cik, _company) = commands::report::resolve_cik(symbol).await?; + let filings = commands::report::get_filings(cik, Some("10-Q"), 1, Some(&date_str)).await?; + let filing = filings + .first() + .ok_or_else(|| anyhow::anyhow!("No 10-Q filing found for {} before {}", symbol, date))?; + commands::report::get(symbol, &filing.accession_number, output, json_output).await +} + +async fn cmd_trade( + symbol: &str, + amount: f64, + price: Option, + side: TradeSide, + trade_type: TradeType, + date: NaiveDate, + portfolio: &mut Portfolio, + json_output: bool, +) -> Result<()> { + let price = match price { + Some(p) => p, + None => backtest::fetch_price_at_date(symbol, date).await?, + }; + + let leverage = if trade_type == TradeType::Perp { + portfolio.get_leverage(symbol) + } else { + 1 + }; + + let trade = portfolio.add_trade(symbol, side, amount, price, date, trade_type); + portfolio.save()?; + + let pnl_prices = backtest::fetch_pnl_prices(symbol, date).await?; + + if json_output { + let mut output = backtest::build_pnl_json(&trade, &pnl_prices, leverage); + output["portfolio"] = backtest::build_portfolio_json(portfolio); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + backtest::print_pnl_table(&trade, &pnl_prices, leverage)?; + backtest::print_portfolio_summary(portfolio); + } + Ok(()) +} + +// ── JSON dispatch ─────────────────────────────────────────────────────── + +async fn run_json( + json_str: &str, + date: NaiveDate, + portfolio: &mut Portfolio, +) -> Result<()> { + let cmd: JsonCommand = serde_json::from_str(json_str) + .map_err(|e| anyhow::anyhow!("Invalid JSON command: {}", e))?; + + match cmd { + JsonCommand::Quote { symbol } => cmd_quote(&symbol, date, true).await, + JsonCommand::News { symbol } => cmd_news(&symbol, date, true).await, + JsonCommand::ReportList { symbol, limit } => { + cmd_report_list(&symbol, limit, date, true).await + } + JsonCommand::ReportAnnual { symbol, output } => { + cmd_report_annual(&symbol, output.as_deref(), date, true).await + } + JsonCommand::ReportQuarterly { symbol, output } => { + cmd_report_quarterly(&symbol, output.as_deref(), date, true).await + } + JsonCommand::ReportGet { + symbol, + accession, + output, + } => commands::report::get(&symbol, &accession, output.as_deref(), true).await, + JsonCommand::Buy { + symbol, + amount, + price, + } => { + cmd_trade( + &symbol, + amount, + price, + TradeSide::Buy, + TradeType::Spot, + date, + portfolio, + true, + ) + .await + } + JsonCommand::Sell { + symbol, + amount, + price, + } => { + cmd_trade( + &symbol, + amount, + price, + TradeSide::Sell, + TradeType::Spot, + date, + portfolio, + true, + ) + .await + } + JsonCommand::PerpBuy { + symbol, + amount, + price, + .. + } => { + cmd_trade( + &symbol, + amount, + price, + TradeSide::Buy, + TradeType::Perp, + date, + portfolio, + true, + ) + .await + } + JsonCommand::PerpSell { + symbol, + amount, + price, + .. + } => { + cmd_trade( + &symbol, + amount, + price, + TradeSide::Sell, + TradeType::Perp, + date, + portfolio, + true, + ) + .await + } + JsonCommand::PerpLeverage { symbol, leverage } => { + portfolio.set_leverage(&symbol, leverage); + portfolio.save()?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "symbol": symbol.to_uppercase(), + "leverage": leverage, + }))? + ); + Ok(()) + } + JsonCommand::Balance => { + let cash = portfolio.cash_balance(); + let positions = portfolio.positions(); + let pos_json: Vec = positions + .iter() + .map(|p| { + json!({ + "symbol": p.symbol, + "type": p.trade_type, + "side": p.side, + "quantity": p.net_quantity, + "avgEntryPrice": format!("{:.2}", p.avg_entry_price), + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "cashBalance": format!("{:.2}", cash), + "positions": pos_json, + "totalTrades": portfolio.trade_count(), + "leverageSettings": &portfolio.leverage_settings, + }))? + ); + Ok(()) + } + JsonCommand::Positions => { + let positions = portfolio.positions(); + let pos_json: Vec = positions + .iter() + .map(|p| { + json!({ + "symbol": p.symbol, + "type": p.trade_type, + "side": p.side, + "quantity": p.net_quantity, + "avgEntryPrice": format!("{:.2}", p.avg_entry_price), + "totalCost": format!("{:.2}", p.total_cost), + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "positions": pos_json, + }))? + ); + Ok(()) + } + JsonCommand::Reset => { + portfolio.reset(); + portfolio.save()?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "status": "ok", + "message": "Portfolio reset. All trades and positions cleared.", + }))? + ); + Ok(()) + } + } +} + +// ── Main ──────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Parse the --at date + let date = NaiveDate::parse_from_str(&cli.at, "%Y-%m-%d").map_err(|e| { + anyhow::anyhow!( + "Invalid date '{}': {}. Use YYYY-MM-DD format.", + cli.at, + e + ) + })?; + + // Validate date is not in the future + let today = chrono::Utc::now().date_naive(); + if date > today { + anyhow::bail!("Backtest date {} is in the future. Use a past date.", date); + } + + let mut portfolio = Portfolio::load().unwrap_or_else(|e| { + eprintln!("Warning: could not load portfolio state: {:#}. Starting fresh.", e); + Portfolio::new() + }); + + // JSON mode + if let Some(ref json_str) = cli.json { + let result = run_json(json_str, date, &mut portfolio).await; + if let Err(e) = result { + let err_json = json!({"error": format!("{:#}", e)}); + println!("{}", serde_json::to_string_pretty(&err_json).unwrap()); + std::process::exit(1); + } + return Ok(()); + } + + // CLI mode + let command = match cli.command { + Some(cmd) => cmd, + None => { + Cli::parse_from(["backtest", "--at", &cli.at, "--help"]); + unreachable!() + } + }; + let json_output = false; + + let result = match command { + Commands::Quote { symbol } => cmd_quote(&symbol, date, json_output).await, + Commands::News { symbol } => cmd_news(&symbol, date, json_output).await, + Commands::Report(cmd) => match cmd { + ReportCmd::Annual { symbol, output } => { + cmd_report_annual(&symbol, output.as_deref(), date, json_output).await + } + ReportCmd::Quarterly { symbol, output } => { + cmd_report_quarterly(&symbol, output.as_deref(), date, json_output).await + } + ReportCmd::List { symbol, limit } => { + cmd_report_list(&symbol, limit, date, json_output).await + } + ReportCmd::Get { + symbol, + accession, + output, + } => commands::report::get(&symbol, &accession, output.as_deref(), json_output).await, + }, + Commands::Buy { + symbol, + amount, + price, + } => { + cmd_trade( + &symbol, amount, price, TradeSide::Buy, TradeType::Spot, date, &mut portfolio, + json_output, + ) + .await + } + Commands::Sell { + symbol, + amount, + price, + } => { + cmd_trade( + &symbol, amount, price, TradeSide::Sell, TradeType::Spot, date, &mut portfolio, + json_output, + ) + .await + } + Commands::Perp(cmd) => match cmd { + PerpCmd::Buy { + symbol, + amount, + price, + .. + } => { + cmd_trade( + &symbol, amount, price, TradeSide::Buy, TradeType::Perp, date, + &mut portfolio, json_output, + ) + .await + } + PerpCmd::Sell { + symbol, + amount, + price, + .. + } => { + cmd_trade( + &symbol, amount, price, TradeSide::Sell, TradeType::Perp, date, + &mut portfolio, json_output, + ) + .await + } + PerpCmd::Leverage { symbol, leverage } => { + portfolio.set_leverage(&symbol, leverage); + portfolio.save()?; + println!( + "\n Leverage for {} set to {}x\n", + symbol.to_uppercase().bold(), + leverage + ); + Ok(()) + } + }, + Commands::Balance => { + let cash = portfolio.cash_balance(); + let positions = portfolio.positions(); + println!( + "\n {} Simulated portfolio", + "[BACKTEST]".dimmed() + ); + let cash_str = format!("${:.2}", cash); + let colored_cash = if cash >= 0.0 { + cash_str.green().to_string() + } else { + cash_str.red().to_string() + }; + println!(" Cash balance: {}", colored_cash); + println!(" Total trades: {}", portfolio.trade_count()); + if !positions.is_empty() { + println!(" Open positions: {}", positions.len()); + } + println!(); + Ok(()) + } + Commands::Positions => { + let positions = portfolio.positions(); + if positions.is_empty() { + println!( + "\n {} No open positions.\n", + "[BACKTEST]".dimmed() + ); + } else { + println!( + "\n {} Open positions:\n", + "[BACKTEST]".dimmed() + ); + println!( + " {:<10} {:<6} {:<8} {:>12} {:>14}", + "Symbol", "Type", "Side", "Quantity", "Avg Entry" + ); + println!(" {}", "-".repeat(54)); + for p in &positions { + let type_str = match p.trade_type { + TradeType::Spot => "spot", + TradeType::Perp => "perp", + }; + println!( + " {:<10} {:<6} {:<8} {:>12.4} {:>14.2}", + p.symbol, type_str, p.side, p.net_quantity.abs(), p.avg_entry_price + ); + } + println!(); + } + Ok(()) + } + Commands::Reset => { + portfolio.reset(); + portfolio.save()?; + println!( + "\n {} Portfolio reset. All trades and positions cleared.\n", + "[BACKTEST]".dimmed() + ); + Ok(()) + } + }; + + if let Err(e) = result { + eprintln!("{}: {:#}", "Error".red(), e); + std::process::exit(1); + } + Ok(()) +} diff --git a/src/commands/quote.rs b/src/commands/quote.rs index 61fa057..e271774 100644 --- a/src/commands/quote.rs +++ b/src/commands/quote.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use crate::config; /// Aliases for common index/ETF symbols → Yahoo Finance tickers -fn symbol_aliases() -> HashMap<&'static str, &'static str> { +pub fn symbol_aliases() -> HashMap<&'static str, &'static str> { let mut map = HashMap::new(); // Indices map.insert("SP500", "^GSPC"); @@ -41,7 +41,7 @@ fn symbol_aliases() -> HashMap<&'static str, &'static str> { } /// CoinGecko symbol to ID mapping for top cryptos -fn coingecko_symbol_map() -> HashMap<&'static str, &'static str> { +pub fn coingecko_symbol_map() -> HashMap<&'static str, &'static str> { let mut map = HashMap::new(); map.insert("BTC", "bitcoin"); map.insert("ETH", "ethereum"); diff --git a/src/commands/report.rs b/src/commands/report.rs index b6b5605..4319fa4 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -8,20 +8,20 @@ const USER_AGENT: &str = "fintool contact@fintool.dev"; #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] -struct Filing { - form: String, - filing_date: String, - report_date: String, - accession_number: String, - primary_document: String, - url: String, +pub struct Filing { + pub form: String, + pub filing_date: String, + pub report_date: String, + pub accession_number: String, + pub primary_document: String, + pub url: String, } fn client() -> Result { Ok(reqwest::Client::builder().user_agent(USER_AGENT).build()?) } -async fn resolve_cik(symbol: &str) -> Result<(u64, String)> { +pub async fn resolve_cik(symbol: &str) -> Result<(u64, String)> { let c = client()?; let body: HashMap = c .get("https://www.sec.gov/files/company_tickers.json") @@ -41,7 +41,12 @@ async fn resolve_cik(symbol: &str) -> Result<(u64, String)> { Err(anyhow!("Ticker '{}' not found in SEC EDGAR", symbol)) } -async fn get_filings(cik: u64, form_type: Option<&str>, limit: usize) -> Result> { +pub async fn get_filings( + cik: u64, + form_type: Option<&str>, + limit: usize, + before_date: Option<&str>, +) -> Result> { let c = client()?; let url = format!("https://data.sec.gov/submissions/CIK{:010}.json", cik); let body: Value = c.get(&url).send().await?.json().await?; @@ -63,6 +68,12 @@ async fn get_filings(cik: u64, form_type: Option<&str>, limit: usize) -> Result< continue; } } + if let Some(cutoff) = before_date { + let filing_date = filing_dates[i].as_str().unwrap_or(""); + if filing_date > cutoff { + continue; + } + } let accession = accessions[i].as_str().unwrap_or(""); let primary_doc = primary_docs[i].as_str().unwrap_or(""); let acc_no_dashes = accession.replace('-', ""); @@ -142,7 +153,7 @@ async fn fetch_and_output( json: bool, ) -> Result<()> { let (cik, company) = resolve_cik(symbol).await?; - let filings = get_filings(cik, Some(form_type), 1).await?; + let filings = get_filings(cik, Some(form_type), 1, None).await?; let filing = filings .first() .ok_or_else(|| anyhow!("No {} filing found for {}", form_type, symbol))?; @@ -205,7 +216,7 @@ pub async fn quarterly(symbol: &str, output: Option<&str>, json: bool) -> Result pub async fn list(symbol: &str, limit: usize, json: bool) -> Result<()> { let (cik, company) = resolve_cik(symbol).await?; - let filings = get_filings(cik, None, limit).await?; + let filings = get_filings(cik, None, limit, None).await?; if json { println!("{}", serde_json::to_string_pretty(&filings)?); @@ -231,7 +242,7 @@ pub async fn list(symbol: &str, limit: usize, json: bool) -> Result<()> { pub async fn get(symbol: &str, accession: &str, output: Option<&str>, json: bool) -> Result<()> { let (cik, company) = resolve_cik(symbol).await?; - let filings = get_filings(cik, None, 100).await?; + let filings = get_filings(cik, None, 100, None).await?; let filing = filings .iter() .find(|f| f.accession_number == accession) diff --git a/src/lib.rs b/src/lib.rs index 3f83645..fc52497 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod backtest; pub mod binance; pub mod bridge; pub mod coinbase; diff --git a/tests/backtest/balance.sh b/tests/backtest/balance.sh new file mode 100755 index 0000000..51479f7 --- /dev/null +++ b/tests/backtest/balance.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Test portfolio balance tracking via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Reset portfolio +# 2. Buy BTC — verify cash goes negative +# 3. Sell BTC at higher price — verify cash becomes positive +# +# Usage: ./tests/backtest/balance.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Portfolio balance tracking (JSON API)" + +# ── Reset ────────────────────────────────────────────────────────────── +info "Resetting portfolio..." +bt "2025-01-15" '{"command":"reset"}' > /dev/null + +RESULT=$(bt "2025-01-15" '{"command":"balance"}') +CASH=$(echo "$RESULT" | jq -r '.cashBalance // empty') +TRADES=$(echo "$RESULT" | jq -r '.totalTrades // empty') + +if [[ "$TRADES" != "0" ]]; then + fail "Reset failed: $TRADES trades remain" + exit 1 +fi +ok "Start: cash \$$CASH, $TRADES trades" + +# ── Buy BTC ──────────────────────────────────────────────────────────── +info "Buying 0.01 BTC..." +RESULT=$(bt "2025-01-15" '{"command":"buy","symbol":"BTC","amount":0.01}') + +CASH=$(echo "$RESULT" | jq -r '.portfolio.cashBalance // empty') +if echo "$CASH" | grep -q '^-'; then + ok "After buy: cash \$$CASH (negative — correct)" +else + fail "Expected negative cash after buy, got: \$$CASH" + exit 1 +fi + +# ── Sell BTC at profit ───────────────────────────────────────────────── +info "Selling 0.01 BTC at \$105000 (above entry)..." +RESULT=$(bt "2025-02-15" '{"command":"sell","symbol":"BTC","amount":0.01,"price":105000}') + +CASH=$(echo "$RESULT" | jq -r '.portfolio.cashBalance // empty') +if echo "$CASH" | grep -qv '^-'; then + ok "After sell: cash \$$CASH (positive — profit!)" +else + fail "Expected positive cash after profitable sell, got: \$$CASH" + exit 1 +fi + +# ── Verify via balance command ───────────────────────────────────────── +RESULT=$(bt "2025-02-15" '{"command":"balance"}') +CASH=$(echo "$RESULT" | jq -r '.cashBalance // empty') +TRADES=$(echo "$RESULT" | jq -r '.totalTrades // empty') +POS_COUNT=$(echo "$RESULT" | jq '.positions | length') + +done_step +ok "Final: cash \$$CASH, $TRADES trades, $POS_COUNT open positions" +echo "$RESULT" | jq . + +# ── Cleanup ──────────────────────────────────────────────────────────── +bt "2025-01-15" '{"command":"reset"}' > /dev/null diff --git a/tests/backtest/buy_spot.sh b/tests/backtest/buy_spot.sh new file mode 100755 index 0000000..16b4fd7 --- /dev/null +++ b/tests/backtest/buy_spot.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Simulated BTC spot buy with forward PnL and portfolio tracking +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Reset portfolio +# 2. Simulate buying 0.01 BTC on 2025-01-15 +# 3. Verify trade details, PnL offsets, and portfolio state +# +# Usage: ./tests/backtest/buy_spot.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Simulated BTC spot buy on 2025-01-15 (JSON API)" + +# ── Reset portfolio ──────────────────────────────────────────────────── +bt "2025-01-15" '{"command":"reset"}' > /dev/null + +# ── Simulate spot buy ────────────────────────────────────────────────── +info "Buying 0.01 BTC at historical price..." +RESULT=$(bt "2025-01-15" '{"command":"buy","symbol":"BTC","amount":0.01}') + +if [[ -z "$RESULT" ]]; then + fail "BTC spot buy returned empty" + exit 1 +fi + +# Verify trade details +SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') +SYMBOL=$(echo "$RESULT" | jq -r '.trade.symbol // empty') +AMOUNT=$(echo "$RESULT" | jq -r '.trade.amount // empty') +TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.type // empty') + +if [[ "$SIDE" != "buy" ]]; then + fail "Expected side buy, got: $SIDE" + echo "$RESULT" | jq . + exit 1 +fi + +if [[ "$TRADE_TYPE" != "spot" ]]; then + fail "Expected type spot, got: $TRADE_TYPE" + exit 1 +fi + +# Verify PnL offsets +PNL_COUNT=$(echo "$RESULT" | jq '.pnl | length') +if [[ "$PNL_COUNT" -lt 1 ]]; then + fail "No PnL data returned" + echo "$RESULT" | jq . + exit 1 +fi + +# Verify portfolio data +CASH=$(echo "$RESULT" | jq -r '.portfolio.cashBalance // empty') +POS_COUNT=$(echo "$RESULT" | jq '.portfolio.positions | length') + +if [[ -z "$CASH" ]]; then + fail "Trade output missing portfolio.cashBalance" + exit 1 +fi + +if [[ "$POS_COUNT" -lt 1 ]]; then + fail "Trade output missing positions" + exit 1 +fi + +done_step +ok "BTC spot buy: $AMOUNT BTC (side=$SIDE, type=$TRADE_TYPE)" +ok "PnL offsets returned: $PNL_COUNT" +ok "Cash balance: \$$CASH" +echo "$RESULT" | jq '.pnl[] | {offset, price, pnl, pnlPct}' + +# ── Cleanup ──────────────────────────────────────────────────────────── +bt "2025-01-15" '{"command":"reset"}' > /dev/null diff --git a/tests/backtest/e2e_backtest.sh b/tests/backtest/e2e_backtest.sh new file mode 100755 index 0000000..07c8670 --- /dev/null +++ b/tests/backtest/e2e_backtest.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# +# End-to-end backtest CLI tests +# +# Tests historical quotes, simulated trades, PnL output, persistent +# portfolio balance and positions. +# +# Usage: ./tests/backtest/e2e_backtest.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +# ══════════════════════════════════════════════════════════════════════ +# 0. Reset portfolio +# ══════════════════════════════════════════════════════════════════════ +log "Step 0: Reset portfolio" + +RESULT=$(bt "2025-01-15" '{"command":"reset"}') +STATUS=$(echo "$RESULT" | jq -r '.status // empty') +if [[ "$STATUS" == "ok" ]]; then + ok "Portfolio reset" +else + fail "Portfolio reset failed" + echo "$RESULT" | jq . + exit 1 +fi + +# ══════════════════════════════════════════════════════════════════════ +# 1. Historical quote — BTC +# ══════════════════════════════════════════════════════════════════════ +log "Step 1: Historical BTC quote on 2025-01-15" + +RESULT=$(bt "2025-01-15" '{"command":"quote","symbol":"BTC"}') +if [[ -z "$RESULT" ]]; then + fail "BTC historical quote failed" + exit 1 +fi + +PRICE=$(echo "$RESULT" | jq -r '.price // empty') +if [[ -z "$PRICE" ]]; then + fail "BTC quote returned but price is missing" + echo "$RESULT" | jq . + exit 1 +fi + +ok "BTC price on 2025-01-15: \$$PRICE" + +# ══════════════════════════════════════════════════════════════════════ +# 2. Historical quote — AAPL (stock) +# ══════════════════════════════════════════════════════════════════════ +log "Step 2: Historical AAPL quote on 2025-01-15" + +RESULT=$(bt "2025-01-15" '{"command":"quote","symbol":"AAPL"}') +PRICE=$(echo "$RESULT" | jq -r '.price // empty') + +if [[ -n "$PRICE" ]]; then + ok "AAPL price on 2025-01-15: \$$PRICE" +else + warn "AAPL quote returned no price" + echo "$RESULT" | jq . +fi + +# ══════════════════════════════════════════════════════════════════════ +# 3. Historical quote — GOLD (commodity alias) +# ══════════════════════════════════════════════════════════════════════ +log "Step 3: Historical GOLD quote on 2025-01-15" + +RESULT=$(bt "2025-01-15" '{"command":"quote","symbol":"GOLD"}') +PRICE=$(echo "$RESULT" | jq -r '.price // empty') + +if [[ -n "$PRICE" ]]; then + ok "GOLD price on 2025-01-15: \$$PRICE" +else + warn "GOLD quote returned no price" +fi + +# ══════════════════════════════════════════════════════════════════════ +# 4. Simulated spot buy with PnL + portfolio +# ══════════════════════════════════════════════════════════════════════ +log "Step 4: Simulated BTC spot buy" + +RESULT=$(bt "2025-01-15" '{"command":"buy","symbol":"BTC","amount":0.01}') +if [[ -z "$RESULT" ]]; then + fail "BTC simulated buy failed" + exit 1 +fi + +PNL_COUNT=$(echo "$RESULT" | jq '.pnl | length') +if [[ "$PNL_COUNT" -ge 1 ]]; then + ok "BTC buy returned PnL with $PNL_COUNT offsets" +else + warn "BTC buy returned no PnL data" + echo "$RESULT" | jq . +fi + +# Verify portfolio is included in trade output +CASH=$(echo "$RESULT" | jq -r '.portfolio.cashBalance // empty') +if [[ -n "$CASH" ]]; then + ok "Portfolio cash balance after buy: \$$CASH" +else + fail "Trade output missing portfolio.cashBalance" +fi + +POS_COUNT=$(echo "$RESULT" | jq '.portfolio.positions | length') +if [[ "$POS_COUNT" -ge 1 ]]; then + ok "Portfolio shows $POS_COUNT position(s)" +else + fail "No positions after buy" +fi + +# ══════════════════════════════════════════════════════════════════════ +# 5. Check balance (should be negative) +# ══════════════════════════════════════════════════════════════════════ +log "Step 5: Check balance (should be negative after buy)" + +RESULT=$(bt "2025-01-15" '{"command":"balance"}') +CASH=$(echo "$RESULT" | jq -r '.cashBalance // empty') +TRADES=$(echo "$RESULT" | jq -r '.totalTrades // empty') + +# Cash should be negative (we bought) +if echo "$CASH" | grep -q '^-'; then + ok "Cash balance is negative: \$$CASH (correct after buy)" +else + warn "Expected negative cash balance, got: \$$CASH" +fi +ok "Total trades: $TRADES" + +# ══════════════════════════════════════════════════════════════════════ +# 6. Simulated perp sell (short) with PnL +# ══════════════════════════════════════════════════════════════════════ +log "Step 6: Simulated ETH perp short" + +RESULT=$(bt "2025-01-15" '{"command":"perp_sell","symbol":"ETH","amount":0.1,"price":3300}') +if [[ -z "$RESULT" ]]; then + fail "ETH perp short failed" + exit 1 +fi + +SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') +if [[ "$SIDE" == "sell" ]]; then + ok "ETH perp short recorded (side=$SIDE)" +else + warn "Unexpected trade side: $SIDE" +fi + +# ══════════════════════════════════════════════════════════════════════ +# 7. Check positions +# ══════════════════════════════════════════════════════════════════════ +log "Step 7: Check positions" + +RESULT=$(bt "2025-01-15" '{"command":"positions"}') +POS_COUNT=$(echo "$RESULT" | jq '.positions | length') +if [[ "$POS_COUNT" -ge 2 ]]; then + ok "Found $POS_COUNT positions (BTC spot + ETH perp)" + echo "$RESULT" | jq '.positions[] | {symbol, type, side, quantity}' +else + warn "Expected 2 positions, got $POS_COUNT" + echo "$RESULT" | jq . +fi + +# ══════════════════════════════════════════════════════════════════════ +# 8. News stub +# ══════════════════════════════════════════════════════════════════════ +log "Step 8: News stub" + +RESULT=$(bt "2025-01-15" '{"command":"news","symbol":"BTC"}') +MSG=$(echo "$RESULT" | jq -r '.message // empty') +if [[ "$MSG" == *"not available"* ]]; then + ok "News stub returned expected message" +else + warn "Unexpected news response" + echo "$RESULT" | jq . +fi + +# ══════════════════════════════════════════════════════════════════════ +# 9. SEC report list with date filter +# ══════════════════════════════════════════════════════════════════════ +log "Step 9: SEC filings for AAPL before 2024-06-01" + +RESULT=$(bt "2024-06-01" '{"command":"report_list","symbol":"AAPL","limit":3}') +if [[ -z "$RESULT" ]]; then + fail "Report list failed" + exit 1 +fi + +COUNT=$(echo "$RESULT" | jq 'length') +if [[ "$COUNT" -ge 1 ]]; then + ok "Found $COUNT filings for AAPL before 2024-06-01" + echo "$RESULT" | jq '.[] | {form, filingDate}' +else + warn "No filings returned" +fi + +# ══════════════════════════════════════════════════════════════════════ +# 10. Reset and verify clean state +# ══════════════════════════════════════════════════════════════════════ +log "Step 10: Reset and verify clean state" + +bt "2025-01-15" '{"command":"reset"}' > /dev/null +RESULT=$(bt "2025-01-15" '{"command":"balance"}') +CASH=$(echo "$RESULT" | jq -r '.cashBalance // empty') +TRADES=$(echo "$RESULT" | jq -r '.totalTrades // empty') + +if [[ "$TRADES" == "0" ]]; then + ok "Portfolio reset: 0 trades, cash \$$CASH" +else + fail "Reset failed: $TRADES trades remain" +fi + +done_step +ok "End-to-end backtest workflow complete" diff --git a/tests/backtest/news_stub.sh b/tests/backtest/news_stub.sh new file mode 100755 index 0000000..cb89153 --- /dev/null +++ b/tests/backtest/news_stub.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# News stub test via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Request historical news for BTC +# 2. Verify stub message is returned +# +# Usage: ./tests/backtest/news_stub.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "News stub for BTC on 2025-01-15 (JSON API)" + +# ── Test news stub ───────────────────────────────────────────────────── +info "Requesting BTC news..." +RESULT=$(bt "2025-01-15" '{"command":"news","symbol":"BTC"}') + +if [[ -z "$RESULT" ]]; then + fail "News command returned empty" + exit 1 +fi + +MSG=$(echo "$RESULT" | jq -r '.message // empty') + +if [[ "$MSG" == *"not available"* ]]; then + done_step + ok "News stub returned expected message" + echo "$RESULT" | jq . +else + fail "Unexpected news response" + echo "$RESULT" | jq . + exit 1 +fi diff --git a/tests/backtest/perp_buy.sh b/tests/backtest/perp_buy.sh new file mode 100755 index 0000000..040fc26 --- /dev/null +++ b/tests/backtest/perp_buy.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# Simulated ETH perp buy (long) with forward PnL via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Set ETH leverage to 3x +# 2. Simulate perp buy of 0.1 ETH at $3300 on 2025-01-15 +# 3. Verify trade details and leveraged PnL +# +# Usage: ./tests/backtest/perp_buy.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Simulated ETH perp long on 2025-01-15 (JSON API)" + +# ── Set leverage ─────────────────────────────────────────────────────── +info "Setting ETH leverage to 3x..." +RESULT=$(bt "2025-01-15" '{"command":"perp_leverage","symbol":"ETH","leverage":3}') + +if [[ -z "$RESULT" ]]; then + fail "ETH set leverage failed" + exit 1 +fi + +LEVERAGE=$(echo "$RESULT" | jq -r '.leverage // empty') +ok "ETH leverage set to ${LEVERAGE}x" + +# ── Simulate perp buy ───────────────────────────────────────────────── +info "Perp buying 0.1 ETH at \$3300..." +RESULT=$(bt "2025-01-15" '{"command":"perp_buy","symbol":"ETH","amount":0.1,"price":3300}') + +if [[ -z "$RESULT" ]]; then + fail "ETH perp buy returned empty" + exit 1 +fi + +# Verify trade details +SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') +TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.tradeType // empty') + +if [[ "$SIDE" != "buy" ]]; then + fail "Expected side buy, got: $SIDE" + echo "$RESULT" | jq . + exit 1 +fi + +if [[ "$TRADE_TYPE" != "perp" ]]; then + fail "Expected tradeType perp, got: $TRADE_TYPE" + exit 1 +fi + +# Verify PnL offsets +PNL_COUNT=$(echo "$RESULT" | jq '.pnl | length') +if [[ "$PNL_COUNT" -lt 1 ]]; then + fail "No PnL data returned" + echo "$RESULT" | jq . + exit 1 +fi + +done_step +ok "ETH perp buy: 0.1 ETH (side=$SIDE, type=$TRADE_TYPE)" +ok "PnL offsets returned: $PNL_COUNT" +echo "$RESULT" | jq '.pnl[] | {offset, price, pnl, pnlPct}' diff --git a/tests/backtest/perp_sell.sh b/tests/backtest/perp_sell.sh new file mode 100755 index 0000000..b0fbc28 --- /dev/null +++ b/tests/backtest/perp_sell.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# Simulated BTC perp sell (short) with forward PnL via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Simulate perp sell of 0.01 BTC at $100000 on 2025-01-15 +# 2. Verify trade details and PnL offsets +# +# Usage: ./tests/backtest/perp_sell.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Simulated BTC perp short on 2025-01-15 (JSON API)" + +# ── Simulate perp sell ───────────────────────────────────────────────── +info "Perp selling 0.01 BTC at \$100000..." +RESULT=$(bt "2025-01-15" '{"command":"perp_sell","symbol":"BTC","amount":0.01,"price":100000}') + +if [[ -z "$RESULT" ]]; then + fail "BTC perp sell returned empty" + exit 1 +fi + +# Verify trade details +SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') +TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.tradeType // empty') +ENTRY_PRICE=$(echo "$RESULT" | jq -r '.trade.price // empty') + +if [[ "$SIDE" != "sell" ]]; then + fail "Expected side sell, got: $SIDE" + echo "$RESULT" | jq . + exit 1 +fi + +if [[ "$TRADE_TYPE" != "perp" ]]; then + fail "Expected tradeType perp, got: $TRADE_TYPE" + exit 1 +fi + +# Verify PnL offsets +PNL_COUNT=$(echo "$RESULT" | jq '.pnl | length') +if [[ "$PNL_COUNT" -lt 1 ]]; then + fail "No PnL data returned" + echo "$RESULT" | jq . + exit 1 +fi + +done_step +ok "BTC perp sell: 0.01 BTC at \$$ENTRY_PRICE (side=$SIDE, type=$TRADE_TYPE)" +ok "PnL offsets returned: $PNL_COUNT" +echo "$RESULT" | jq '.pnl[] | {offset, price, pnl, pnlPct}' diff --git a/tests/backtest/quote_btc.sh b/tests/backtest/quote_btc.sh new file mode 100755 index 0000000..a0de84d --- /dev/null +++ b/tests/backtest/quote_btc.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Historical BTC quote via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Fetch BTC historical price on 2025-01-15 +# 2. Verify price is returned and reasonable +# +# Usage: ./tests/backtest/quote_btc.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Historical BTC quote on 2025-01-15 (JSON API)" + +# ── Fetch BTC price ──────────────────────────────────────────────────── +info "Fetching BTC price on 2025-01-15..." +RESULT=$(bt "2025-01-15" '{"command":"quote","symbol":"BTC"}') + +if [[ -z "$RESULT" ]]; then + fail "BTC historical quote returned empty" + exit 1 +fi + +PRICE=$(echo "$RESULT" | jq -r '.price // empty') +SYMBOL=$(echo "$RESULT" | jq -r '.symbol // empty') +DATE=$(echo "$RESULT" | jq -r '.date // empty') + +if [[ -z "$PRICE" ]]; then + fail "BTC quote returned but price is missing" + echo "$RESULT" | jq . + exit 1 +fi + +if [[ "$SYMBOL" != "BTC" ]]; then + fail "Expected symbol BTC, got: $SYMBOL" + exit 1 +fi + +if [[ "$DATE" != "2025-01-15" ]]; then + fail "Expected date 2025-01-15, got: $DATE" + exit 1 +fi + +done_step +ok "BTC price on 2025-01-15: \$$PRICE" +echo "$RESULT" | jq . diff --git a/tests/backtest/quote_commodity.sh b/tests/backtest/quote_commodity.sh new file mode 100755 index 0000000..c684117 --- /dev/null +++ b/tests/backtest/quote_commodity.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Historical commodity (GOLD) quote via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Fetch GOLD historical price on 2025-01-15 +# 2. Verify price is returned +# +# Usage: ./tests/backtest/quote_commodity.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Historical GOLD quote on 2025-01-15 (JSON API)" + +# ── Fetch GOLD price ────────────────────────────────────────────────── +info "Fetching GOLD price on 2025-01-15..." +RESULT=$(bt "2025-01-15" '{"command":"quote","symbol":"GOLD"}') + +if [[ -z "$RESULT" ]]; then + fail "GOLD historical quote returned empty" + exit 1 +fi + +PRICE=$(echo "$RESULT" | jq -r '.price // empty') +SYMBOL=$(echo "$RESULT" | jq -r '.symbol // empty') + +if [[ -z "$PRICE" ]]; then + fail "GOLD quote returned but price is missing" + echo "$RESULT" | jq . + exit 1 +fi + +if [[ "$SYMBOL" != "GOLD" ]]; then + fail "Expected symbol GOLD, got: $SYMBOL" + exit 1 +fi + +done_step +ok "GOLD price on 2025-01-15: \$$PRICE" +echo "$RESULT" | jq . diff --git a/tests/backtest/quote_stock.sh b/tests/backtest/quote_stock.sh new file mode 100755 index 0000000..313d5ac --- /dev/null +++ b/tests/backtest/quote_stock.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Historical stock (AAPL) quote via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Fetch AAPL historical price on 2025-01-15 +# 2. Verify price is returned +# +# Usage: ./tests/backtest/quote_stock.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Historical AAPL quote on 2025-01-15 (JSON API)" + +# ── Fetch AAPL price ─────────────────────────────────────────────────── +info "Fetching AAPL price on 2025-01-15..." +RESULT=$(bt "2025-01-15" '{"command":"quote","symbol":"AAPL"}') + +if [[ -z "$RESULT" ]]; then + fail "AAPL historical quote returned empty" + exit 1 +fi + +PRICE=$(echo "$RESULT" | jq -r '.price // empty') +SYMBOL=$(echo "$RESULT" | jq -r '.symbol // empty') + +if [[ -z "$PRICE" ]]; then + fail "AAPL quote returned but price is missing" + echo "$RESULT" | jq . + exit 1 +fi + +if [[ "$SYMBOL" != "AAPL" ]]; then + fail "Expected symbol AAPL, got: $SYMBOL" + exit 1 +fi + +done_step +ok "AAPL price on 2025-01-15: \$$PRICE" +echo "$RESULT" | jq . diff --git a/tests/backtest/report_list.sh b/tests/backtest/report_list.sh new file mode 100755 index 0000000..f87050c --- /dev/null +++ b/tests/backtest/report_list.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# SEC filings with date filter via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. List AAPL SEC filings before 2024-06-01 +# 2. Verify filings are returned and dates are correct +# +# Usage: ./tests/backtest/report_list.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "SEC filings for AAPL before 2024-06-01 (JSON API)" + +# ── Fetch SEC filings ───────────────────────────────────────────────── +info "Listing AAPL filings before 2024-06-01..." +RESULT=$(bt "2024-06-01" '{"command":"report_list","symbol":"AAPL","limit":3}') + +if [[ -z "$RESULT" ]]; then + fail "Report list returned empty" + exit 1 +fi + +COUNT=$(echo "$RESULT" | jq 'length') + +if [[ "$COUNT" -lt 1 ]]; then + fail "No filings returned" + echo "$RESULT" | jq . + exit 1 +fi + +# Verify all filing dates are before or on 2024-06-01 +FUTURE=$(echo "$RESULT" | jq '[.[] | select(.filingDate > "2024-06-01")] | length') +if [[ "$FUTURE" -gt 0 ]]; then + fail "Found $FUTURE filing(s) after 2024-06-01 — date filter broken" + echo "$RESULT" | jq . + exit 1 +fi + +done_step +ok "Found $COUNT filings for AAPL before 2024-06-01" +echo "$RESULT" | jq '.[] | {form: .form, filingDate: .filingDate, reportDate: .reportDate}' diff --git a/tests/backtest/reset.sh b/tests/backtest/reset.sh new file mode 100755 index 0000000..7fcc9a2 --- /dev/null +++ b/tests/backtest/reset.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Test portfolio reset via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Add some trades to build up portfolio state +# 2. Verify state exists +# 3. Reset and verify clean state +# +# Usage: ./tests/backtest/reset.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Portfolio reset test (JSON API)" + +# ── Add some trades ──────────────────────────────────────────────────── +info "Adding trades..." +bt "2025-01-15" '{"command":"reset"}' > /dev/null +bt "2025-01-15" '{"command":"buy","symbol":"BTC","amount":0.01}' > /dev/null +bt "2025-01-15" '{"command":"perp_buy","symbol":"ETH","amount":0.5,"price":3300}' > /dev/null + +# Verify state exists +RESULT=$(bt "2025-01-15" '{"command":"balance"}') +TRADES=$(echo "$RESULT" | jq -r '.totalTrades // "0"') +if [[ "$TRADES" -ge 2 ]]; then + ok "State has $TRADES trades" +else + fail "Expected at least 2 trades, got $TRADES" + exit 1 +fi + +# ── Reset ────────────────────────────────────────────────────────────── +info "Resetting portfolio..." +RESULT=$(bt "2025-01-15" '{"command":"reset"}') +STATUS=$(echo "$RESULT" | jq -r '.status // empty') + +if [[ "$STATUS" != "ok" ]]; then + fail "Reset did not return status:ok" + echo "$RESULT" | jq . + exit 1 +fi +ok "Reset returned status: ok" + +# ── Verify clean state ───────────────────────────────────────────────── +RESULT=$(bt "2025-01-15" '{"command":"balance"}') +TRADES=$(echo "$RESULT" | jq -r '.totalTrades // empty') +CASH=$(echo "$RESULT" | jq -r '.cashBalance // empty') +POS_COUNT=$(echo "$RESULT" | jq '.positions | length') + +if [[ "$TRADES" != "0" ]]; then + fail "Expected 0 trades after reset, got: $TRADES" + exit 1 +fi + +if [[ "$POS_COUNT" != "0" ]]; then + fail "Expected 0 positions after reset, got: $POS_COUNT" + exit 1 +fi + +done_step +ok "Reset verified: $TRADES trades, $POS_COUNT positions, cash \$$CASH" +echo "$RESULT" | jq . diff --git a/tests/backtest/sell_spot.sh b/tests/backtest/sell_spot.sh new file mode 100755 index 0000000..7dac61e --- /dev/null +++ b/tests/backtest/sell_spot.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Simulated ETH spot sell with forward PnL via backtest +# +# Uses backtest --json API. Output is always JSON. +# +# Workflow: +# 1. Simulate selling 0.5 ETH at $3300 on 2025-01-15 +# 2. Verify trade details and PnL offsets are returned +# +# Usage: ./tests/backtest/sell_spot.sh +# +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/../helpers.sh" +ensure_built + +bt() { $BACKTEST --at "$1" --json "$2" 2>/dev/null; } + +log "Simulated ETH spot sell on 2025-01-15 (JSON API)" + +# ── Simulate spot sell ───────────────────────────────────────────────── +info "Selling 0.5 ETH at \$3300..." +RESULT=$(bt "2025-01-15" '{"command":"sell","symbol":"ETH","amount":0.5,"price":3300}') + +if [[ -z "$RESULT" ]]; then + fail "ETH spot sell returned empty" + exit 1 +fi + +# Verify trade details +SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') +SYMBOL=$(echo "$RESULT" | jq -r '.trade.symbol // empty') +TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.tradeType // empty') +ENTRY_PRICE=$(echo "$RESULT" | jq -r '.trade.price // empty') + +if [[ "$SIDE" != "sell" ]]; then + fail "Expected side sell, got: $SIDE" + echo "$RESULT" | jq . + exit 1 +fi + +if [[ "$TRADE_TYPE" != "spot" ]]; then + fail "Expected tradeType spot, got: $TRADE_TYPE" + exit 1 +fi + +# Verify PnL offsets +PNL_COUNT=$(echo "$RESULT" | jq '.pnl | length') +if [[ "$PNL_COUNT" -lt 1 ]]; then + fail "No PnL data returned" + echo "$RESULT" | jq . + exit 1 +fi + +done_step +ok "ETH spot sell: 0.5 ETH at \$$ENTRY_PRICE (side=$SIDE, type=$TRADE_TYPE)" +ok "PnL offsets returned: $PNL_COUNT" +echo "$RESULT" | jq '.pnl[] | {offset, price, pnl, pnlPct}' diff --git a/tests/helpers.sh b/tests/helpers.sh index 376c9d7..a224169 100755 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -9,6 +9,7 @@ BINANCE="${BINANCE:-./target/release/binance}" COINBASE="${COINBASE:-./target/release/coinbase}" POLYMARKET="${POLYMARKET:-./target/release/polymarket}" OKX="${OKX:-./target/release/okx}" +BACKTEST="${BACKTEST:-./target/release/backtest}" # Last command results (set by run_tool) LAST_STDOUT="" @@ -71,7 +72,7 @@ check_fail() { ensure_built() { local need_build=false - for bin in "$FINTOOL" "$HYPERLIQUID" "$BINANCE" "$COINBASE" "$POLYMARKET" "$OKX"; do + for bin in "$FINTOOL" "$HYPERLIQUID" "$BINANCE" "$COINBASE" "$POLYMARKET" "$OKX" "$BACKTEST"; do if [[ ! -x "$bin" ]]; then need_build=true break @@ -81,7 +82,7 @@ ensure_built() { if $need_build; then info "Building all binaries..." cargo build --release 2>&1 - for bin in "$FINTOOL" "$HYPERLIQUID" "$BINANCE" "$COINBASE" "$POLYMARKET" "$OKX"; do + for bin in "$FINTOOL" "$HYPERLIQUID" "$BINANCE" "$COINBASE" "$POLYMARKET" "$OKX" "$BACKTEST"; do if [[ ! -x "$bin" ]]; then fail "Build failed — binary not found at $bin" exit 1 From d4e2b04ac8fd16ac7c622e939366edffa542034d Mon Sep 17 00:00:00 2001 From: Michael Yuan Date: Sun, 15 Mar 2026 02:07:13 -0700 Subject: [PATCH 2/4] fix: resolve clippy warnings and formatting in backtest CLI Remove unused `close` field from JSON PerpBuy/PerpSell variants, fix print_literal warning, and suppress too_many_arguments for cmd_trade. Signed-off-by: Michael Yuan Co-Authored-By: Claude Opus 4.6 --- src/bin/backtest.rs | 94 ++++++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/src/bin/backtest.rs b/src/bin/backtest.rs index 947a098..cc7851e 100644 --- a/src/bin/backtest.rs +++ b/src/bin/backtest.rs @@ -29,14 +29,10 @@ struct Cli { #[derive(Subcommand)] enum Commands { /// Get historical price at the backtest date - Quote { - symbol: String, - }, + Quote { symbol: String }, /// Show news (stub — historical news not available) - News { - symbol: String, - }, + News { symbol: String }, /// SEC filings on or before the backtest date #[command(subcommand)] @@ -180,15 +176,11 @@ enum JsonCommand { symbol: String, amount: f64, price: Option, - #[serde(default)] - close: bool, }, PerpSell { symbol: String, amount: f64, price: Option, - #[serde(default)] - close: bool, }, PerpLeverage { symbol: String, @@ -262,8 +254,8 @@ async fn cmd_report_list( date, ); println!( - " {:<8} {:<12} {:<12} {}", - "Form", "Filed", "Period", "Accession Number" + " {:<8} {:<12} {:<12} Accession Number", + "Form", "Filed", "Period" ); println!(" {}", "-".repeat(66)); for f in &filings { @@ -307,6 +299,7 @@ async fn cmd_report_quarterly( commands::report::get(symbol, &filing.accession_number, output, json_output).await } +#[allow(clippy::too_many_arguments)] async fn cmd_trade( symbol: &str, amount: f64, @@ -346,11 +339,7 @@ async fn cmd_trade( // ── JSON dispatch ─────────────────────────────────────────────────────── -async fn run_json( - json_str: &str, - date: NaiveDate, - portfolio: &mut Portfolio, -) -> Result<()> { +async fn run_json(json_str: &str, date: NaiveDate, portfolio: &mut Portfolio) -> Result<()> { let cmd: JsonCommand = serde_json::from_str(json_str) .map_err(|e| anyhow::anyhow!("Invalid JSON command: {}", e))?; @@ -524,13 +513,8 @@ async fn main() -> Result<()> { let cli = Cli::parse(); // Parse the --at date - let date = NaiveDate::parse_from_str(&cli.at, "%Y-%m-%d").map_err(|e| { - anyhow::anyhow!( - "Invalid date '{}': {}. Use YYYY-MM-DD format.", - cli.at, - e - ) - })?; + let date = NaiveDate::parse_from_str(&cli.at, "%Y-%m-%d") + .map_err(|e| anyhow::anyhow!("Invalid date '{}': {}. Use YYYY-MM-DD format.", cli.at, e))?; // Validate date is not in the future let today = chrono::Utc::now().date_naive(); @@ -539,7 +523,10 @@ async fn main() -> Result<()> { } let mut portfolio = Portfolio::load().unwrap_or_else(|e| { - eprintln!("Warning: could not load portfolio state: {:#}. Starting fresh.", e); + eprintln!( + "Warning: could not load portfolio state: {:#}. Starting fresh.", + e + ); Portfolio::new() }); @@ -589,7 +576,13 @@ async fn main() -> Result<()> { price, } => { cmd_trade( - &symbol, amount, price, TradeSide::Buy, TradeType::Spot, date, &mut portfolio, + &symbol, + amount, + price, + TradeSide::Buy, + TradeType::Spot, + date, + &mut portfolio, json_output, ) .await @@ -600,7 +593,13 @@ async fn main() -> Result<()> { price, } => { cmd_trade( - &symbol, amount, price, TradeSide::Sell, TradeType::Spot, date, &mut portfolio, + &symbol, + amount, + price, + TradeSide::Sell, + TradeType::Spot, + date, + &mut portfolio, json_output, ) .await @@ -613,8 +612,14 @@ async fn main() -> Result<()> { .. } => { cmd_trade( - &symbol, amount, price, TradeSide::Buy, TradeType::Perp, date, - &mut portfolio, json_output, + &symbol, + amount, + price, + TradeSide::Buy, + TradeType::Perp, + date, + &mut portfolio, + json_output, ) .await } @@ -625,8 +630,14 @@ async fn main() -> Result<()> { .. } => { cmd_trade( - &symbol, amount, price, TradeSide::Sell, TradeType::Perp, date, - &mut portfolio, json_output, + &symbol, + amount, + price, + TradeSide::Sell, + TradeType::Perp, + date, + &mut portfolio, + json_output, ) .await } @@ -644,10 +655,7 @@ async fn main() -> Result<()> { Commands::Balance => { let cash = portfolio.cash_balance(); let positions = portfolio.positions(); - println!( - "\n {} Simulated portfolio", - "[BACKTEST]".dimmed() - ); + println!("\n {} Simulated portfolio", "[BACKTEST]".dimmed()); let cash_str = format!("${:.2}", cash); let colored_cash = if cash >= 0.0 { cash_str.green().to_string() @@ -665,15 +673,9 @@ async fn main() -> Result<()> { Commands::Positions => { let positions = portfolio.positions(); if positions.is_empty() { - println!( - "\n {} No open positions.\n", - "[BACKTEST]".dimmed() - ); + println!("\n {} No open positions.\n", "[BACKTEST]".dimmed()); } else { - println!( - "\n {} Open positions:\n", - "[BACKTEST]".dimmed() - ); + println!("\n {} Open positions:\n", "[BACKTEST]".dimmed()); println!( " {:<10} {:<6} {:<8} {:>12} {:>14}", "Symbol", "Type", "Side", "Quantity", "Avg Entry" @@ -686,7 +688,11 @@ async fn main() -> Result<()> { }; println!( " {:<10} {:<6} {:<8} {:>12.4} {:>14.2}", - p.symbol, type_str, p.side, p.net_quantity.abs(), p.avg_entry_price + p.symbol, + type_str, + p.side, + p.net_quantity.abs(), + p.avg_entry_price ); } println!(); From edc386e877748cdeed7a02ea04a39b27cb78c078 Mon Sep 17 00:00:00 2001 From: Michael Yuan Date: Sun, 15 Mar 2026 02:09:46 -0700 Subject: [PATCH 3/4] fix: use correct JSON field name in backtest test scripts The trade JSON output uses `.trade.type` not `.trade.tradeType`. Fixed in sell_spot.sh, perp_buy.sh, and perp_sell.sh. Signed-off-by: Michael Yuan Co-Authored-By: Claude Opus 4.6 --- tests/backtest/perp_buy.sh | 2 +- tests/backtest/perp_sell.sh | 2 +- tests/backtest/sell_spot.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/backtest/perp_buy.sh b/tests/backtest/perp_buy.sh index 040fc26..de4dd7f 100755 --- a/tests/backtest/perp_buy.sh +++ b/tests/backtest/perp_buy.sh @@ -43,7 +43,7 @@ fi # Verify trade details SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') -TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.tradeType // empty') +TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.type // empty') if [[ "$SIDE" != "buy" ]]; then fail "Expected side buy, got: $SIDE" diff --git a/tests/backtest/perp_sell.sh b/tests/backtest/perp_sell.sh index b0fbc28..20c3bc2 100755 --- a/tests/backtest/perp_sell.sh +++ b/tests/backtest/perp_sell.sh @@ -30,7 +30,7 @@ fi # Verify trade details SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') -TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.tradeType // empty') +TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.type // empty') ENTRY_PRICE=$(echo "$RESULT" | jq -r '.trade.price // empty') if [[ "$SIDE" != "sell" ]]; then diff --git a/tests/backtest/sell_spot.sh b/tests/backtest/sell_spot.sh index 7dac61e..2885be1 100755 --- a/tests/backtest/sell_spot.sh +++ b/tests/backtest/sell_spot.sh @@ -31,7 +31,7 @@ fi # Verify trade details SIDE=$(echo "$RESULT" | jq -r '.trade.side // empty') SYMBOL=$(echo "$RESULT" | jq -r '.trade.symbol // empty') -TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.tradeType // empty') +TRADE_TYPE=$(echo "$RESULT" | jq -r '.trade.type // empty') ENTRY_PRICE=$(echo "$RESULT" | jq -r '.trade.price // empty') if [[ "$SIDE" != "sell" ]]; then From 1886e37fd6c90f11afa0f3e7cf08760584a23f67 Mon Sep 17 00:00:00 2001 From: Michael Yuan Date: Sun, 15 Mar 2026 02:12:56 -0700 Subject: [PATCH 4/4] fix: apply cargo fmt to src/backtest.rs Signed-off-by: Michael Yuan Co-Authored-By: Claude Opus 4.6 --- src/backtest.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/backtest.rs b/src/backtest.rs index 0756008..ea34c37 100644 --- a/src/backtest.rs +++ b/src/backtest.rs @@ -158,7 +158,11 @@ impl Portfolio { TradeSide::Sell => t.amount * t.price, }) .sum(); - if balance == 0.0 { 0.0 } else { balance } + if balance == 0.0 { + 0.0 + } else { + balance + } } /// Compute net positions grouped by (symbol, trade_type). @@ -286,16 +290,8 @@ pub async fn fetch_yahoo_bars( .build()?; let tickers = resolve_yahoo_tickers(symbol); - let period1 = from - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp(); - let period2 = to - .and_hms_opt(23, 59, 59) - .unwrap() - .and_utc() - .timestamp(); + let period1 = from.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp(); + let period2 = to.and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp(); for ticker in &tickers { let url = format!( @@ -438,9 +434,7 @@ pub async fn fetch_pnl_prices( let price = match &bars { Ok(bars) => { // Find closest bar on or after target date (handles weekends) - bars.iter() - .find(|b| b.date >= target) - .map(|b| b.close) + bars.iter().find(|b| b.date >= target).map(|b| b.close) } Err(_) => None, };