stratiq-backtest 0.3.0__tar.gz

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,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,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,101 @@
1
+ # stratiq-backtest
2
+
3
+ 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.
4
+
5
+ This is the engine behind [StratIQ's public track record](https://stratiqai.app/track-record), where every backtest is published with wins and losses.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install stratiq-backtest
11
+ ```
12
+
13
+ Or from source: `pip install .` — zero dependencies, stdlib only.
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from stratiq_backtest import CrossoverStrategy, run, summarize, synthetic_bars
19
+
20
+ data = synthetic_bars(n=1500, seed=42) # reproducible demo data (NOT real market data)
21
+ strat = CrossoverStrategy(fast=50, slow=200) # golden cross
22
+ result = run(data, strat, warmup=strat.warmup)
23
+ stats = summarize(data, result)
24
+
25
+ print(stats["strategy_total_return_pct"], "vs buy-and-hold",
26
+ stats["buyhold_total_return_pct"])
27
+ for t in stats["trades"]:
28
+ print(t)
29
+ ```
30
+
31
+ Run the bundled example (golden cross on synthetic data):
32
+
33
+ ```bash
34
+ python3 examples/golden_cross_synthetic.py
35
+ ```
36
+
37
+ ## Execution semantics (read this before trusting a number)
38
+
39
+ - **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.
40
+ - **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).
41
+ - 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.
42
+ - Use dividend/split-adjusted closes or your returns will be wrong.
43
+
44
+ > **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.
45
+
46
+ ## Data
47
+
48
+ 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.
49
+
50
+ For real data:
51
+ - `fetch_yahoo(symbol)` — free daily bars from Yahoo Finance's chart API, adjusted close (dividend- and split-adjusted). Unofficial endpoint; fails loudly.
52
+ - `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.)
53
+ - `load_json()` / `load_csv()` — bring your own dividend/split-adjusted closes.
54
+
55
+ ## Use it inside your AI assistant (MCP)
56
+
57
+ 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.
58
+
59
+ ```bash
60
+ python3 -m stratiq_backtest.mcp_server
61
+ ```
62
+
63
+ MCP client config (stdio):
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "stratiq-backtest": {
69
+ "command": "stratiq-backtest-mcp"
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ Tools:
76
+
77
+ - `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).
78
+ - `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.
79
+
80
+ ## Write your own strategy
81
+
82
+ ```python
83
+ from stratiq_backtest import Strategy
84
+
85
+ class MyStrategy(Strategy):
86
+ def on_bar(self, i, data, position):
87
+ # i: current bar index, data.closes / data.dates available.
88
+ # Return 1 to be long, 0 for cash. The new position takes
89
+ # effect AFTER bar i's close (see execution semantics above).
90
+ ...
91
+ ```
92
+
93
+ Warm up indicators with `warmup=` so signals only fire on fully-formed values.
94
+
95
+ ## Tests
96
+
97
+ ```bash
98
+ python3 -m pytest tests/
99
+ ```
100
+
101
+ Happy paths, failure modes, edge cases — including regression tests that pin the no-look-ahead contract above.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stratiq-backtest"
7
+ version = "0.3.0"
8
+ description = "A tiny, honest backtesting engine: long-only signal backtests with full trade logs and buy-and-hold comparison."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "StratIQ" }]
13
+ keywords = ["backtest", "trading", "quant", "systematic"]
14
+
15
+ [project.scripts]
16
+ stratiq-backtest-mcp = "stratiq_backtest.mcp_server:main"
17
+
18
+ [project.urls]
19
+ Homepage = "https://stratiqai.app/track-record"
20
+ Repository = "https://github.com/germygrimes/stratiq-backtest"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["."]
24
+ include = ["stratiq_backtest*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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