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.
Files changed (71) hide show
  1. tam_quant-0.1.0/PKG-INFO +92 -0
  2. tam_quant-0.1.0/README.md +66 -0
  3. tam_quant-0.1.0/pyproject.toml +48 -0
  4. tam_quant-0.1.0/setup.cfg +4 -0
  5. tam_quant-0.1.0/tam/__init__.py +0 -0
  6. tam_quant-0.1.0/tam/backtest/__init__.py +0 -0
  7. tam_quant-0.1.0/tam/backtest/config.py +29 -0
  8. tam_quant-0.1.0/tam/backtest/harness.py +168 -0
  9. tam_quant-0.1.0/tam/backtest/live.py +124 -0
  10. tam_quant-0.1.0/tam/backtest/presenter.py +200 -0
  11. tam_quant-0.1.0/tam/backtest/report.py +94 -0
  12. tam_quant-0.1.0/tam/backtest/runner.py +313 -0
  13. tam_quant-0.1.0/tam/backtest/visualization.py +371 -0
  14. tam_quant-0.1.0/tam/config.py +272 -0
  15. tam_quant-0.1.0/tam/data/__init__.py +0 -0
  16. tam_quant-0.1.0/tam/data/providers.py +94 -0
  17. tam_quant-0.1.0/tam/data/repository.py +70 -0
  18. tam_quant-0.1.0/tam/data/schema.py +17 -0
  19. tam_quant-0.1.0/tam/data/storage.py +109 -0
  20. tam_quant-0.1.0/tam/events/__init__.py +0 -0
  21. tam_quant-0.1.0/tam/events/bus.py +21 -0
  22. tam_quant-0.1.0/tam/events/clock.py +24 -0
  23. tam_quant-0.1.0/tam/events/types.py +25 -0
  24. tam_quant-0.1.0/tam/portfolio/__init__.py +0 -0
  25. tam_quant-0.1.0/tam/portfolio/orders.py +74 -0
  26. tam_quant-0.1.0/tam/portfolio/portfolio.py +84 -0
  27. tam_quant-0.1.0/tam/portfolio/registry.py +20 -0
  28. tam_quant-0.1.0/tam/registry.py +66 -0
  29. tam_quant-0.1.0/tam/status.py +33 -0
  30. tam_quant-0.1.0/tam/strategy/__init__.py +16 -0
  31. tam_quant-0.1.0/tam/strategy/base.py +71 -0
  32. tam_quant-0.1.0/tam/strategy/buy_and_hold.py +45 -0
  33. tam_quant-0.1.0/tam/strategy/indicators.py +46 -0
  34. tam_quant-0.1.0/tam/strategy/intraday_hold.py +66 -0
  35. tam_quant-0.1.0/tam/strategy/llm_trading.py +443 -0
  36. tam_quant-0.1.0/tam/strategy/ma_crossover.py +84 -0
  37. tam_quant-0.1.0/tam/strategy/ml_walk_forward.py +168 -0
  38. tam_quant-0.1.0/tam/strategy/mlx_lora_client.py +618 -0
  39. tam_quant-0.1.0/tam/strategy/moving_average.py +56 -0
  40. tam_quant-0.1.0/tam/strategy/overnight_hold.py +77 -0
  41. tam_quant-0.1.0/tam/strategy/signals.py +270 -0
  42. tam_quant-0.1.0/tam/strategy/trend_rotation.py +258 -0
  43. tam_quant-0.1.0/tam/trading/__init__.py +0 -0
  44. tam_quant-0.1.0/tam/trading/gateway.py +50 -0
  45. tam_quant-0.1.0/tam_quant.egg-info/PKG-INFO +92 -0
  46. tam_quant-0.1.0/tam_quant.egg-info/SOURCES.txt +69 -0
  47. tam_quant-0.1.0/tam_quant.egg-info/dependency_links.txt +1 -0
  48. tam_quant-0.1.0/tam_quant.egg-info/requires.txt +22 -0
  49. tam_quant-0.1.0/tam_quant.egg-info/top_level.txt +1 -0
  50. tam_quant-0.1.0/tests/test_backtest.py +146 -0
  51. tam_quant-0.1.0/tests/test_backtest_cli.py +371 -0
  52. tam_quant-0.1.0/tests/test_checkpointing.py +147 -0
  53. tam_quant-0.1.0/tests/test_config.py +240 -0
  54. tam_quant-0.1.0/tests/test_data.py +143 -0
  55. tam_quant-0.1.0/tests/test_events.py +32 -0
  56. tam_quant-0.1.0/tests/test_indicators.py +24 -0
  57. tam_quant-0.1.0/tests/test_live.py +89 -0
  58. tam_quant-0.1.0/tests/test_llm_trading.py +783 -0
  59. tam_quant-0.1.0/tests/test_ma_crossover.py +106 -0
  60. tam_quant-0.1.0/tests/test_ml_walk_forward.py +155 -0
  61. tam_quant-0.1.0/tests/test_mlx_lora_client.py +525 -0
  62. tam_quant-0.1.0/tests/test_overnight_hold.py +98 -0
  63. tam_quant-0.1.0/tests/test_portfolio.py +64 -0
  64. tam_quant-0.1.0/tests/test_providers.py +74 -0
  65. tam_quant-0.1.0/tests/test_qty_resolution.py +191 -0
  66. tam_quant-0.1.0/tests/test_registry.py +112 -0
  67. tam_quant-0.1.0/tests/test_report.py +151 -0
  68. tam_quant-0.1.0/tests/test_signals.py +145 -0
  69. tam_quant-0.1.0/tests/test_strategy_factories.py +175 -0
  70. tam_quant-0.1.0/tests/test_trend_rotation.py +139 -0
  71. tam_quant-0.1.0/tests/test_visualization.py +254 -0
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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)