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.
- stratiq_backtest/__init__.py +35 -0
- stratiq_backtest/backtest.py +130 -0
- stratiq_backtest/data.py +111 -0
- stratiq_backtest/indicators.py +82 -0
- stratiq_backtest/mcp_server.py +205 -0
- stratiq_backtest/stats.py +65 -0
- stratiq_backtest/strategy.py +37 -0
- stratiq_backtest/synthetic.py +47 -0
- stratiq_backtest-0.3.0.dist-info/METADATA +115 -0
- stratiq_backtest-0.3.0.dist-info/RECORD +14 -0
- stratiq_backtest-0.3.0.dist-info/WHEEL +5 -0
- stratiq_backtest-0.3.0.dist-info/entry_points.txt +2 -0
- stratiq_backtest-0.3.0.dist-info/licenses/LICENSE +21 -0
- stratiq_backtest-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|
stratiq_backtest/data.py
ADDED
|
@@ -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,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
|