ml4t-backtest 0.1.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.
Files changed (68) hide show
  1. ml4t/backtest/__init__.py +131 -0
  2. ml4t/backtest/_compat.py +46 -0
  3. ml4t/backtest/_validation/__init__.py +1 -0
  4. ml4t/backtest/_validation/backtrader_runner.py +153 -0
  5. ml4t/backtest/_validation/case_study_lean.py +280 -0
  6. ml4t/backtest/_validation/lean_runner.py +350 -0
  7. ml4t/backtest/_validation/vectorbt_runner.py +167 -0
  8. ml4t/backtest/_validation/zipline_runner.py +380 -0
  9. ml4t/backtest/_validation_imports.py +65 -0
  10. ml4t/backtest/_version.py +24 -0
  11. ml4t/backtest/accounting/__init__.py +47 -0
  12. ml4t/backtest/accounting/account.py +162 -0
  13. ml4t/backtest/accounting/gatekeeper.py +331 -0
  14. ml4t/backtest/accounting/policy.py +727 -0
  15. ml4t/backtest/analytics/__init__.py +37 -0
  16. ml4t/backtest/analytics/annualization.py +154 -0
  17. ml4t/backtest/analytics/bridge.py +155 -0
  18. ml4t/backtest/analytics/equity.py +178 -0
  19. ml4t/backtest/analytics/metrics.py +180 -0
  20. ml4t/backtest/analytics/trades.py +527 -0
  21. ml4t/backtest/broker.py +2423 -0
  22. ml4t/backtest/calendar.py +857 -0
  23. ml4t/backtest/config.py +1455 -0
  24. ml4t/backtest/core/__init__.py +23 -0
  25. ml4t/backtest/core/execution_engine.py +719 -0
  26. ml4t/backtest/core/fill_engine.py +239 -0
  27. ml4t/backtest/core/order_book.py +580 -0
  28. ml4t/backtest/core/portfolio_ledger.py +51 -0
  29. ml4t/backtest/core/risk_engine.py +275 -0
  30. ml4t/backtest/core/shared.py +81 -0
  31. ml4t/backtest/core/state.py +63 -0
  32. ml4t/backtest/datafeed.py +461 -0
  33. ml4t/backtest/engine.py +910 -0
  34. ml4t/backtest/execution/__init__.py +58 -0
  35. ml4t/backtest/execution/fill_executor.py +793 -0
  36. ml4t/backtest/execution/impact.py +186 -0
  37. ml4t/backtest/execution/limits.py +186 -0
  38. ml4t/backtest/execution/rebalancer.py +624 -0
  39. ml4t/backtest/execution/result.py +40 -0
  40. ml4t/backtest/execution/schedule.py +981 -0
  41. ml4t/backtest/export.py +329 -0
  42. ml4t/backtest/lifecycle.py +111 -0
  43. ml4t/backtest/models.py +312 -0
  44. ml4t/backtest/preopen.py +999 -0
  45. ml4t/backtest/profiles.py +490 -0
  46. ml4t/backtest/py.typed +0 -0
  47. ml4t/backtest/result.py +1621 -0
  48. ml4t/backtest/risk/__init__.py +75 -0
  49. ml4t/backtest/risk/portfolio/__init__.py +44 -0
  50. ml4t/backtest/risk/portfolio/limits.py +605 -0
  51. ml4t/backtest/risk/portfolio/manager.py +252 -0
  52. ml4t/backtest/risk/position/__init__.py +34 -0
  53. ml4t/backtest/risk/position/composite.py +103 -0
  54. ml4t/backtest/risk/position/dynamic.py +505 -0
  55. ml4t/backtest/risk/position/protocol.py +38 -0
  56. ml4t/backtest/risk/position/signal.py +48 -0
  57. ml4t/backtest/risk/position/static.py +246 -0
  58. ml4t/backtest/risk/types.py +151 -0
  59. ml4t/backtest/sessions.py +424 -0
  60. ml4t/backtest/spec_bridge.py +31 -0
  61. ml4t/backtest/strategies/__init__.py +44 -0
  62. ml4t/backtest/strategies/templates.py +455 -0
  63. ml4t/backtest/strategy.py +48 -0
  64. ml4t/backtest/types.py +706 -0
  65. ml4t_backtest-0.1.0.dist-info/METADATA +473 -0
  66. ml4t_backtest-0.1.0.dist-info/RECORD +68 -0
  67. ml4t_backtest-0.1.0.dist-info/WHEEL +4 -0
  68. ml4t_backtest-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,131 @@
