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.
- stratiq_backtest-0.3.0/LICENSE +21 -0
- stratiq_backtest-0.3.0/PKG-INFO +115 -0
- stratiq_backtest-0.3.0/README.md +101 -0
- stratiq_backtest-0.3.0/pyproject.toml +24 -0
- stratiq_backtest-0.3.0/setup.cfg +4 -0
- stratiq_backtest-0.3.0/stratiq_backtest/__init__.py +35 -0
- stratiq_backtest-0.3.0/stratiq_backtest/backtest.py +130 -0
- stratiq_backtest-0.3.0/stratiq_backtest/data.py +111 -0
- stratiq_backtest-0.3.0/stratiq_backtest/indicators.py +82 -0
- stratiq_backtest-0.3.0/stratiq_backtest/mcp_server.py +205 -0
- stratiq_backtest-0.3.0/stratiq_backtest/stats.py +65 -0
- stratiq_backtest-0.3.0/stratiq_backtest/strategy.py +37 -0
- stratiq_backtest-0.3.0/stratiq_backtest/synthetic.py +47 -0
- stratiq_backtest-0.3.0/stratiq_backtest.egg-info/PKG-INFO +115 -0
- stratiq_backtest-0.3.0/stratiq_backtest.egg-info/SOURCES.txt +18 -0
- stratiq_backtest-0.3.0/stratiq_backtest.egg-info/dependency_links.txt +1 -0
- stratiq_backtest-0.3.0/stratiq_backtest.egg-info/entry_points.txt +2 -0
- stratiq_backtest-0.3.0/stratiq_backtest.egg-info/top_level.txt +1 -0
- stratiq_backtest-0.3.0/tests/test_backtest.py +296 -0
- stratiq_backtest-0.3.0/tests/test_mcp_server.py +113 -0
|
@@ -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,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
|