Skip to content

Commit 178b73e

Browse files
authored
Merge pull request #13 from second-state/feat/backtest-skills-examples
feat: add backtest CLI to skills and convert examples to Python
2 parents dcd7cf6 + 1cadc2a commit 178b73e

12 files changed

Lines changed: 940 additions & 309 deletions

examples/backtest/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Backtest Strategy Examples
2+
3+
Historical trading simulations with forward PnL analysis. Each script simulates a multi-leg trade at a specific historical date and shows what the PnL would have been at +1, +2, +4, and +7 days.
4+
5+
## Available Scenarios
6+
7+
| Script | Date | Strategy | Assets | Outcome |
8+
|--------|------|----------|--------|---------|
9+
| `covid_crash_hedge.py` | 2020-02-21 | Flight-to-safety | Long GOLD + Short SP500 | SP500 fell 12% in 7 days; short leg dominated |
10+
| `ftx_crypto_contagion.py` | 2022-11-08 | Crypto contagion hedge | Short BTC + Short ETH + Long GOLD | BTC -15%, ETH -18%; crypto shorts drove profit |
11+
| `nvda_earnings_alpha.py` | 2023-05-25 | Sector alpha pair | Long NVDA + Short SP500 | NVDA +5% while SP500 flat; pure alpha |
12+
| `ukraine_oil_shock.py` | 2022-02-24 | Commodity supply shock | Long OIL + Long GOLD + Short SP500 | Oil surged 16% in 7 days on sanctions |
13+
14+
## Setup
15+
16+
No API keys or wallet configuration needed. Just build the backtest binary:
17+
18+
```bash
19+
cargo build --release
20+
```
21+
22+
## Usage
23+
24+
```bash
25+
# Run any scenario
26+
python3 examples/backtest/covid_crash_hedge.py
27+
python3 examples/backtest/ftx_crypto_contagion.py
28+
python3 examples/backtest/nvda_earnings_alpha.py
29+
python3 examples/backtest/ukraine_oil_shock.py
30+
31+
# Override binary path
32+
python3 examples/backtest/covid_crash_hedge.py --backtest /path/to/backtest
33+
```
34+
35+
Each script:
36+
1. Resets the portfolio to a clean state
37+
2. Fetches historical prices at the scenario date
38+
3. Executes simulated trades (spot buy/sell)
39+
4. Displays a PnL table at +1, +2, +4, +7 days for each leg
40+
5. Shows the final portfolio summary (cash balance, positions)
41+
6. Cleans up (resets portfolio)
42+
43+
## How It Works
44+
45+
The `backtest` CLI fetches historical OHLCV data from Yahoo Finance (with CoinGecko fallback for crypto) and computes forward PnL by looking up actual prices at future dates.
46+
47+
- **Auto-pricing**: If `--price` is omitted, the historical close price is used
48+
- **Portfolio state**: Trades are tracked in `~/.fintool/backtest_portfolio.json`
49+
- **Cash balance**: Buying subtracts cost, selling adds proceeds (spot trades only)
50+
- **Perp trades**: Use `perp_buy`/`perp_sell` with leverage for leveraged PnL
51+
52+
## Dependencies
53+
54+
- **Python 3.10+** (no third-party packages — uses only stdlib)
55+
- **backtest** CLI binary (compiled from this repo)
56+
57+
## Writing Your Own
58+
59+
Create a new Python script following this pattern:
60+
61+
```python
62+
#!/usr/bin/env python3
63+
import json, os, subprocess
64+
from pathlib import Path
65+
66+
REPO_DIR = Path(__file__).resolve().parent.parent.parent
67+
BT = os.environ.get("BACKTEST", str(REPO_DIR / "target" / "release" / "backtest"))
68+
DATE = "2024-01-15"
69+
70+
def cli(cmd, date=DATE):
71+
r = subprocess.run([BT, "--at", date, "--json", json.dumps(cmd)],
72+
capture_output=True, text=True, timeout=30)
73+
return json.loads(r.stdout)
74+
75+
# Reset
76+
cli({"command": "reset"})
77+
78+
# Quote
79+
btc = cli({"command": "quote", "symbol": "BTC"})
80+
print(f"BTC on {DATE}: ${btc['price']}")
81+
82+
# Trade
83+
result = cli({"command": "buy", "symbol": "BTC", "amount": 0.01})
84+
for p in result["pnl"]:
85+
print(f" {p['offset']}: ${p['price']} (PnL: {p['pnl']}, {p['pnlPct']}%)")
86+
87+
# Cleanup
88+
cli({"command": "reset"})
89+
```
90+
91+
Supported symbols include all major crypto (BTC, ETH, SOL, ...), stocks (AAPL, NVDA, TSLA, ...), commodities (GOLD, SILVER, OIL), and indices (SP500, NASDAQ, DOW).
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env python3
2+
"""
3+
COVID-19 Crash — Flight-to-Safety Hedge
4+
========================================
5+
6+
Date: February 21, 2020 (Friday before the crash accelerated)
7+
8+
Thesis: By late February 2020, COVID-19 had spread to Italy and South
9+
Korea. The S&P 500 hit its all-time high on Feb 19. A pandemic-driven
10+
selloff was imminent. The classic hedge: go long gold (safe haven) and
11+
short equities. This is a dollar-neutral pair: ~$5,000 each side.
12+
13+
Legs:
14+
1. Long GOLD — 3 oz at ~$1,645/oz ($4,934 notional)
15+
2. Short SP500 — 1.5 units at ~$3,337 ($5,006 notional)
16+
17+
What happened: The S&P 500 fell ~12% over the next 7 trading days
18+
(the fastest correction from ATH in history). Gold initially held
19+
steady, then pulled back as margin calls hit.
20+
21+
Result: The short equity leg dominates — this hedge captured the
22+
crash while the gold leg acts as a stabilizer.
23+
24+
Usage: python3 examples/backtest/covid_crash_hedge.py
25+
"""
26+
27+
import argparse
28+
import json
29+
import os
30+
import subprocess
31+
import sys
32+
from pathlib import Path
33+
34+
SCRIPT_DIR = Path(__file__).resolve().parent
35+
REPO_DIR = SCRIPT_DIR.parent.parent
36+
37+
DEFAULTS = {
38+
"backtest": os.environ.get("BACKTEST", str(REPO_DIR / "target" / "release" / "backtest")),
39+
}
40+
41+
DATE = "2020-02-21"
42+
43+
44+
def cli(cmd: dict, binary: str, date: str) -> dict:
45+
"""Call the backtest CLI in JSON mode. Returns parsed JSON output."""
46+
try:
47+
result = subprocess.run(
48+
[binary, "--at", date, "--json", json.dumps(cmd)],
49+
capture_output=True, text=True, timeout=30,
50+
)
51+
if result.returncode != 0:
52+
return {"error": result.stderr.strip() or f"exit code {result.returncode}"}
53+
return json.loads(result.stdout)
54+
except (json.JSONDecodeError, subprocess.TimeoutExpired) as e:
55+
return {"error": str(e)}
56+
57+
58+
def run(cfg: dict):
59+
bt = cfg["backtest"]
60+
61+
print()
62+
print("=" * 62)
63+
print(" COVID-19 Crash Hedge — February 21, 2020")
64+
print(" Long GOLD + Short S&P 500 (dollar-neutral pair)")
65+
print("=" * 62)
66+
print()
67+
68+
# Reset portfolio
69+
cli({"command": "reset"}, bt, DATE)
70+
71+
# Scout prices
72+
print("-- Scouting prices on", DATE, "--")
73+
print()
74+
75+
gold = cli({"command": "quote", "symbol": "GOLD"}, bt, DATE)
76+
sp = cli({"command": "quote", "symbol": "SP500"}, bt, DATE)
77+
gold_price = float(gold.get("price", 0))
78+
sp_price = float(sp.get("price", 0))
79+
80+
print(f" GOLD: ${gold_price:.2f} / oz")
81+
print(f" SP500: ${sp_price:.2f}")
82+
print()
83+
84+
# Leg 1: Long GOLD
85+
print("-- Leg 1: Long GOLD (flight to safety) --")
86+
result = cli({"command": "buy", "symbol": "GOLD", "amount": 3, "price": gold_price}, bt, DATE)
87+
print_trade(result)
88+
89+
# Leg 2: Short SP500
90+
print("-- Leg 2: Short S&P 500 (equity crash) --")
91+
result = cli({"command": "sell", "symbol": "SP500", "amount": 1.5, "price": sp_price}, bt, DATE)
92+
print_trade(result)
93+
94+
# Portfolio summary
95+
print("=" * 62)
96+
print(" Portfolio Summary")
97+
print("=" * 62)
98+
balance = cli({"command": "balance"}, bt, DATE)
99+
positions = cli({"command": "positions"}, bt, DATE)
100+
print_portfolio(balance, positions)
101+
102+
# Cleanup
103+
cli({"command": "reset"}, bt, DATE)
104+
105+
106+
def print_trade(result: dict):
107+
if "error" in result:
108+
print(f" ERROR: {result['error']}")
109+
return
110+
trade = result.get("trade", {})
111+
pnl = result.get("pnl", [])
112+
symbol = trade.get("symbol", "?")
113+
side = trade.get("side", "?")
114+
amount = trade.get("amount", 0)
115+
price = trade.get("price", 0)
116+
total = amount * price
117+
print(f" {side.upper()} {amount} {symbol} @ ${price:,.2f} (${total:,.2f} notional)")
118+
if pnl:
119+
print()
120+
print(f" {'':>10} {' +1 day':>14} {' +2 days':>14} {' +4 days':>14} {' +7 days':>14}")
121+
print(f" {'':>10} {'':->14} {'':->14} {'':->14} {'':->14}")
122+
prices = "".join(f" ${float(p.get('price', 0)):>10,.2f}" for p in pnl)
123+
pnl_dollars = "".join(
124+
f" {'+' if float(p.get('pnl', 0)) >= 0 else ''}{float(p.get('pnl', 0)):>10,.2f}"
125+
for p in pnl
126+
)
127+
pnl_pcts = "".join(
128+
f" {'+' if float(p.get('pnlPct', 0)) >= 0 else ''}{float(p.get('pnlPct', 0)):>9,.2f}%"
129+
for p in pnl
130+
)
131+
print(f" {'Price':>10}{prices}")
132+
print(f" {'PnL $':>10}{pnl_dollars}")
133+
print(f" {'PnL %':>10}{pnl_pcts}")
134+
print()
135+
136+
portfolio = result.get("portfolio", {})
137+
cash = float(portfolio.get("cashBalance", 0))
138+
print(f" [PORTFOLIO] Cash balance: ${cash:,.2f}")
139+
for pos in portfolio.get("positions", []):
140+
print(
141+
f" [PORTFOLIO] {pos['type']} {pos['side']} {pos['symbol']}: "
142+
f"{abs(pos['quantity']):.4f} @ avg ${pos['avgEntryPrice']}"
143+
)
144+
print()
145+
146+
147+
def print_portfolio(balance: dict, positions: dict):
148+
cash = float(balance.get("cashBalance", 0))
149+
total_trades = balance.get("totalTrades", 0)
150+
pos_list = positions if isinstance(positions, list) else positions.get("positions", [])
151+
print(f" Cash balance: ${cash:,.2f}")
152+
print(f" Total trades: {total_trades}")
153+
print(f" Open positions: {len(pos_list)}")
154+
if pos_list:
155+
print()
156+
print(f" {'Symbol':<10} {'Type':<6} {'Side':<8} {'Quantity':>12} {'Avg Entry':>14}")
157+
print(f" {'-' * 54}")
158+
for p in pos_list:
159+
print(
160+
f" {p['symbol']:<10} {p['type']:<6} {p['side']:<8} "
161+
f"{abs(p['quantity']):>12.4f} {float(p['avgEntryPrice']):>14.2f}"
162+
)
163+
print()
164+
165+
166+
def main():
167+
parser = argparse.ArgumentParser(description="COVID-19 crash hedge backtest")
168+
parser.add_argument("--backtest", default=DEFAULTS["backtest"], help="Path to backtest binary")
169+
args = parser.parse_args()
170+
run({"backtest": args.backtest})
171+
172+
173+
if __name__ == "__main__":
174+
main()

examples/backtest/covid_crash_hedge.sh

Lines changed: 0 additions & 67 deletions
This file was deleted.

0 commit comments

Comments
 (0)