tam-quant 0.1.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.
- tam_quant-0.1.0/PKG-INFO +92 -0
- tam_quant-0.1.0/README.md +66 -0
- tam_quant-0.1.0/pyproject.toml +48 -0
- tam_quant-0.1.0/setup.cfg +4 -0
- tam_quant-0.1.0/tam/__init__.py +0 -0
- tam_quant-0.1.0/tam/backtest/__init__.py +0 -0
- tam_quant-0.1.0/tam/backtest/config.py +29 -0
- tam_quant-0.1.0/tam/backtest/harness.py +168 -0
- tam_quant-0.1.0/tam/backtest/live.py +124 -0
- tam_quant-0.1.0/tam/backtest/presenter.py +200 -0
- tam_quant-0.1.0/tam/backtest/report.py +94 -0
- tam_quant-0.1.0/tam/backtest/runner.py +313 -0
- tam_quant-0.1.0/tam/backtest/visualization.py +371 -0
- tam_quant-0.1.0/tam/config.py +272 -0
- tam_quant-0.1.0/tam/data/__init__.py +0 -0
- tam_quant-0.1.0/tam/data/providers.py +94 -0
- tam_quant-0.1.0/tam/data/repository.py +70 -0
- tam_quant-0.1.0/tam/data/schema.py +17 -0
- tam_quant-0.1.0/tam/data/storage.py +109 -0
- tam_quant-0.1.0/tam/events/__init__.py +0 -0
- tam_quant-0.1.0/tam/events/bus.py +21 -0
- tam_quant-0.1.0/tam/events/clock.py +24 -0
- tam_quant-0.1.0/tam/events/types.py +25 -0
- tam_quant-0.1.0/tam/portfolio/__init__.py +0 -0
- tam_quant-0.1.0/tam/portfolio/orders.py +74 -0
- tam_quant-0.1.0/tam/portfolio/portfolio.py +84 -0
- tam_quant-0.1.0/tam/portfolio/registry.py +20 -0
- tam_quant-0.1.0/tam/registry.py +66 -0
- tam_quant-0.1.0/tam/status.py +33 -0
- tam_quant-0.1.0/tam/strategy/__init__.py +16 -0
- tam_quant-0.1.0/tam/strategy/base.py +71 -0
- tam_quant-0.1.0/tam/strategy/buy_and_hold.py +45 -0
- tam_quant-0.1.0/tam/strategy/indicators.py +46 -0
- tam_quant-0.1.0/tam/strategy/intraday_hold.py +66 -0
- tam_quant-0.1.0/tam/strategy/llm_trading.py +443 -0
- tam_quant-0.1.0/tam/strategy/ma_crossover.py +84 -0
- tam_quant-0.1.0/tam/strategy/ml_walk_forward.py +168 -0
- tam_quant-0.1.0/tam/strategy/mlx_lora_client.py +618 -0
- tam_quant-0.1.0/tam/strategy/moving_average.py +56 -0
- tam_quant-0.1.0/tam/strategy/overnight_hold.py +77 -0
- tam_quant-0.1.0/tam/strategy/signals.py +270 -0
- tam_quant-0.1.0/tam/strategy/trend_rotation.py +258 -0
- tam_quant-0.1.0/tam/trading/__init__.py +0 -0
- tam_quant-0.1.0/tam/trading/gateway.py +50 -0
- tam_quant-0.1.0/tam_quant.egg-info/PKG-INFO +92 -0
- tam_quant-0.1.0/tam_quant.egg-info/SOURCES.txt +69 -0
- tam_quant-0.1.0/tam_quant.egg-info/dependency_links.txt +1 -0
- tam_quant-0.1.0/tam_quant.egg-info/requires.txt +22 -0
- tam_quant-0.1.0/tam_quant.egg-info/top_level.txt +1 -0
- tam_quant-0.1.0/tests/test_backtest.py +146 -0
- tam_quant-0.1.0/tests/test_backtest_cli.py +371 -0
- tam_quant-0.1.0/tests/test_checkpointing.py +147 -0
- tam_quant-0.1.0/tests/test_config.py +240 -0
- tam_quant-0.1.0/tests/test_data.py +143 -0
- tam_quant-0.1.0/tests/test_events.py +32 -0
- tam_quant-0.1.0/tests/test_indicators.py +24 -0
- tam_quant-0.1.0/tests/test_live.py +89 -0
- tam_quant-0.1.0/tests/test_llm_trading.py +783 -0
- tam_quant-0.1.0/tests/test_ma_crossover.py +106 -0
- tam_quant-0.1.0/tests/test_ml_walk_forward.py +155 -0
- tam_quant-0.1.0/tests/test_mlx_lora_client.py +525 -0
- tam_quant-0.1.0/tests/test_overnight_hold.py +98 -0
- tam_quant-0.1.0/tests/test_portfolio.py +64 -0
- tam_quant-0.1.0/tests/test_providers.py +74 -0
- tam_quant-0.1.0/tests/test_qty_resolution.py +191 -0
- tam_quant-0.1.0/tests/test_registry.py +112 -0
- tam_quant-0.1.0/tests/test_report.py +151 -0
- tam_quant-0.1.0/tests/test_signals.py +145 -0
- tam_quant-0.1.0/tests/test_strategy_factories.py +175 -0
- tam_quant-0.1.0/tests/test_trend_rotation.py +139 -0
- tam_quant-0.1.0/tests/test_visualization.py +254 -0
tam_quant-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tam-quant
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Config, data ingestion, and event-driven backtesting for stocks/indices
|
|
5
|
+
Project-URL: Homepage, https://github.com/Huddie/Tam
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pandas>=2.0
|
|
9
|
+
Requires-Dist: pyarrow>=14.0
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Requires-Dist: requests>=2.31
|
|
12
|
+
Requires-Dist: yfinance>=0.2
|
|
13
|
+
Requires-Dist: plotly>=5.20
|
|
14
|
+
Requires-Dist: pydantic>=2.6
|
|
15
|
+
Requires-Dist: tulipy>=0.4.0
|
|
16
|
+
Requires-Dist: scikit-learn>=1.7.2
|
|
17
|
+
Requires-Dist: rich>=13.7
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Provides-Extra: live
|
|
21
|
+
Requires-Dist: dash>=2.17; extra == "live"
|
|
22
|
+
Provides-Extra: llm
|
|
23
|
+
Requires-Dist: mlx-lm>=0.31.3; extra == "llm"
|
|
24
|
+
Provides-Extra: notebook
|
|
25
|
+
Requires-Dist: dash>=2.17; extra == "notebook"
|
|
26
|
+
|
|
27
|
+
# tam
|
|
28
|
+
|
|
29
|
+
Config-driven event backtesting for stocks/indices: YAML in, an interactive
|
|
30
|
+
HTML dashboard out. Strategies (moving-average, MA crossover, trend rotation,
|
|
31
|
+
online-learning ML, local-LLM) are all pluggable — `examples/backtest.py`
|
|
32
|
+
doesn't import any strategy directly, it builds whatever's listed in the
|
|
33
|
+
config's `strategies:` section by name.
|
|
34
|
+
|
|
35
|
+
Published on PyPI as `tam-quant` (`pip install tam-quant`; `import tam` either
|
|
36
|
+
way). Running in Google Colab or Jupyter instead of this repo's own CLI? See
|
|
37
|
+
[NOTEBOOK.md](NOTEBOOK.md).
|
|
38
|
+
|
|
39
|
+
## Setup
|
|
40
|
+
|
|
41
|
+
Requires [`uv`](https://docs.astral.sh/uv/) and Python 3.11 (pinned in
|
|
42
|
+
`.python-version` — `uv` will fetch it automatically if you don't have it).
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
uv sync --extra dev
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This creates `.venv/` and installs everything, including dev dependencies
|
|
49
|
+
(pytest). Run any command below with `uv run ...` so it uses that environment
|
|
50
|
+
— no need to activate the venv manually.
|
|
51
|
+
|
|
52
|
+
If you want to use the FMP data provider instead of the (no-key-needed)
|
|
53
|
+
yfinance default, copy `.env.example` to `.env` and fill in `FMP_API_KEY`.
|
|
54
|
+
|
|
55
|
+
## Running the examples
|
|
56
|
+
|
|
57
|
+
Each example is a YAML config passed to the same runner:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
uv run python -m examples.backtest examples/moving_average_config.yaml
|
|
61
|
+
uv run python -m examples.backtest examples/ma_crossover_config.yaml
|
|
62
|
+
uv run python -m examples.backtest examples/trend_rotation_config.yaml
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
These three work out of the box — no extra setup, no external services. Each
|
|
66
|
+
run prints a summary table (returns, Sharpe, drawdown, etc. per strategy)
|
|
67
|
+
with a live progress bar, and writes an interactive HTML dashboard to
|
|
68
|
+
`examples/output/<name>_report.html` — open that in a browser to see the
|
|
69
|
+
equity curves, drawdown, and per-trade markers (toggle with the "Show
|
|
70
|
+
Trades" button).
|
|
71
|
+
|
|
72
|
+
`examples/llm_trading_config.yaml` is different: it drives a strategy that
|
|
73
|
+
queries a local language model each simulated day, and by default also
|
|
74
|
+
periodically LoRA fine-tunes it (both via `mlx-lm`, Apple Silicon only). The
|
|
75
|
+
first run downloads the base model from Hugging Face (needs network once).
|
|
76
|
+
Because it calls the model every simulated day, this one is much slower than
|
|
77
|
+
the others — try a short date range first (edit `start`/`end` in the config)
|
|
78
|
+
before running the full period. See the comments in that file for how to
|
|
79
|
+
point it at Ollama or another server instead, or turn LoRA fine-tuning off.
|
|
80
|
+
|
|
81
|
+
Want to try your own mix of strategies? Copy one of the configs and edit its
|
|
82
|
+
`strategies:` list — see `tam/strategy/*.py` for what's registered and what
|
|
83
|
+
params each one takes.
|
|
84
|
+
|
|
85
|
+
## Running the tests
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
uv run pytest
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
No network access or external services required — everything is tested
|
|
92
|
+
against fakes/mocks (fake data providers, a stubbed LLM client, etc.).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# tam
|
|
2
|
+
|
|
3
|
+
Config-driven event backtesting for stocks/indices: YAML in, an interactive
|
|
4
|
+
HTML dashboard out. Strategies (moving-average, MA crossover, trend rotation,
|
|
5
|
+
online-learning ML, local-LLM) are all pluggable — `examples/backtest.py`
|
|
6
|
+
doesn't import any strategy directly, it builds whatever's listed in the
|
|
7
|
+
config's `strategies:` section by name.
|
|
8
|
+
|
|
9
|
+
Published on PyPI as `tam-quant` (`pip install tam-quant`; `import tam` either
|
|
10
|
+
way). Running in Google Colab or Jupyter instead of this repo's own CLI? See
|
|
11
|
+
[NOTEBOOK.md](NOTEBOOK.md).
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
Requires [`uv`](https://docs.astral.sh/uv/) and Python 3.11 (pinned in
|
|
16
|
+
`.python-version` — `uv` will fetch it automatically if you don't have it).
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
uv sync --extra dev
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This creates `.venv/` and installs everything, including dev dependencies
|
|
23
|
+
(pytest). Run any command below with `uv run ...` so it uses that environment
|
|
24
|
+
— no need to activate the venv manually.
|
|
25
|
+
|
|
26
|
+
If you want to use the FMP data provider instead of the (no-key-needed)
|
|
27
|
+
yfinance default, copy `.env.example` to `.env` and fill in `FMP_API_KEY`.
|
|
28
|
+
|
|
29
|
+
## Running the examples
|
|
30
|
+
|
|
31
|
+
Each example is a YAML config passed to the same runner:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
uv run python -m examples.backtest examples/moving_average_config.yaml
|
|
35
|
+
uv run python -m examples.backtest examples/ma_crossover_config.yaml
|
|
36
|
+
uv run python -m examples.backtest examples/trend_rotation_config.yaml
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
These three work out of the box — no extra setup, no external services. Each
|
|
40
|
+
run prints a summary table (returns, Sharpe, drawdown, etc. per strategy)
|
|
41
|
+
with a live progress bar, and writes an interactive HTML dashboard to
|
|
42
|
+
`examples/output/<name>_report.html` — open that in a browser to see the
|
|
43
|
+
equity curves, drawdown, and per-trade markers (toggle with the "Show
|
|
44
|
+
Trades" button).
|
|
45
|
+
|
|
46
|
+
`examples/llm_trading_config.yaml` is different: it drives a strategy that
|
|
47
|
+
queries a local language model each simulated day, and by default also
|
|
48
|
+
periodically LoRA fine-tunes it (both via `mlx-lm`, Apple Silicon only). The
|
|
49
|
+
first run downloads the base model from Hugging Face (needs network once).
|
|
50
|
+
Because it calls the model every simulated day, this one is much slower than
|
|
51
|
+
the others — try a short date range first (edit `start`/`end` in the config)
|
|
52
|
+
before running the full period. See the comments in that file for how to
|
|
53
|
+
point it at Ollama or another server instead, or turn LoRA fine-tuning off.
|
|
54
|
+
|
|
55
|
+
Want to try your own mix of strategies? Copy one of the configs and edit its
|
|
56
|
+
`strategies:` list — see `tam/strategy/*.py` for what's registered and what
|
|
57
|
+
params each one takes.
|
|
58
|
+
|
|
59
|
+
## Running the tests
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
uv run pytest
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
No network access or external services required — everything is tested
|
|
66
|
+
against fakes/mocks (fake data providers, a stubbed LLM client, etc.).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tam-quant"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Config, data ingestion, and event-driven backtesting for stocks/indices"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pandas>=2.0",
|
|
9
|
+
"pyarrow>=14.0",
|
|
10
|
+
"pyyaml>=6.0",
|
|
11
|
+
"requests>=2.31",
|
|
12
|
+
"yfinance>=0.2",
|
|
13
|
+
"plotly>=5.20",
|
|
14
|
+
"pydantic>=2.6",
|
|
15
|
+
"tulipy>=0.4.0",
|
|
16
|
+
"scikit-learn>=1.7.2",
|
|
17
|
+
"rich>=13.7",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/Huddie/Tam"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=8.0"]
|
|
25
|
+
live = ["dash>=2.17"]
|
|
26
|
+
# mlx-lm/mlx are Apple-Silicon-only (Metal) -- a hard dependency here would
|
|
27
|
+
# make `pip install tam-quant` fail outright on Linux/Colab/Windows, not just
|
|
28
|
+
# leave an unusable feature installed. Only tam.strategy.mlx_lora_client
|
|
29
|
+
# needs this; every other strategy works fine without it (see
|
|
30
|
+
# tam/strategy/__init__.py, which registers strategies defensively so a
|
|
31
|
+
# missing optional dependency skips just that one strategy, not the whole
|
|
32
|
+
# package).
|
|
33
|
+
llm = ["mlx-lm>=0.31.3"]
|
|
34
|
+
# Convenience bundle for a from-scratch notebook environment (Colab/Jupyter,
|
|
35
|
+
# always Linux or a container -- never Apple Silicon in practice), so a user
|
|
36
|
+
# doesn't have to separately learn about `live`: `pip install "tam-quant[notebook]"`.
|
|
37
|
+
notebook = ["dash>=2.17"]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["setuptools>=68"]
|
|
41
|
+
build-backend = "setuptools.build_meta"
|
|
42
|
+
|
|
43
|
+
[[tool.uv.index]]
|
|
44
|
+
url = "https://pypi.apple.com/simple"
|
|
45
|
+
default = true
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
include = ["tam*"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Builds every strategy+portfolio in a backtest run from a config-driven list.
|
|
2
|
+
|
|
3
|
+
Each entry in `backtest.strategies` names a strategy registered under
|
|
4
|
+
(Strategy, name) in tam.registry.Registry, plus its own params — so which
|
|
5
|
+
strategies run, and what they're compared against, is a config change, not a
|
|
6
|
+
code change. New strategies just need one @Registry.register(Strategy, "name")
|
|
7
|
+
adapter function (see tam/strategy/buy_and_hold.py for an example).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import List, Tuple
|
|
12
|
+
|
|
13
|
+
from ..data.repository import DataRepository
|
|
14
|
+
from ..portfolio.portfolio import Portfolio
|
|
15
|
+
from ..registry import Registry
|
|
16
|
+
from ..strategy.base import Strategy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_strategies(
|
|
20
|
+
repository: DataRepository, specs, default_cash: float
|
|
21
|
+
) -> Tuple[List[Strategy], dict]:
|
|
22
|
+
strategies = []
|
|
23
|
+
portfolios = {}
|
|
24
|
+
for spec in specs:
|
|
25
|
+
cash = float(spec.cash) if "cash" in spec else default_cash
|
|
26
|
+
strategy = Registry.create(Strategy, spec.strategy, repository, spec.portfolio_id, spec.params, cash)
|
|
27
|
+
strategies.append(strategy)
|
|
28
|
+
portfolios[spec.portfolio_id] = Portfolio(spec.portfolio_id, cash=cash)
|
|
29
|
+
return strategies, portfolios
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Wires strategies, portfolios, and market data into a day-by-day event loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import pickle
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Dict, List, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from ..data.repository import DataRepository
|
|
13
|
+
from ..data.schema import CLOSE, OPEN
|
|
14
|
+
from ..events.bus import EventBus
|
|
15
|
+
from ..events.clock import Clock
|
|
16
|
+
from ..events.types import ANNOTATION_TOPIC, State
|
|
17
|
+
from ..portfolio.orders import PriceBasis
|
|
18
|
+
from ..portfolio.portfolio import Portfolio
|
|
19
|
+
from ..portfolio.registry import PortfolioRegistry
|
|
20
|
+
from ..strategy.base import Strategy
|
|
21
|
+
from ..trading.gateway import TradeGateway
|
|
22
|
+
from .report import Report
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Progress:
|
|
27
|
+
"""How far a run has gotten: day_index is 1-based, so day_index == total_days
|
|
28
|
+
on the final simulated day."""
|
|
29
|
+
|
|
30
|
+
day_index: int
|
|
31
|
+
total_days: int
|
|
32
|
+
current_date: date
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def fraction(self) -> float:
|
|
36
|
+
return self.day_index / self.total_days if self.total_days else 1.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
OnProgress = Callable[[Progress], None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BacktestHarness:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
repository: DataRepository,
|
|
46
|
+
strategies: Sequence[Strategy],
|
|
47
|
+
portfolios: Dict[str, Portfolio],
|
|
48
|
+
dates: Sequence[date],
|
|
49
|
+
):
|
|
50
|
+
self._repository = repository
|
|
51
|
+
self._bus = EventBus()
|
|
52
|
+
self._annotations: List[dict] = []
|
|
53
|
+
self._bus.subscribe(ANNOTATION_TOPIC, self._on_annotation)
|
|
54
|
+
self._portfolios = PortfolioRegistry(portfolios)
|
|
55
|
+
self._trader = TradeGateway(self._portfolios, self._price_on)
|
|
56
|
+
self._strategies = list(strategies)
|
|
57
|
+
for strategy in self._strategies:
|
|
58
|
+
strategy.bind(self._bus, self._trader, self._portfolios)
|
|
59
|
+
self._clock = Clock(dates, self._bus)
|
|
60
|
+
|
|
61
|
+
def _on_annotation(self, event) -> None:
|
|
62
|
+
self._annotations.append(dict(event.payload))
|
|
63
|
+
|
|
64
|
+
def _price_on(self, ticker: str, as_of: date, basis: PriceBasis = PriceBasis.CLOSE) -> float:
|
|
65
|
+
history = self._repository.query(ticker, end=as_of)
|
|
66
|
+
if history.empty:
|
|
67
|
+
raise LookupError(f"no price data for {ticker} on or before {as_of}")
|
|
68
|
+
column = OPEN if basis is PriceBasis.OPEN else CLOSE
|
|
69
|
+
return float(history.iloc[-1][column])
|
|
70
|
+
|
|
71
|
+
def run(
|
|
72
|
+
self,
|
|
73
|
+
on_progress: Optional[OnProgress] = None,
|
|
74
|
+
checkpoint_path: Optional[str] = None,
|
|
75
|
+
checkpoint_every: int = 1,
|
|
76
|
+
) -> Report:
|
|
77
|
+
"""Run the full date range. If checkpoint_path is given: resume from it if
|
|
78
|
+
it already exists (skipping every day already completed), and write a
|
|
79
|
+
fresh checkpoint every `checkpoint_every` completed days -- so a crash
|
|
80
|
+
(bad data, a flaky external model server, anything) loses at most that
|
|
81
|
+
many days of work, not the whole run. The checkpoint is removed on a
|
|
82
|
+
clean finish, since it exists purely to resume an interrupted run of
|
|
83
|
+
THIS exact strategies/portfolios/dates configuration -- rerunning the
|
|
84
|
+
same config from scratch after success should start fresh, not replay
|
|
85
|
+
a stale checkpoint from a previous, unrelated run.
|
|
86
|
+
"""
|
|
87
|
+
snapshots: List[dict] = []
|
|
88
|
+
completed_days = 0
|
|
89
|
+
if checkpoint_path is not None and Path(checkpoint_path).exists():
|
|
90
|
+
completed_days, snapshots, self._annotations = self._load_checkpoint(checkpoint_path)
|
|
91
|
+
|
|
92
|
+
for strategy in self._strategies:
|
|
93
|
+
strategy.state_change(State.START)
|
|
94
|
+
for strategy in self._strategies:
|
|
95
|
+
strategy.state_change(State.RUNNING)
|
|
96
|
+
|
|
97
|
+
total_days = len(self._clock.dates)
|
|
98
|
+
for day_index, current_date in enumerate(self._clock.dates, start=1):
|
|
99
|
+
if day_index <= completed_days:
|
|
100
|
+
continue
|
|
101
|
+
self._trader.current_date = current_date
|
|
102
|
+
self._clock.tick(current_date)
|
|
103
|
+
snapshots.extend(self._snapshot(current_date))
|
|
104
|
+
if on_progress is not None:
|
|
105
|
+
on_progress(Progress(day_index, total_days, current_date))
|
|
106
|
+
if checkpoint_path is not None and day_index % checkpoint_every == 0:
|
|
107
|
+
self._write_checkpoint(checkpoint_path, day_index, snapshots)
|
|
108
|
+
|
|
109
|
+
for strategy in self._strategies:
|
|
110
|
+
strategy.state_change(State.END)
|
|
111
|
+
|
|
112
|
+
if checkpoint_path is not None:
|
|
113
|
+
Path(checkpoint_path).unlink(missing_ok=True)
|
|
114
|
+
|
|
115
|
+
return Report(snapshots, self._trades(), self._annotations)
|
|
116
|
+
|
|
117
|
+
def _write_checkpoint(self, checkpoint_path: str, day_index: int, snapshots: List[dict]) -> None:
|
|
118
|
+
state = {
|
|
119
|
+
"day_index": day_index,
|
|
120
|
+
"snapshots": snapshots,
|
|
121
|
+
"annotations": list(self._annotations),
|
|
122
|
+
"portfolios": {portfolio_id: p.get_state() for portfolio_id, p in self._portfolios.items()},
|
|
123
|
+
"strategies": [s.get_state() for s in self._strategies],
|
|
124
|
+
}
|
|
125
|
+
path = Path(checkpoint_path)
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
# Write-then-rename so a crash mid-write can't corrupt the last good checkpoint.
|
|
128
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
129
|
+
with os.fdopen(fd, "wb") as handle:
|
|
130
|
+
pickle.dump(state, handle)
|
|
131
|
+
os.replace(tmp_name, path)
|
|
132
|
+
|
|
133
|
+
def _load_checkpoint(self, checkpoint_path: str) -> tuple[int, List[dict], List[dict]]:
|
|
134
|
+
with open(checkpoint_path, "rb") as handle:
|
|
135
|
+
state = pickle.load(handle)
|
|
136
|
+
for portfolio_id, portfolio_state in state["portfolios"].items():
|
|
137
|
+
self._portfolios[portfolio_id].load_state(portfolio_state)
|
|
138
|
+
for strategy, strategy_state in zip(self._strategies, state["strategies"]):
|
|
139
|
+
strategy.load_state(strategy_state)
|
|
140
|
+
return state["day_index"], state["snapshots"], state.get("annotations", [])
|
|
141
|
+
|
|
142
|
+
def _trades(self) -> List[dict]:
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
"date": trade.date,
|
|
146
|
+
"portfolio": portfolio_id,
|
|
147
|
+
"ticker": trade.ticker,
|
|
148
|
+
"side": trade.side,
|
|
149
|
+
"qty": trade.qty,
|
|
150
|
+
"price": trade.price,
|
|
151
|
+
}
|
|
152
|
+
for portfolio_id, portfolio in self._portfolios.items()
|
|
153
|
+
for trade in portfolio.trades
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
def _snapshot(self, as_of: date) -> List[dict]:
|
|
157
|
+
rows = []
|
|
158
|
+
for portfolio_id, portfolio in self._portfolios.items():
|
|
159
|
+
prices = {ticker: self._price_on(ticker, as_of) for ticker in portfolio.tickers}
|
|
160
|
+
rows.append(
|
|
161
|
+
{
|
|
162
|
+
"date": as_of,
|
|
163
|
+
"portfolio": portfolio_id,
|
|
164
|
+
"cash": portfolio.cash,
|
|
165
|
+
"value": portfolio.market_value(prices),
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
return rows
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Live-updating view of an in-progress backtest.
|
|
2
|
+
|
|
3
|
+
Polls the same checkpoint file BacktestHarness.run(checkpoint_path=...) already
|
|
4
|
+
writes every `checkpoint_every` days -- the backtest loop itself needs zero
|
|
5
|
+
awareness that anything is watching it. Kept out of visualization.py/report.py
|
|
6
|
+
so those stay dependency-light; this module needs the `live` extra
|
|
7
|
+
(`uv sync --extra live`, adds `dash`) since most runs just want the static
|
|
8
|
+
HTML report from write_html and shouldn't need Dash installed for that.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import pickle
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Optional
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from .report import Report
|
|
20
|
+
from .visualization import render
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def report_from_checkpoint(checkpoint_path: str) -> Optional[Report]:
|
|
24
|
+
"""Reconstruct a partial Report from whatever's in the checkpoint right
|
|
25
|
+
now, or None if it doesn't exist yet (e.g. day 1 hasn't completed)."""
|
|
26
|
+
path = Path(checkpoint_path)
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return None
|
|
29
|
+
with path.open("rb") as handle:
|
|
30
|
+
state = pickle.load(handle)
|
|
31
|
+
|
|
32
|
+
trades = [
|
|
33
|
+
{**trade, "portfolio": portfolio_id}
|
|
34
|
+
for portfolio_id, portfolio_state in state["portfolios"].items()
|
|
35
|
+
for trade in portfolio_state["trades"]
|
|
36
|
+
]
|
|
37
|
+
return Report(state["snapshots"], trades, state.get("annotations", []))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def serve(
|
|
41
|
+
checkpoint_path: str,
|
|
42
|
+
title: str = "Backtest (live)",
|
|
43
|
+
poll_seconds: float = 3.0,
|
|
44
|
+
port: int = 8050,
|
|
45
|
+
verbose: bool = False,
|
|
46
|
+
ticker_colors: Optional[Dict[str, str]] = None,
|
|
47
|
+
prices: Optional[Dict[str, "pd.Series"]] = None,
|
|
48
|
+
jupyter_mode: Optional[str] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Blocking (unless `jupyter_mode` says otherwise -- see below): serves a
|
|
51
|
+
dashboard at http://127.0.0.1:<port> that re-reads the checkpoint every
|
|
52
|
+
`poll_seconds` and redraws the same figure visualization.render() would
|
|
53
|
+
produce for the final report -- just from whatever's completed so far.
|
|
54
|
+
Keeps showing the last good read after the checkpoint is removed on a
|
|
55
|
+
clean finish, rather than reverting to blank.
|
|
56
|
+
|
|
57
|
+
`jupyter_mode`: passed straight through to Dash's own `app.run()` --
|
|
58
|
+
None (default) behaves exactly as before (blocks, serves a normal HTTP
|
|
59
|
+
dashboard for a separate browser tab); "inline" renders the live-updating
|
|
60
|
+
dashboard directly in the current notebook cell's output instead, and
|
|
61
|
+
(like Dash's own inline mode) does NOT block -- `serve()` returns once
|
|
62
|
+
the server starts, while the dashboard keeps polling/updating
|
|
63
|
+
asynchronously in the output area. Use "external" if "inline" renders
|
|
64
|
+
blank (some notebook hosts, including Colab in certain configurations,
|
|
65
|
+
sandbox iframes in a way inline mode doesn't tolerate) -- it prints a
|
|
66
|
+
clickable link instead of embedding an iframe.
|
|
67
|
+
|
|
68
|
+
`prices`, if given, is the same already-fetched historical price data
|
|
69
|
+
write_html()'s optional top panel supports -- passed in whole, but
|
|
70
|
+
render() itself truncates each series to whatever date the equity/
|
|
71
|
+
drawdown panels have reached so far, so the price panel builds up in
|
|
72
|
+
lockstep with them instead of spoiling the ending upfront.
|
|
73
|
+
|
|
74
|
+
Flask/Werkzeug's per-request access log (one line per poll, forever) is
|
|
75
|
+
silenced by default -- it drowns out the rich progress display the
|
|
76
|
+
backtest itself is drawing in the same terminal. Pass verbose=True (or
|
|
77
|
+
--log-level verbose on the CLI) to see it, e.g. while debugging the live
|
|
78
|
+
server itself."""
|
|
79
|
+
try:
|
|
80
|
+
import dash
|
|
81
|
+
from dash import dcc, html
|
|
82
|
+
from dash.dependencies import Input, Output
|
|
83
|
+
except ImportError as exc:
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"`--mode live` needs the `live` extra: run `uv sync --extra live` (adds dash) and retry. "
|
|
86
|
+
"In a notebook, install the `notebook` extra instead (`pip install \"tam-quant[notebook]\"`)."
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
if not verbose:
|
|
90
|
+
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
|
91
|
+
|
|
92
|
+
app = dash.Dash(__name__)
|
|
93
|
+
app.layout = html.Div(
|
|
94
|
+
[
|
|
95
|
+
html.Div(id="status", style={"fontFamily": "monospace", "padding": "8px"}),
|
|
96
|
+
dcc.Graph(id="figure", style={"height": "95vh"}),
|
|
97
|
+
dcc.Interval(id="tick", interval=int(poll_seconds * 1000)),
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
last_report: dict = {"value": None}
|
|
101
|
+
|
|
102
|
+
@app.callback(Output("figure", "figure"), Output("status", "children"), Input("tick", "n_intervals"))
|
|
103
|
+
def _refresh(_):
|
|
104
|
+
fresh = report_from_checkpoint(checkpoint_path)
|
|
105
|
+
if fresh is not None:
|
|
106
|
+
last_report["value"] = fresh
|
|
107
|
+
report = last_report["value"]
|
|
108
|
+
|
|
109
|
+
if report is None or not report.snapshots:
|
|
110
|
+
return {}, "waiting for the first completed day..."
|
|
111
|
+
|
|
112
|
+
fig = render(report, title=title, ticker_colors=ticker_colors, prices=prices)
|
|
113
|
+
frame = report.to_frame()
|
|
114
|
+
last_date = frame["date"].max()
|
|
115
|
+
day_count = frame["date"].nunique()
|
|
116
|
+
status = f"through {last_date} — day {day_count}"
|
|
117
|
+
if not Path(checkpoint_path).exists():
|
|
118
|
+
status += " -- backtest finished, showing final state"
|
|
119
|
+
return fig, status
|
|
120
|
+
|
|
121
|
+
run_kwargs = {"port": port, "debug": False, "threaded": True}
|
|
122
|
+
if jupyter_mode is not None:
|
|
123
|
+
run_kwargs["jupyter_mode"] = jupyter_mode
|
|
124
|
+
app.run(**run_kwargs)
|