stratiq-backtest 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ """stratiq_backtest — a tiny, honest backtesting engine.
2
+
3
+ Long-only signal backtests with full trade logs and a buy-and-hold
4
+ comparison. No hidden assumptions: the methodology is documented in
5
+ the README and every number the engine prints can be traced to a trade.
6
+ """
7
+
8
+ from .backtest import BarData, BacktestResult, Strategy, Trade, run
9
+ from .data import fetch_stooq, fetch_yahoo, load_csv, load_json
10
+ from .indicators import atr_series, ema_series, rsi_series, sma
11
+ from .stats import max_drawdown, summarize
12
+ from .strategy import CrossoverStrategy
13
+ from .synthetic import synthetic_bars
14
+
15
+ __all__ = [
16
+ "BarData",
17
+ "BacktestResult",
18
+ "CrossoverStrategy",
19
+ "Strategy",
20
+ "Trade",
21
+ "atr_series",
22
+ "ema_series",
23
+ "fetch_stooq",
24
+ "fetch_yahoo",
25
+ "load_csv",
26
+ "load_json",
27
+ "max_drawdown",
28
+ "rsi_series",
29
+ "run",
30
+ "sma",
31
+ "summarize",
32
+ "synthetic_bars",
33
+ ]
34
+
35
+ __version__ = "0.3.0"
@@ -0,0 +1,130 @@
1
+ """Core backtest loop.
2
+
3
+ Methodology (documented so every number is auditable):
4
+ - Long-only, fully invested-or-cash. No leverage, no shorting.
5
+ - Trades execute AT the close of the bar that generates the signal:
6
+ the strategy sees bar i's close, the trade prints at bar i's close.
7
+ - NO look-ahead: a position entered on bar i does NOT earn bar i's
8
+ return (that move already happened before the trade printed). It
9
+ starts earning on bar i+1. A position exited on bar i DOES earn
10
+ bar i's return (it was exposed during the bar, sold at its close).
11
+ NOTE: v0.1.0 credited the signal bar's own return to the new
12
+ position — that was a look-ahead error, fixed in v0.2.0.
13
+ - No commissions, no slippage, no taxes are modeled. Real trading
14
+ will do worse than these numbers by roughly those costs.
15
+ - Use dividend/split-adjusted closes or your returns will be wrong.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+
21
+
22
+ @dataclass
23
+ class BarData:
24
+ dates: list # ISO date strings, one per bar
25
+ closes: list # floats, preferably dividend/split-adjusted
26
+
27
+ def __len__(self):
28
+ if len(self.dates) != len(self.closes):
29
+ raise ValueError("dates and closes must be the same length")
30
+ return len(self.dates)
31
+
32
+
33
+ @dataclass
34
+ class Trade:
35
+ entry_date: str
36
+ entry: float
37
+ exit_date: str
38
+ exit: float
39
+ return_pct: float
40
+ open_at_end: bool = False
41
+
42
+
43
+ @dataclass
44
+ class BacktestResult:
45
+ trades: list = field(default_factory=list)
46
+ equity: list = field(default_factory=list) # starts at 1.0, one entry per bar + 1
47
+ in_market_bars: int = 0
48
+ warmup: int = 0
49
+
50
+
51
+ class Strategy:
52
+ """Decide the position after seeing bar i's close.
53
+
54
+ on_bar(i, data, position) -> new position (0 = cash, 1 = long).
55
+ The returned position takes effect after bar i's close (see the
56
+ no-look-ahead rule above). Strategies may keep state between bars;
57
+ reset() is called before each run.
58
+ """
59
+
60
+ def reset(self):
61
+ pass
62
+
63
+ def on_bar(self, i: int, data: BarData, position: int) -> int:
64
+ raise NotImplementedError
65
+
66
+
67
+ def run(data: BarData, strategy: Strategy, warmup: int) -> BacktestResult:
68
+ """Run a backtest. Signals are evaluated from bar `warmup` onward."""
69
+ n = len(data)
70
+ if warmup < 1 or warmup >= n:
71
+ raise ValueError("warmup must be >= 1 and < len(data)")
72
+ strategy.reset()
73
+ closes, dates = data.closes, data.dates
74
+ position = 0
75
+ entry_px = entry_date = None
76
+ trades: list[Trade] = []
77
+ equity = [1.0]
78
+ in_market = 0
79
+
80
+ for i in range(n):
81
+ # 1) The position held DURING bar i earns bar i's return.
82
+ if position == 1:
83
+ in_market += 1
84
+ equity.append(equity[-1] * (closes[i] / closes[i - 1] if i > 0 else 1.0))
85
+ else:
86
+ equity.append(equity[-1])
87
+ # 2) Then the strategy reacts to bar i's close. A change takes
88
+ # effect after the close: an entry printed at closes[i] starts
89
+ # earning on bar i+1 (no look-ahead); an exit printed at
90
+ # closes[i] keeps the return already earned during bar i.
91
+ if i >= warmup:
92
+ want = strategy.on_bar(i, data, position)
93
+ if want not in (0, 1):
94
+ raise ValueError("strategy must return 0 (cash) or 1 (long)")
95
+ if want != position:
96
+ if want == 1:
97
+ position = 1
98
+ entry_px = closes[i]
99
+ entry_date = dates[i]
100
+ else:
101
+ ret = closes[i] / entry_px - 1
102
+ trades.append(
103
+ Trade(
104
+ entry_date=entry_date,
105
+ entry=round(entry_px, 2),
106
+ exit_date=dates[i],
107
+ exit=round(closes[i], 2),
108
+ return_pct=round(ret * 100, 2),
109
+ )
110
+ )
111
+ position = 0
112
+ entry_px = entry_date = None
113
+
114
+ # Close any open position at the final close so equity is comparable.
115
+ if position == 1:
116
+ ret = closes[-1] / entry_px - 1
117
+ trades.append(
118
+ Trade(
119
+ entry_date=entry_date,
120
+ entry=round(entry_px, 2),
121
+ exit_date=dates[-1],
122
+ exit=round(closes[-1], 2),
123
+ return_pct=round(ret * 100, 2),
124
+ open_at_end=True,
125
+ )
126
+ )
127
+
128
+ return BacktestResult(
129
+ trades=trades, equity=equity, in_market_bars=in_market, warmup=warmup
130
+ )
@@ -0,0 +1,111 @@
1
+ """Data loading. Prefer dividend/split-adjusted closes — unadjusted
2
+ data makes every backtest a lie about the past (dividends vanish)."""
3
+ import csv
4
+ import datetime
5
+ import json
6
+ import urllib.request
7
+
8
+ from .backtest import BarData
9
+
10
+
11
+ def load_json(path) -> BarData:
12
+ """Load {"dates": [...], "closes": [...]} or the compact
13
+ {"ts": [unix timestamps], "adj": [adjusted closes]} format."""
14
+ with open(path) as f:
15
+ d = json.load(f)
16
+ if "ts" in d and "adj" in d:
17
+ dates = [
18
+ datetime.datetime.fromtimestamp(t, datetime.UTC).date().isoformat()
19
+ for t in d["ts"]
20
+ ]
21
+ closes = [float(x) for x in d["adj"]]
22
+ elif "dates" in d and "closes" in d:
23
+ dates = list(d["dates"])
24
+ closes = [float(x) for x in d["closes"]]
25
+ else:
26
+ raise ValueError("JSON must have {dates, closes} or {ts, adj}")
27
+ return BarData(dates=dates, closes=closes)
28
+
29
+
30
+ def load_csv(path, date_col="date", close_col="close") -> BarData:
31
+ """Load a CSV with a date column and a close column."""
32
+ dates, closes = [], []
33
+ with open(path, newline="") as f:
34
+ for row in csv.DictReader(f):
35
+ if not row.get(date_col) or not row.get(close_col):
36
+ continue
37
+ try:
38
+ px = float(row[close_col])
39
+ except ValueError:
40
+ continue # e.g. Stooq "N/D" rows
41
+ dates.append(row[date_col][:10])
42
+ closes.append(px)
43
+ if not dates:
44
+ raise ValueError(f"no usable rows in {path}")
45
+ return BarData(dates=dates, closes=closes)
46
+
47
+
48
+ def fetch_yahoo(symbol: str, start: str = "2019-01-01", end: str | None = None) -> BarData:
49
+ """Free daily bars from Yahoo Finance's chart API (no API key), using
50
+ the ADJUSTED close series — dividend- and split-adjusted, the only
51
+ honest basis for a long-window backtest. Unofficial endpoint: it can
52
+ rate-limit or change without notice, so callers must fail loudly."""
53
+ if end is None:
54
+ end = datetime.date.today().isoformat()
55
+ p1 = int(datetime.datetime.fromisoformat(start).replace(tzinfo=datetime.UTC).timestamp())
56
+ p2 = int(datetime.datetime.fromisoformat(end).replace(tzinfo=datetime.UTC).timestamp()) + 86400
57
+ url = (
58
+ "https://query1.finance.yahoo.com/v8/finance/chart/"
59
+ f"{symbol.upper()}?period1={p1}&period2={p2}&interval=1d&events=div,split"
60
+ )
61
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 stratiq-backtest/0.3"})
62
+ with urllib.request.urlopen(req, timeout=30) as r:
63
+ payload = json.loads(r.read().decode("utf-8"))
64
+ result = (payload.get("chart") or {}).get("result") or []
65
+ if not result:
66
+ raise ValueError(f"no data returned for {symbol}")
67
+ result = result[0]
68
+ stamps = result.get("timestamp") or []
69
+ adj = ((result.get("indicators") or {}).get("adjclose") or [{}])[0].get("adjclose")
70
+ if adj is None: # fall back to raw closes rather than nothing
71
+ adj = ((result.get("indicators") or {}).get("quote") or [{}])[0].get("close")
72
+ if not stamps or not adj:
73
+ raise ValueError(f"no price series returned for {symbol}")
74
+ dates, closes = [], []
75
+ for t, px in zip(stamps, adj):
76
+ if px is None:
77
+ continue
78
+ dates.append(datetime.datetime.fromtimestamp(t, datetime.UTC).date().isoformat())
79
+ closes.append(float(px))
80
+ if not dates:
81
+ raise ValueError(f"no usable bars returned for {symbol}")
82
+ return BarData(dates=dates, closes=closes)
83
+
84
+
85
+ def fetch_stooq(symbol: str, start: str = "2019-01-01", end: str | None = None) -> BarData:
86
+ """Free daily bars from Stooq (no API key). NOTE: Stooq's free CSV is
87
+ split-adjusted but NOT dividend-adjusted — fine for experimenting,
88
+ not for publishing numbers. NOTE 2: as of 2026-11 Stooq serves a
89
+ JavaScript browser-verification challenge to plain HTTP clients, so
90
+ this fetch currently fails against the live site; fetch_yahoo is the
91
+ working free path."""
92
+ if end is None:
93
+ end = datetime.date.today().isoformat()
94
+ url = (
95
+ "https://stooq.com/q/d/l/?s="
96
+ f"{symbol.lower()}.us&d1={start.replace('-', '')}"
97
+ f"&d2={end.replace('-', '')}&i=d"
98
+ )
99
+ req = urllib.request.Request(url, headers={"User-Agent": "stratiq-backtest/0.1"})
100
+ with urllib.request.urlopen(req, timeout=30) as r:
101
+ text = r.read().decode("utf-8")
102
+ dates, closes = [], []
103
+ for line in text.strip().splitlines()[1:]: # skip header
104
+ parts = line.split(",")
105
+ if len(parts) < 5 or parts[4] in ("", "N/D"):
106
+ continue
107
+ dates.append(parts[0])
108
+ closes.append(float(parts[4]))
109
+ if not dates:
110
+ raise ValueError(f"no data returned for {symbol}")
111
+ return BarData(dates=dates, closes=closes)
@@ -0,0 +1,82 @@
1
+ """Classic technical indicators. All functions use only data available
2
+ at or before the bar they are evaluated on — no lookahead."""
3
+
4
+
5
+ def sma(values, n, i):
6
+ """Simple moving average of the n values ending at index i (inclusive)."""
7
+ if n < 1:
8
+ raise ValueError("n must be >= 1")
9
+ if i - n + 1 < 0:
10
+ raise ValueError(f"not enough data: need {n} values ending at {i}")
11
+ return sum(values[i - n + 1 : i + 1]) / n
12
+
13
+
14
+ def ema_series(values, n):
15
+ """Exponential moving average over the whole series (seeded with SMA)."""
16
+ if n < 1:
17
+ raise ValueError("n must be >= 1")
18
+ k = 2 / (n + 1)
19
+ out = []
20
+ ema = None
21
+ for i, v in enumerate(values):
22
+ if i < n - 1:
23
+ out.append(None)
24
+ elif i == n - 1:
25
+ ema = sum(values[:n]) / n
26
+ out.append(ema)
27
+ else:
28
+ ema = v * k + ema * (1 - k)
29
+ out.append(ema)
30
+ return out
31
+
32
+
33
+ def rsi_series(values, n=14):
34
+ """Relative Strength Index (Wilder's smoothing). None for the first n bars."""
35
+ if n < 1:
36
+ raise ValueError("n must be >= 1")
37
+ out = [None] * len(values)
38
+ if len(values) <= n:
39
+ return out
40
+ gains, losses = [], []
41
+ for i in range(1, n + 1):
42
+ chg = values[i] - values[i - 1]
43
+ gains.append(max(chg, 0))
44
+ losses.append(max(-chg, 0))
45
+ avg_gain = sum(gains) / n
46
+ avg_loss = sum(losses) / n
47
+ for i in range(n, len(values)):
48
+ if i > n:
49
+ chg = values[i] - values[i - 1]
50
+ avg_gain = (avg_gain * (n - 1) + max(chg, 0)) / n
51
+ avg_loss = (avg_loss * (n - 1) + max(-chg, 0)) / n
52
+ if avg_loss == 0:
53
+ out[i] = 100.0
54
+ else:
55
+ rs = avg_gain / avg_loss
56
+ out[i] = 100 - 100 / (1 + rs)
57
+ return out
58
+
59
+
60
+ def atr_series(highs, lows, closes, n=14):
61
+ """Average True Range (Wilder's smoothing). None for the first n bars."""
62
+ if n < 1:
63
+ raise ValueError("n must be >= 1")
64
+ if not (len(highs) == len(lows) == len(closes)):
65
+ raise ValueError("highs, lows, closes must be the same length")
66
+ out = [None] * len(closes)
67
+ if len(closes) <= n:
68
+ return out
69
+ trs = []
70
+ for i in range(1, len(closes)):
71
+ tr = max(
72
+ highs[i] - lows[i],
73
+ abs(highs[i] - closes[i - 1]),
74
+ abs(lows[i] - closes[i - 1]),
75
+ )
76
+ trs.append(tr)
77
+ atr = sum(trs[:n]) / n
78
+ out[n] = atr
79
+ for i in range(n + 1, len(closes)):
80
+ atr = (atr * (n - 1) + trs[i - 1]) / n
81
+ out[i] = atr
82
+ return out
@@ -0,0 +1,205 @@
1
+ """StratIQ backtest engine as an MCP (Model Context Protocol) server.
2
+
3
+ Stdio transport, newline-delimited JSON-RPC 2.0, standard library only —
4
+ the same zero-dependency discipline as the rest of the package. Any MCP
5
+ client (Claude, Claude Code, Cursor, and other AI assistants) can then
6
+ run an honest backtest *inside the assistant session*: every result is
7
+ shown next to buy-and-hold, with the full trade log and the data caveats
8
+ printed in the result itself. A backtest that hides its benchmark or its
9
+ data limits is a sales pitch, not a receipt.
10
+
11
+ Tools:
12
+ backtest_crossover — moving-average crossover on a real ticker
13
+ (daily adjusted bars via Yahoo Finance; see the
14
+ honesty footer the tool returns with every result).
15
+ stratiq_track_record — StratIQ's live published track record, fetched
16
+ from stratiqai.app at call time; fails closed to
17
+ a bare link when the fetch does not succeed, so
18
+ the server never quotes a stale record.
19
+
20
+ Run: python -m stratiq_backtest.mcp_server
21
+ """
22
+ import json
23
+ import sys
24
+ import urllib.request
25
+
26
+ from .backtest import run
27
+ from .data import fetch_yahoo
28
+ from .stats import summarize
29
+ from .strategy import CrossoverStrategy
30
+
31
+ SERVER_NAME = "stratiq-backtest"
32
+ SERVER_VERSION = "0.3.0"
33
+ # The MCP protocol revision this server speaks. Clients that offer a
34
+ # different revision still get a working session: we answer with ours.
35
+ PROTOCOL_VERSION = "2024-11-05"
36
+
37
+ TRACK_RECORD_URL = "https://stratiqai.app/track-record"
38
+ TRACK_RECORD_API = "https://stratiqai.app/api/track-record"
39
+ TEST_MY_RULE_URL = "https://stratiqai.app/test-my-rule?src=mcp"
40
+
41
+ DATA_HONESTY = (
42
+ "Data: daily ADJUSTED closes from Yahoo Finance's free chart API "
43
+ "(dividend- and split-adjusted; unofficial endpoint, may rate-limit). "
44
+ "No commissions, slippage, or taxes are modeled — real trading does "
45
+ "worse by roughly those costs. Past performance does not predict "
46
+ "future returns; this is a historical test, not financial advice."
47
+ )
48
+
49
+ ATTRIBUTION = (
50
+ "Engine: stratiq-backtest by StratIQ (https://stratiqai.app). "
51
+ f"Test your own rule free: {TEST_MY_RULE_URL} · "
52
+ f"StratIQ's live, graded track record: {TRACK_RECORD_URL}"
53
+ )
54
+
55
+
56
+ def tool_backtest_crossover(args: dict) -> dict:
57
+ ticker = str(args.get("ticker", "")).strip().upper()
58
+ if not ticker or not ticker.replace(".", "").replace("-", "").isalnum():
59
+ raise ValueError("ticker must be a plain symbol like AAPL or SPY")
60
+ fast = int(args.get("fast", 50))
61
+ slow = int(args.get("slow", 200))
62
+ start = str(args.get("start", "2019-01-01"))
63
+ strat = CrossoverStrategy(fast=fast, slow=slow) # validates fast < slow
64
+ data = fetch_yahoo(ticker, start=start)
65
+ if len(data.closes) <= strat.warmup + 2:
66
+ raise ValueError(
67
+ f"not enough daily bars for {ticker} since {start} "
68
+ f"to run a {fast}/{slow} crossover"
69
+ )
70
+ result = run(data, strat, warmup=strat.warmup)
71
+ summary = summarize(data, result)
72
+ return {
73
+ "ticker": ticker,
74
+ "rule": f"{fast}/{slow} moving-average crossover (long when fast > slow, else cash)",
75
+ **summary,
76
+ "honesty": DATA_HONESTY,
77
+ "attribution": ATTRIBUTION,
78
+ }
79
+
80
+
81
+ def tool_track_record(_args: dict) -> dict:
82
+ """Live record or a bare link — never a remembered number."""
83
+ try:
84
+ req = urllib.request.Request(
85
+ TRACK_RECORD_API, headers={"User-Agent": f"{SERVER_NAME}/{SERVER_VERSION}"}
86
+ )
87
+ with urllib.request.urlopen(req, timeout=15) as r:
88
+ payload = json.loads(r.read().decode("utf-8"))
89
+ return {
90
+ "source": TRACK_RECORD_API,
91
+ "track_record": payload,
92
+ "page": TRACK_RECORD_URL,
93
+ "attribution": ATTRIBUTION,
94
+ }
95
+ except Exception:
96
+ return {
97
+ "source": "unavailable — failing closed to the live page",
98
+ "page": TRACK_RECORD_URL,
99
+ "note": (
100
+ "The live track-record feed could not be fetched, so no "
101
+ "record numbers are quoted here. Read the current record "
102
+ f"on the page itself: {TRACK_RECORD_URL}"
103
+ ),
104
+ "attribution": ATTRIBUTION,
105
+ }
106
+
107
+
108
+ TOOLS = {
109
+ "backtest_crossover": {
110
+ "fn": tool_backtest_crossover,
111
+ "description": (
112
+ "Backtest a moving-average crossover rule on a real ticker and "
113
+ "get the honest receipt: strategy return vs buy-and-hold over "
114
+ "the same window, CAGR, max drawdown, win rate, and the full "
115
+ "trade log. Long-only; long when the fast average is above the "
116
+ "slow one, in cash otherwise."
117
+ ),
118
+ "inputSchema": {
119
+ "type": "object",
120
+ "properties": {
121
+ "ticker": {"type": "string", "description": "US ticker symbol, e.g. AAPL, SPY, QQQ"},
122
+ "fast": {"type": "integer", "default": 50, "description": "Fast moving-average length in days"},
123
+ "slow": {"type": "integer", "default": 200, "description": "Slow moving-average length in days"},
124
+ "start": {"type": "string", "default": "2019-01-01", "description": "First date to load, YYYY-MM-DD"},
125
+ },
126
+ "required": ["ticker"],
127
+ },
128
+ },
129
+ "stratiq_track_record": {
130
+ "fn": tool_track_record,
131
+ "description": (
132
+ "Fetch StratIQ's live published track record (daily stock "
133
+ "picks, graded at the Friday close of their publication week, "
134
+ "wins and losses) from stratiqai.app at call time."
135
+ ),
136
+ "inputSchema": {"type": "object", "properties": {}},
137
+ },
138
+ }
139
+
140
+
141
+ def handle(msg: dict) -> dict | None:
142
+ """One JSON-RPC message in, one response out (None for notifications)."""
143
+ method = msg.get("method", "")
144
+ mid = msg.get("id")
145
+ if method == "initialize":
146
+ return _ok(mid, {
147
+ "protocolVersion": PROTOCOL_VERSION,
148
+ "capabilities": {"tools": {}},
149
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
150
+ })
151
+ if method.startswith("notifications/"):
152
+ return None
153
+ if method == "ping":
154
+ return _ok(mid, {})
155
+ if method == "tools/list":
156
+ return _ok(mid, {"tools": [
157
+ {"name": name, "description": t["description"], "inputSchema": t["inputSchema"]}
158
+ for name, t in TOOLS.items()
159
+ ]})
160
+ if method == "tools/call":
161
+ params = msg.get("params") or {}
162
+ name = params.get("name", "")
163
+ tool = TOOLS.get(name)
164
+ if tool is None:
165
+ return _err(mid, -32602, f"unknown tool: {name}")
166
+ try:
167
+ result = tool["fn"](params.get("arguments") or {})
168
+ return _ok(mid, {"content": [
169
+ {"type": "text", "text": json.dumps(result, indent=2)}
170
+ ]})
171
+ except Exception as e: # tool errors are results, not protocol errors
172
+ return _ok(mid, {
173
+ "content": [{"type": "text", "text": f"Error: {e}"}],
174
+ "isError": True,
175
+ })
176
+ return _err(mid, -32601, f"method not found: {method}")
177
+
178
+
179
+ def _ok(mid, result):
180
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
181
+
182
+
183
+ def _err(mid, code, message):
184
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
185
+
186
+
187
+ def main(instream=None, outstream=None):
188
+ instream = instream or sys.stdin
189
+ outstream = outstream or sys.stdout
190
+ for line in instream:
191
+ line = line.strip()
192
+ if not line:
193
+ continue
194
+ try:
195
+ msg = json.loads(line)
196
+ except json.JSONDecodeError:
197
+ continue # a garbled line must never kill the session
198
+ resp = handle(msg)
199
+ if resp is not None:
200
+ outstream.write(json.dumps(resp) + "\n")
201
+ outstream.flush()
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()
@@ -0,0 +1,65 @@
1
+ """Performance statistics, always shown next to buy-and-hold.
2
+
3
+ A strategy that beats nothing is a hobby. Every summary includes the
4
+ do-nothing alternative over the same period so the numbers mean something.
5
+ """
6
+ from dataclasses import asdict
7
+
8
+ TRADING_DAYS = 252
9
+
10
+
11
+ def max_drawdown(equity) -> float:
12
+ """Largest peak-to-trough decline, as a fraction (negative or zero)."""
13
+ peak = equity[0]
14
+ mdd = 0.0
15
+ for v in equity:
16
+ peak = max(peak, v)
17
+ mdd = min(mdd, v / peak - 1)
18
+ return mdd
19
+
20
+
21
+ def summarize(data, result) -> dict:
22
+ """Full honest summary: strategy stats + buy-and-hold over the same window."""
23
+ n = len(data)
24
+ warmup = result.warmup
25
+ closes = data.closes
26
+ # Bars [warmup, n-1] are the strategy's active window.
27
+ span_bars = n - warmup
28
+ if span_bars < 2:
29
+ raise ValueError("not enough bars after warmup")
30
+
31
+ strat_ret = result.equity[-1] - 1
32
+ years = span_bars / TRADING_DAYS
33
+ cagr = result.equity[-1] ** (1 / years) - 1
34
+
35
+ bh_ret = closes[-1] / closes[warmup] - 1
36
+ bh_cagr = (1 + bh_ret) ** (1 / years) - 1
37
+ bh_eq = [c / closes[warmup] for c in closes[warmup:]]
38
+
39
+ trades = result.trades
40
+ wins = [t for t in trades if t.return_pct > 0]
41
+ losses = [t for t in trades if t.return_pct <= 0]
42
+
43
+ def trade_dict(t):
44
+ return asdict(t) if t is not None else None
45
+
46
+ return {
47
+ "data_range": [data.dates[0], data.dates[-1]],
48
+ "strategy_period": [data.dates[warmup], data.dates[-1]],
49
+ "n_trades": len(trades),
50
+ "win_rate_pct": round(100 * len(wins) / len(trades), 1) if trades else 0.0,
51
+ "avg_win_pct": round(sum(t.return_pct for t in wins) / len(wins), 2) if wins else 0.0,
52
+ "avg_loss_pct": round(sum(t.return_pct for t in losses) / len(losses), 2)
53
+ if losses
54
+ else 0.0,
55
+ "strategy_total_return_pct": round(strat_ret * 100, 1),
56
+ "strategy_cagr_pct": round(cagr * 100, 1),
57
+ "strategy_max_dd_pct": round(max_drawdown(result.equity) * 100, 1),
58
+ "buyhold_total_return_pct": round(bh_ret * 100, 1),
59
+ "buyhold_cagr_pct": round(bh_cagr * 100, 1),
60
+ "buyhold_max_dd_pct": round(max_drawdown(bh_eq) * 100, 1),
61
+ "time_in_market_pct": round(100 * result.in_market_bars / span_bars, 1),
62
+ "best_trade": trade_dict(max(trades, key=lambda t: t.return_pct)) if trades else None,
63
+ "worst_trade": trade_dict(min(trades, key=lambda t: t.return_pct)) if trades else None,
64
+ "trades": [asdict(t) for t in trades],
65
+ }
@@ -0,0 +1,37 @@
1
+ """Ready-made strategies. Subclass Strategy (see backtest.py) for your own."""
2
+ from .backtest import BarData, Strategy
3
+ from .indicators import sma
4
+
5
+
6
+ class CrossoverStrategy(Strategy):
7
+ """Go long when the fast SMA crosses above the slow SMA; exit (to cash)
8
+ when it crosses back below. Hysteretic: no signal, no change.
9
+
10
+ Pass warmup=strategy.warmup to run(). warmup = slow - 1, the first bar
11
+ on which both averages are defined.
12
+ """
13
+
14
+ def __init__(self, fast: int = 50, slow: int = 200):
15
+ if not 1 < fast < slow:
16
+ raise ValueError("need 1 < fast < slow")
17
+ self.fast = fast
18
+ self.slow = slow
19
+
20
+ @property
21
+ def warmup(self) -> int:
22
+ return self.slow - 1
23
+
24
+ def on_bar(self, i: int, data: BarData, position: int) -> int:
25
+ # No signal until both the current AND previous averages are
26
+ # defined — comparing against a half-formed average would be
27
+ # peeking at a signal that doesn't exist yet.
28
+ if i - 1 < self.slow - 1:
29
+ return position
30
+ c = data.closes
31
+ f, s = sma(c, self.fast, i), sma(c, self.slow, i)
32
+ fp, sp = sma(c, self.fast, i - 1), sma(c, self.slow, i - 1)
33
+ if position == 0 and fp <= sp and f > s:
34
+ return 1
35
+ if position == 1 and fp >= sp and f < s:
36
+ return 0
37
+ return position
@@ -0,0 +1,47 @@
1
+ """Deterministic SYNTHETIC price series for examples and tests.
2
+
3
+ These are NOT real market data: a seeded geometric Brownian motion
4
+ with a mild upward drift. They exist so the bundled example is
5
+ reproducible forever without redistributing anyone's price data.
6
+
7
+ Never publish performance numbers computed on this data as if they
8
+ were real. For real data, use fetch_stooq() (split-adjusted only —
9
+ fine for experimenting, not for publishing) or load your own
10
+ dividend/split-adjusted closes.
11
+ """
12
+ import datetime
13
+ import math
14
+ import random
15
+
16
+ from .backtest import BarData
17
+
18
+
19
+ def synthetic_bars(
20
+ n: int = 1500,
21
+ seed: int = 42,
22
+ start: str = "2020-01-01",
23
+ start_px: float = 100.0,
24
+ drift: float = 0.0004,
25
+ vol: float = 0.012,
26
+ ) -> BarData:
27
+ """Generate n daily bars of synthetic closes from a seeded PRNG.
28
+
29
+ Same seed -> identical series, forever. Weekends are skipped so
30
+ the dates look like market data.
31
+ """
32
+ if n < 1:
33
+ raise ValueError("n must be >= 1")
34
+ rng = random.Random(seed)
35
+ closes = []
36
+ px = start_px
37
+ for _ in range(n):
38
+ px *= math.exp(drift - 0.5 * vol * vol + vol * rng.gauss(0, 1))
39
+ closes.append(round(px, 2))
40
+ d0 = datetime.date.fromisoformat(start)
41
+ dates = []
42
+ d = d0
43
+ while len(dates) < n:
44
+ if d.weekday() < 5: # Mon-Fri only
45
+ dates.append(d.isoformat())
46
+ d += datetime.timedelta(days=1)
47
+ return BarData(dates=dates, closes=closes)
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: stratiq-backtest
3
+ Version: 0.3.0
4
+ Summary: A tiny, honest backtesting engine: long-only signal backtests with full trade logs and buy-and-hold comparison.
5
+ Author: StratIQ
6
+ License: MIT
7
+ Project-URL: Homepage, https://stratiqai.app/track-record
8
+ Project-URL: Repository, https://github.com/germygrimes/stratiq-backtest
9
+ Keywords: backtest,trading,quant,systematic
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # stratiq-backtest
16
+
17
+ A tiny, honest backtesting engine. Long-only signal backtests with full trade logs — and every result shown next to the do-nothing alternative, because a strategy that beats nothing is a hobby.
18
+
19
+ This is the engine behind [StratIQ's public track record](https://stratiqai.app/track-record), where every backtest is published with wins and losses.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install stratiq-backtest
25
+ ```
26
+
27
+ Or from source: `pip install .` — zero dependencies, stdlib only.
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from stratiq_backtest import CrossoverStrategy, run, summarize, synthetic_bars
33
+
34
+ data = synthetic_bars(n=1500, seed=42) # reproducible demo data (NOT real market data)
35
+ strat = CrossoverStrategy(fast=50, slow=200) # golden cross
36
+ result = run(data, strat, warmup=strat.warmup)
37
+ stats = summarize(data, result)
38
+
39
+ print(stats["strategy_total_return_pct"], "vs buy-and-hold",
40
+ stats["buyhold_total_return_pct"])
41
+ for t in stats["trades"]:
42
+ print(t)
43
+ ```
44
+
45
+ Run the bundled example (golden cross on synthetic data):
46
+
47
+ ```bash
48
+ python3 examples/golden_cross_synthetic.py
49
+ ```
50
+
51
+ ## Execution semantics (read this before trusting a number)
52
+
53
+ - **Trades print at the close of the bar that generates the signal.** The strategy sees bar *i*'s close; the trade executes at bar *i*'s close.
54
+ - **No look-ahead, ever.** A position entered on bar *i* does **not** earn bar *i*'s return — that move already happened before the trade printed. It starts earning on bar *i+1*. A position exited on bar *i* **does** earn bar *i*'s return (it was exposed during the bar, sold at its close).
55
+ - Long-only, fully invested-or-cash. No leverage, no shorting. No commissions, no slippage, no taxes — real trading will do worse by roughly those costs.
56
+ - Use dividend/split-adjusted closes or your returns will be wrong.
57
+
58
+ > **Correction note (v0.2.0):** v0.1.0 credited each new position with the signal bar's own price move. That is look-ahead bias — the signal only exists after the bar closes — so v0.1.0 results were biased and unreliable, and must be re-run. (Note: "biased" does not always mean "overstated" — the old loop also dropped exit-bar returns, so on some datasets the corrected run comes out higher.) v0.2.0 fixes the execution timing and adds regression tests pinning the corrected behavior. If you published numbers computed with v0.1.0, re-run them.
59
+
60
+ ## Data
61
+
62
+ The repo ships **no real market data**. `synthetic_bars()` generates a deterministic seeded random walk for examples and tests — reproducible forever, and clearly labeled synthetic. Never publish performance numbers computed on it as if they were real.
63
+
64
+ For real data:
65
+ - `fetch_yahoo(symbol)` — free daily bars from Yahoo Finance's chart API, adjusted close (dividend- and split-adjusted). Unofficial endpoint; fails loudly.
66
+ - `fetch_stooq(symbol)` — free daily bars from Stooq, no API key. **Split-adjusted but NOT dividend-adjusted**: fine for experimenting, not for publishing numbers. (As of 2026-11 Stooq serves a JavaScript browser check to plain HTTP clients, so this path currently fails live; `fetch_yahoo` is the working one.)
67
+ - `load_json()` / `load_csv()` — bring your own dividend/split-adjusted closes.
68
+
69
+ ## Use it inside your AI assistant (MCP)
70
+
71
+ The engine ships an [MCP](https://modelcontextprotocol.io) server, so Claude, Claude Code, Cursor, and other MCP clients can backtest inside the conversation — every result arrives with buy-and-hold next to it, the full trade log, the data caveats, and a link to test your own rule.
72
+
73
+ ```bash
74
+ python3 -m stratiq_backtest.mcp_server
75
+ ```
76
+
77
+ MCP client config (stdio):
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "stratiq-backtest": {
83
+ "command": "stratiq-backtest-mcp"
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ Tools:
90
+
91
+ - `backtest_crossover` — `{ticker, fast=50, slow=200, start="2019-01-01"}`. Real daily bars via Yahoo Finance's adjusted-close series (dividend- and split-adjusted — the result carries its data caveats in the result).
92
+ - `stratiq_track_record` — StratIQ's live graded record, fetched from stratiqai.app at call time. If the feed is unreachable it returns the page link and **no numbers** — a stale record is worse than none.
93
+
94
+ ## Write your own strategy
95
+
96
+ ```python
97
+ from stratiq_backtest import Strategy
98
+
99
+ class MyStrategy(Strategy):
100
+ def on_bar(self, i, data, position):
101
+ # i: current bar index, data.closes / data.dates available.
102
+ # Return 1 to be long, 0 for cash. The new position takes
103
+ # effect AFTER bar i's close (see execution semantics above).
104
+ ...
105
+ ```
106
+
107
+ Warm up indicators with `warmup=` so signals only fire on fully-formed values.
108
+
109
+ ## Tests
110
+
111
+ ```bash
112
+ python3 -m pytest tests/
113
+ ```
114
+
115
+ Happy paths, failure modes, edge cases — including regression tests that pin the no-look-ahead contract above.
@@ -0,0 +1,14 @@
1
+ stratiq_backtest/__init__.py,sha256=jF5A1i10INeoP-WdTxTdio_CWp5ZYhOV2gL8-EvTXUo,924
2
+ stratiq_backtest/backtest.py,sha256=s-bY4qmNWZxpXItJftppeqkMo-J9ZwG9i7sJxv6pWe0,4561
3
+ stratiq_backtest/data.py,sha256=MRol5JYvco7bupmv79qQ2k8y92LXZVwfh9WEh0cb-ec,4769
4
+ stratiq_backtest/indicators.py,sha256=IHQYqvdwGAypypMRTVXK1Q5MDzVxquLHGTFnZhbbbGI,2549
5
+ stratiq_backtest/mcp_server.py,sha256=Hx5q6mi117u81ZcR-go13gmrkArRqq5Ie5ekR1N-uTs,7953
6
+ stratiq_backtest/stats.py,sha256=ERp-r_5_BLltHc_i-cmgVhmyloeW6EzNW1vpFnes6Y4,2511
7
+ stratiq_backtest/strategy.py,sha256=7pZdHwTw_s-HuSmCy7pX1XG1h4UDeaiXoj5apLPtfBw,1354
8
+ stratiq_backtest/synthetic.py,sha256=Oba8IUxqo5Z05SIQKUV8GOwP_A-4SLZOINlFPH670rs,1455
9
+ stratiq_backtest-0.3.0.dist-info/licenses/LICENSE,sha256=iOhQzkbQBFdABRWD9u4HbHzZPLvrFHVGWGJTWUxAN2o,1064
10
+ stratiq_backtest-0.3.0.dist-info/METADATA,sha256=pSOG-8PfczmlhNHP-OTPE9gpd-bkHJIERr2L_Q7flcs,5263
11
+ stratiq_backtest-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ stratiq_backtest-0.3.0.dist-info/entry_points.txt,sha256=zxSnfR894JkHCRhlDu9n_1obwJxc9uHB1YO0nTR5rEU,74
13
+ stratiq_backtest-0.3.0.dist-info/top_level.txt,sha256=kO2C2Y71MigK02tac1Z980R8x0LBNexwFGlggRtPufI,17
14
+ stratiq_backtest-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stratiq-backtest-mcp = stratiq_backtest.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StratIQ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ stratiq_backtest