1
+ """ml4t.backtest - Minimal event-driven backtesting engine.
2
+
3
+ A clean, extensible backtesting engine with:
4
+ - Multi-asset support
5
+ - Polars-first data handling
6
+ - Pluggable commission/slippage models
7
+ - Same-bar and next-bar execution modes
8
+ - Live trading compatible interface
9
+ """
10
+
11
+ from . import _compat as _compat
12
+
13
+ try:
14
+ from ml4t.backtest._version import __version__
15
+ except ImportError:
16
+ __version__ = "0.0.0.dev0"
17
+
18
+ from .broker import Broker
19
+ from .config import BacktestConfig, CommissionType
20
+ from .datafeed import DataFeed
21
+ from .engine import Engine, run_backtest
22
+
23
+ # Execution: rebalancing
24
+ from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor
25
+ from .execution.schedule import (
26
+ RebalanceCadence,
27
+ RebalanceSchedule,
28
+ is_rebalance_timestamp,
29
+ resolve_rebalance_timestamps,
30
+ )
31
+ from .lifecycle import LifecycleDispatcher, LifecycleInvocation, callback_trace
32
+ from .preopen import (
33
+ AmbiguousBarPathError,
34
+ IntentOutcome,
35
+ IntentReconciliation,
36
+ LateAuctionIntentError,
37
+ PreOpenIntentError,
38
+ UnsupportedPreOpenPolicyError,
39
+ default_execution_policy,
40
+ )
41
+ from .result import (
42
+ ArtifactDiagnostic,
43
+ ArtifactError,
44
+ ArtifactIncompleteError,
45
+ ArtifactManifestError,
46
+ ArtifactNotFoundError,
47
+ ArtifactReadError,
48
+ ArtifactWriteError,
49
+ BacktestResult,
50
+ UnsupportedArtifactVersionError,
51
+ )
52
+
53
+ # Risk management rules (position-level)
54
+ from .risk.position.composite import RuleChain
55
+ from .risk.position.dynamic import TrailingStop
56
+ from .risk.position.static import StopLoss, TakeProfit
57
+ from .strategy import Strategy
58
+ from .types import (
59
+ AssetClass,
60
+ ContractSpec,
61
+ ExecutionMode,
62
+ ExitReason,
63
+ Fill,
64
+ Order,
65
+ OrderSide,
66
+ OrderStatus,
67
+ OrderType,
68
+ Position,
69
+ StopFillMode,
70
+ StopLevelBasis,
71
+ Trade,
72
+ )
73
+
74
+ del _compat
75
+
76
+ __all__ = [
77
+ # Core API
78
+ "DataFeed",
79
+ "Broker",
80
+ "Strategy",
81
+ "Engine",
82
+ "run_backtest",
83
+ "BacktestConfig",
84
+ "BacktestResult",
85
+ "ArtifactDiagnostic",
86
+ "ArtifactError",
87
+ "ArtifactNotFoundError",
88
+ "ArtifactManifestError",
89
+ "ArtifactIncompleteError",
90
+ "ArtifactReadError",
91
+ "ArtifactWriteError",
92
+ "UnsupportedArtifactVersionError",
93
+ "CommissionType",
94
+ # Canonical domain types
95
+ "OrderType",
96
+ "OrderSide",
97
+ "OrderStatus",
98
+ "ExecutionMode",
99
+ "ExitReason",
100
+ "StopFillMode",
101
+ "StopLevelBasis",
102
+ "Order",
103
+ "Position",
104
+ "Fill",
105
+ "Trade",
106
+ # Asset specifications
107
+ "AssetClass",
108
+ "ContractSpec",
109
+ # Execution: rebalancing
110
+ "RebalanceConfig",
111
+ "TargetWeightExecutor",
112
+ "RebalanceCadence",
113
+ "RebalanceSchedule",
114
+ "is_rebalance_timestamp",
115
+ "resolve_rebalance_timestamps",
116
+ "LifecycleDispatcher",
117
+ "LifecycleInvocation",
118
+ "callback_trace",
119
+ "AmbiguousBarPathError",
120
+ "IntentOutcome",
121
+ "IntentReconciliation",
122
+ "LateAuctionIntentError",
123
+ "PreOpenIntentError",
124
+ "UnsupportedPreOpenPolicyError",
125
+ "default_execution_policy",
126
+ # Risk rules
127
+ "StopLoss",
128
+ "TakeProfit",
129
+ "TrailingStop",
130
+ "RuleChain",
131
+ ]
@@ -0,0 +1,46 @@
1
+ """Compatibility fixes for supported Python prereleases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+
10
+ def _apply_python_prerelease_compatibility() -> None:
11
+ """Apply dependency fixes needed by the tested Python prerelease."""
12
+ if sys.version_info < (3, 15):
13
+ return
14
+
15
+ import polars as pl
16
+ from polars.series import utils as series_utils
17
+
18
+ def empty_docstring_method() -> None:
19
+ """Sentinel matching Polars expression-dispatch methods."""
20
+
21
+ if series_utils._is_empty_method(empty_docstring_method):
22
+ return
23
+
24
+ original_is_empty_method = series_utils._is_empty_method
25
+
26
+ def is_empty_method(function: Callable[..., Any]) -> bool:
27
+ if original_is_empty_method(function):
28
+ return True
29
+ code = function.__code__
30
+ return (
31
+ code.co_code in series_utils._EMPTY_BYTECODE
32
+ and function.__doc__ is not None
33
+ and code.co_consts == (function.__doc__,)
34
+ )
35
+
36
+ series_utils._is_empty_method = is_empty_method
37
+ try:
38
+ series_utils.expr_dispatch(pl.Series)
39
+ probe = pl.Series([1])
40
+ for accessor in pl.Series._accessors - {"plot"}:
41
+ series_utils.expr_dispatch(type(getattr(probe, accessor)))
42
+ finally:
43
+ series_utils._is_empty_method = original_is_empty_method
44
+
45
+
46
+ _apply_python_prerelease_compatibility()
@@ -0,0 +1 @@
1
+ """Internal validation helpers not exposed as part of the public API."""
@@ -0,0 +1,153 @@
1
+ """Shared Backtrader helpers for internal validation workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import pandas as pd
10
+
11
+
12
+ @dataclass
13
+ class BacktraderRunResult:
14
+ """Standardized Backtrader run artifacts for benchmark consumers."""
15
+
16
+ final_value: float
17
+ num_trades: int
18
+ trades_df: pd.DataFrame | None
19
+ positions_df: pd.DataFrame | None
20
+ transactions_df: pd.DataFrame | None
21
+
22
+
23
+ def load_backtrader_package() -> Any:
24
+ """Import and return the Backtrader package."""
25
+ return importlib.import_module("backtrader")
26
+
27
+
28
+ def transactions_to_trade_log(transactions: pd.DataFrame) -> pd.DataFrame | None:
29
+ """Convert PyFolio transactions into completed round-trip trades."""
30
+ if len(transactions) == 0 or "symbol" not in transactions.columns:
31
+ return None
32
+
33
+ trade_records: list[dict[str, object]] = []
34
+ for symbol in transactions["symbol"].unique():
35
+ symbol_txns = transactions[transactions["symbol"] == symbol].sort_index()
36
+
37
+ running_pos = 0.0
38
+ entry_time = None
39
+ entry_price = None
40
+ entry_size = 0.0
41
+
42
+ for dt, row in symbol_txns.iterrows():
43
+ amount = float(row["amount"])
44
+ price = float(row["price"])
45
+ prev_pos = running_pos
46
+ running_pos += amount
47
+
48
+ if prev_pos == 0 and running_pos != 0:
49
+ entry_time = dt
50
+ entry_price = price
51
+ entry_size = amount
52
+ elif prev_pos != 0 and running_pos == 0:
53
+ assert entry_time is not None
54
+ assert entry_price is not None
55
+ pnl = (price - entry_price) * entry_size
56
+ trade_records.append(
57
+ {
58
+ "entry_date": entry_time,
59
+ "exit_date": dt,
60
+ "asset": str(symbol),
61
+ "side": "long" if entry_size > 0 else "short",
62
+ "quantity": abs(entry_size),
63
+ "entry_price": entry_price,
64
+ "exit_price": price,
65
+ "pnl": pnl,
66
+ }
67
+ )
68
+ entry_time = None
69
+ entry_price = None
70
+ elif prev_pos != 0 and running_pos != 0 and (prev_pos > 0) != (running_pos > 0):
71
+ assert entry_time is not None
72
+ assert entry_price is not None
73
+ pnl = (price - entry_price) * entry_size
74
+ trade_records.append(
75
+ {
76
+ "entry_date": entry_time,
77
+ "exit_date": dt,
78
+ "asset": str(symbol),
79
+ "side": "long" if entry_size > 0 else "short",
80
+ "quantity": abs(entry_size),
81
+ "entry_price": entry_price,
82
+ "exit_price": price,
83
+ "pnl": pnl,
84
+ }
85
+ )
86
+ entry_time = dt
87
+ entry_price = price
88
+ entry_size = running_pos
89
+
90
+ if not trade_records:
91
+ return None
92
+ return pd.DataFrame(trade_records).sort_values("entry_date").reset_index(drop=True)
93
+
94
+
95
+ def run_backtrader_target_shares(
96
+ *,
97
+ bt: Any,
98
+ price_data: dict[str, pd.DataFrame],
99
+ target_lookup: dict[str, dict[str, float]],
100
+ initial_cash: float,
101
+ commission_pct: float,
102
+ ) -> BacktraderRunResult:
103
+ """Run a canonical target-share strategy through Backtrader."""
104
+
105
+ class TopBottomBTStrategy(bt.Strategy):
106
+ def __init__(self):
107
+ self.target_lookup = target_lookup
108
+ self.data_by_name = {data._name: data for data in self.datas}
109
+
110
+ def next(self):
111
+ dt = self.datas[0].datetime.datetime(0)
112
+ dt_key = dt.strftime("%Y-%m-%d")
113
+ targets = self.target_lookup.get(dt_key, {})
114
+
115
+ active_names = set(targets.keys())
116
+ for data in self.datas:
117
+ if self.getposition(data).size != 0:
118
+ active_names.add(data._name)
119
+
120
+ for asset_name in sorted(active_names):
121
+ data = self.data_by_name[asset_name]
122
+ current_size = self.getposition(data).size
123
+ target_size = targets.get(asset_name, 0.0)
124
+ if current_size != target_size:
125
+ self.order_target_size(data=data, target=target_size)
126
+
127
+ cerebro = bt.Cerebro()
128
+ cerebro.addstrategy(TopBottomBTStrategy)
129
+
130
+ for asset_name, df in price_data.items():
131
+ data = bt.feeds.PandasData(dataname=df, name=asset_name)
132
+ cerebro.adddata(data)
133
+
134
+ cerebro.broker.setcash(initial_cash)
135
+ if commission_pct > 0:
136
+ cerebro.broker.setcommission(commission=commission_pct)
137
+ cerebro.addanalyzer(bt.analyzers.PyFolio, _name="pyfolio")
138
+
139
+ results = cerebro.run()
140
+ strat = results[0]
141
+ pyfolio_analyzer = strat.analyzers.getbyname("pyfolio")
142
+ _returns, positions, transactions, _gross_lev = pyfolio_analyzer.get_pf_items()
143
+
144
+ trades_df = transactions_to_trade_log(transactions)
145
+ num_trades = len(trades_df) if trades_df is not None else 0
146
+
147
+ return BacktraderRunResult(
148
+ final_value=float(cerebro.broker.getvalue()),
149
+ num_trades=num_trades,
150
+ trades_df=trades_df,
151
+ positions_df=positions,
152
+ transactions_df=transactions,
153
+ )
@@ -0,0 +1,280 @@
1
+ """Case-study LEAN parity: logged LEAN artifacts vs ml4t-backtest[lean].
2
+
3
+ This reconstructs the case-study parity harness used by the Chapter 16
4
+ ``16_case_study_lean_parity`` notebook. Each ``chapter16_<case_study>`` LEAN
5
+ workspace is self-contained (``main.py`` + ``weights.csv`` + ``rebalance_dates.csv``
6
+ + ``asset_symbols.csv`` + LEAN daily zips), so both sides reproduce from it:
7
+
8
+ * **LEAN side** -- load the committed artifacts produced by the workspace
9
+ algorithm: ``ml4t_order_events.csv`` and ``ml4t_daily_equity.csv``. Regenerate
10
+ them with :func:`validation.benchmark_suite.run_lean_backtest`.
11
+ * **ml4t side** -- decode the workspace's own daily zips (so both engines consume
12
+ byte-identical prices), then replay the identical target-weight strategy through
13
+ the ``lean`` profile.
14
+
15
+ Parity is asserted on the sorted daily fill multiset
16
+ ``(timestamp, asset, side, quantity, 4-decimal price)`` plus terminal portfolio value.
17
+
18
+ The ml4t strategy mirrors the workspace ``main.py`` exactly, including LEAN's
19
+ fill-forward semantics: a name dropped from the target universe is still sized
20
+ (off its last known close) and liquidated at the next real bar, rather than left
21
+ untouched once it has no bar on a rebalance day.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ import json
28
+ import math
29
+ import re
30
+ import zipfile
31
+ from pathlib import Path
32
+
33
+ import pandas as pd
34
+ import polars as pl
35
+
36
+ from ..config import BacktestConfig, CommissionType, SlippageType
37
+ from ..datafeed import DataFeed
38
+ from ..engine import Engine
39
+ from ..strategy import Strategy
40
+
41
+ _NUMBER = r"[-+]?(?:\d[\d_]*(?:\.\d[\d_]*)?|\.\d[\d_]*)(?:[eE][-+]?\d[\d_]*)?"
42
+
43
+
44
+ def _csv_path(base_dir: Path, name: str) -> Path:
45
+ for suffix in ("", ".xz", ".gz"):
46
+ path = base_dir / f"{name}{suffix}"
47
+ if path.exists():
48
+ return path
49
+ raise FileNotFoundError(base_dir / name)
50
+
51
+
52
+ def _read_csv_fixture(base_dir: Path, name: str, **kwargs) -> pd.DataFrame:
53
+ parts = sorted(base_dir.glob(f"{name}.part*.xz"))
54
+ if parts:
55
+ return pd.concat((pd.read_csv(part, **kwargs) for part in parts), ignore_index=True)
56
+ return pd.read_csv(_csv_path(base_dir, name), **kwargs)
57
+
58
+
59
+ def parse_workspace_params(workspace_dir: Path) -> dict:
60
+ """Read start/end/cash/fee from a chapter16 LEAN workspace ``main.py``."""
61
+ text = (workspace_dir / "main.py").read_text(encoding="utf-8")
62
+
63
+ def _date(macro: str) -> str:
64
+ m = re.search(rf"set_{macro}_date\((\d+),\s*(\d+),\s*(\d+)\)", text)
65
+ if not m:
66
+ raise ValueError(f"no set_{macro}_date in {workspace_dir / 'main.py'}")
67
+ y, mo, d = (int(x) for x in m.groups())
68
+ return f"{y:04d}-{mo:02d}-{d:02d}"
69
+
70
+ def _number(pattern: str, name: str) -> float:
71
+ m = re.search(pattern, text)
72
+ if not m:
73
+ raise ValueError(f"no {name} in {workspace_dir / 'main.py'}")
74
+ return float(m.group(1).replace("_", ""))
75
+
76
+ return {
77
+ "start": _date("start"),
78
+ "end": _date("end"),
79
+ "initial_cash": _number(rf"set_cash\(\s*({_NUMBER})\s*\)", "set_cash"),
80
+ "fee": _number(rf"Ml4tPercentFeeModel\(\s*({_NUMBER})\s*\)", "fee model"),
81
+ }
82
+
83
+
84
+ def _decode_lean_zip(zip_path: Path) -> pd.DataFrame:
85
+ """Decode a LEAN daily OHLCV zip (prices stored as x10000 integers)."""
86
+ with zipfile.ZipFile(zip_path) as zf:
87
+ raw = zf.read(zf.namelist()[0]).decode()
88
+ rows = []
89
+ for line in raw.splitlines():
90
+ if not line.strip():
91
+ continue
92
+ dt, o, h, lo, c, v = line.split(",")
93
+ rows.append(
94
+ {
95
+ "timestamp": pd.Timestamp(dt.split()[0]),
96
+ "open": int(o) / 10000.0,
97
+ "high": int(h) / 10000.0,
98
+ "low": int(lo) / 10000.0,
99
+ "close": int(c) / 10000.0,
100
+ "volume": float(v),
101
+ }
102
+ )
103
+ return pd.DataFrame(rows)
104
+
105
+
106
+ def load_workspace(workspace_dir: Path, data_daily: Path) -> dict:
107
+ """Load weights, rebalance dates, asset map and price panel for one workspace."""
108
+ params = parse_workspace_params(workspace_dir)
109
+
110
+ asset_to_ticker: dict[str, str] = {}
111
+ with (workspace_dir / "asset_symbols.csv").open(newline="") as f:
112
+ for row in csv.DictReader(f):
113
+ a, t = row["asset"].strip(), row["ticker"].strip()
114
+ if a and t:
115
+ asset_to_ticker[a] = t
116
+ asset_order = list(asset_to_ticker)
117
+
118
+ targets: dict[str, dict[str, float]] = {}
119
+ for row in _read_csv_fixture(workspace_dir, "weights.csv").to_dict("records"):
120
+ targets.setdefault(row["timestamp"], {})[row["asset"]] = float(row["target_weight"])
121
+
122
+ rebalance_dates = {
123
+ ln.strip()
124
+ for ln in (workspace_dir / "rebalance_dates.csv").read_text().splitlines()
125
+ if ln.strip()
126
+ }
127
+
128
+ start = pd.Timestamp(params["start"]).to_pydatetime()
129
+ end = pd.Timestamp(params["end"]).to_pydatetime()
130
+ frames = []
131
+ for asset, ticker in asset_to_ticker.items():
132
+ df = _decode_lean_zip(data_daily / f"{ticker.lower()}.zip")
133
+ df["symbol"] = asset
134
+ frames.append(df)
135
+ prices_pd = pd.concat(frames, ignore_index=True)
136
+ prices = (
137
+ pl.DataFrame({str(name): series.to_list() for name, series in prices_pd.items()})
138
+ .select("symbol", "timestamp", "open", "high", "low", "close", "volume")
139
+ .filter((pl.col("timestamp") >= start) & (pl.col("timestamp") <= end))
140
+ .sort("timestamp", "symbol")
141
+ )
142
+ return {
143
+ "params": params,
144
+ "asset_order": asset_order,
145
+ "asset_to_ticker": asset_to_ticker,
146
+ "targets": targets,
147
+ "rebalance_dates": rebalance_dates,
148
+ "prices": prices,
149
+ }
150
+
151
+
152
+ class CaseStudyWeightStrategy(Strategy):
153
+ """Target-weight rebalance mirroring the LEAN workspace ``main.py``.
154
+
155
+ On each rebalance date, size ``target_qty = signed_floor(weight * equity / close)``
156
+ and submit the delta as a market order. Includes LEAN-style fill-forward: an
157
+ asset with no real bar on a rebalance day is still sized off its last known
158
+ close (so a dropped name liquidates), until its final real bar (delisting).
159
+ """
160
+
161
+ def __init__(self, asset_order, targets, rebalance_dates, last_active):
162
+ self.asset_order = asset_order
163
+ self.targets = targets
164
+ self.rebalance_dates = rebalance_dates
165
+ self.last_active = last_active
166
+ self.last_close: dict[str, float] = {}
167
+
168
+ @staticmethod
169
+ def _target_quantity(weight: float, portfolio_value: float, price: float) -> int:
170
+ raw = (weight * portfolio_value) / price
171
+ if raw >= 0:
172
+ return int(math.floor(raw + 1e-12))
173
+ return -int(math.floor(abs(raw) + 1e-12))
174
+
175
+ def on_data(self, timestamp, data, context, broker):
176
+ for asset in self.asset_order:
177
+ bar = data.get(asset)
178
+ if bar is not None:
179
+ px = float(bar["close"])
180
+ if px > 0:
181
+ self.last_close[asset] = px
182
+
183
+ ts = pd.Timestamp(timestamp).normalize()
184
+ if ts.strftime("%Y-%m-%d") not in self.rebalance_dates:
185
+ return
186
+ targets = self.targets.get(ts.strftime("%Y-%m-%d"), {})
187
+ pv = broker.equity()
188
+ for asset in self.asset_order:
189
+ if ts > self.last_active.get(asset, ts):
190
+ continue
191
+ price = self.last_close.get(asset)
192
+ if price is None or price <= 0:
193
+ continue
194
+ target_qty = self._target_quantity(targets.get(asset, 0.0), pv, price)
195
+ pos = broker.get_position(asset)
196
+ current = int(pos.quantity) if pos else 0
197
+ delta = target_qty - current
198
+ if delta != 0:
199
+ broker.submit_order(asset, delta)
200
+
201
+
202
+ def _surface(trades: pd.DataFrame) -> pd.DataFrame:
203
+ out = trades.copy()
204
+ out["timestamp"] = pd.to_datetime(out["timestamp"]).dt.normalize()
205
+ out["asset"] = out["asset"].astype(str)
206
+ out["side"] = out["side"].astype(str).str.lower()
207
+ out["quantity"] = out["quantity"].astype(float).abs()
208
+ out["price"] = out["price"].astype(float).round(4)
209
+ return out[["timestamp", "asset", "side", "quantity", "price"]].reset_index(drop=True)
210
+
211
+
212
+ def run_ml4t_lean(workspace_dir: Path, data_daily: Path) -> dict:
213
+ """Run ml4t-backtest[lean] on a workspace's weights; return value + fill surface."""
214
+ wd = load_workspace(workspace_dir, data_daily)
215
+ cfg = BacktestConfig.from_preset("lean")
216
+ cfg.initial_cash = wd["params"]["initial_cash"]
217
+ cfg.allow_short_selling = True
218
+ cfg.allow_leverage = True
219
+ cfg.commission_type = CommissionType.PERCENTAGE
220
+ cfg.commission_rate = wd["params"]["fee"]
221
+ cfg.commission_per_share = 0.0
222
+ cfg.commission_minimum = 0.0
223
+ cfg.slippage_type = SlippageType.NONE
224
+ cfg.slippage_rate = 0.0
225
+
226
+ last_active = {
227
+ row["symbol"]: pd.Timestamp(row["timestamp"])
228
+ for row in wd["prices"].group_by("symbol").agg(pl.col("timestamp").max()).to_dicts()
229
+ }
230
+ feed = DataFeed(prices_df=wd["prices"])
231
+ strat = CaseStudyWeightStrategy(
232
+ wd["asset_order"], wd["targets"], wd["rebalance_dates"], last_active
233
+ )
234
+ result = Engine.from_config(feed, strat, config=cfg).run()
235
+ fills_pl = result.to_fills_dataframe()
236
+ fills = pd.DataFrame(fills_pl.to_dict(as_series=False)).rename(columns={"symbol": "asset"})
237
+ return {"final_value": float(result["final_value"]), "fills": _surface(fills)}
238
+
239
+
240
+ def lean_side(workspace_dir: Path) -> dict:
241
+ """Load the LEAN-side fills + terminal value the workspace algorithm logged.
242
+
243
+ Reads the artifacts the LEAN ``main.py`` writes at the workspace root:
244
+ ``ml4t_order_events.csv`` (fills), ``ml4t_daily_equity.csv`` (terminal value),
245
+ ``ml4t_symbol_map.json`` (obfuscated ticker -> real asset).
246
+ """
247
+ symbol_map = json.loads((workspace_dir / "ml4t_symbol_map.json").read_text(encoding="utf-8"))
248
+ equity = _read_csv_fixture(workspace_dir, "ml4t_daily_equity.csv")
249
+ final_value = float(equity["equity"].iloc[-1])
250
+
251
+ events = _read_csv_fixture(workspace_dir, "ml4t_order_events.csv", low_memory=False)
252
+ filled = events[events["status"].astype(str) == "Filled"].copy()
253
+ filled["asset"] = filled["symbol"].astype(str).map(symbol_map).fillna(filled["symbol"])
254
+ filled = filled.rename(columns={"fill_quantity": "quantity", "fill_price": "price"})
255
+ filled["side"] = filled["direction"].astype(str).str.lower()
256
+ fills = _surface(filled)
257
+ return {"final_value": final_value, "fills": fills, "n_trades": len(fills)}
258
+
259
+
260
+ def compare(lean: dict, ml4t: dict) -> dict:
261
+ """Compare LEAN vs ml4t fill surfaces + terminal value."""
262
+ cols = ["timestamp", "asset", "side", "quantity", "price"]
263
+ ls = lean["fills"].sort_values(cols).reset_index(drop=True)
264
+ ms = ml4t["fills"].sort_values(cols).reset_index(drop=True)
265
+ multiset_match = len(ls) == len(ms) and not (ls[cols] != ms[cols]).any().any()
266
+ raw_match = (
267
+ len(ls) == len(ms)
268
+ and not (lean["fills"][cols].reset_index(drop=True) != ml4t["fills"][cols]).any().any()
269
+ )
270
+ return {
271
+ "lean_final_value": lean["final_value"],
272
+ "ml4t_final_value": ml4t["final_value"],
273
+ "final_value_gap_usd": ml4t["final_value"] - lean["final_value"],
274
+ "final_value_gap_pct": ml4t["final_value"] / lean["final_value"] - 1.0,
275
+ "lean_fills": len(lean["fills"]),
276
+ "ml4t_fills": len(ml4t["fills"]),
277
+ "fill_gap": len(ml4t["fills"]) - len(lean["fills"]),
278
+ "sorted_fill_multiset_match": bool(multiset_match),
279
+ "raw_row_order_match": bool(raw_match),
280
+ }