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.
- ml4t/backtest/__init__.py +131 -0
- ml4t/backtest/_compat.py +46 -0
- ml4t/backtest/_validation/__init__.py +1 -0
- ml4t/backtest/_validation/backtrader_runner.py +153 -0
- ml4t/backtest/_validation/case_study_lean.py +280 -0
- ml4t/backtest/_validation/lean_runner.py +350 -0
- ml4t/backtest/_validation/vectorbt_runner.py +167 -0
- ml4t/backtest/_validation/zipline_runner.py +380 -0
- ml4t/backtest/_validation_imports.py +65 -0
- ml4t/backtest/_version.py +24 -0
- ml4t/backtest/accounting/__init__.py +47 -0
- ml4t/backtest/accounting/account.py +162 -0
- ml4t/backtest/accounting/gatekeeper.py +331 -0
- ml4t/backtest/accounting/policy.py +727 -0
- ml4t/backtest/analytics/__init__.py +37 -0
- ml4t/backtest/analytics/annualization.py +154 -0
- ml4t/backtest/analytics/bridge.py +155 -0
- ml4t/backtest/analytics/equity.py +178 -0
- ml4t/backtest/analytics/metrics.py +180 -0
- ml4t/backtest/analytics/trades.py +527 -0
- ml4t/backtest/broker.py +2423 -0
- ml4t/backtest/calendar.py +857 -0
- ml4t/backtest/config.py +1455 -0
- ml4t/backtest/core/__init__.py +23 -0
- ml4t/backtest/core/execution_engine.py +719 -0
- ml4t/backtest/core/fill_engine.py +239 -0
- ml4t/backtest/core/order_book.py +580 -0
- ml4t/backtest/core/portfolio_ledger.py +51 -0
- ml4t/backtest/core/risk_engine.py +275 -0
- ml4t/backtest/core/shared.py +81 -0
- ml4t/backtest/core/state.py +63 -0
- ml4t/backtest/datafeed.py +461 -0
- ml4t/backtest/engine.py +910 -0
- ml4t/backtest/execution/__init__.py +58 -0
- ml4t/backtest/execution/fill_executor.py +793 -0
- ml4t/backtest/execution/impact.py +186 -0
- ml4t/backtest/execution/limits.py +186 -0
- ml4t/backtest/execution/rebalancer.py +624 -0
- ml4t/backtest/execution/result.py +40 -0
- ml4t/backtest/execution/schedule.py +981 -0
- ml4t/backtest/export.py +329 -0
- ml4t/backtest/lifecycle.py +111 -0
- ml4t/backtest/models.py +312 -0
- ml4t/backtest/preopen.py +999 -0
- ml4t/backtest/profiles.py +490 -0
- ml4t/backtest/py.typed +0 -0
- ml4t/backtest/result.py +1621 -0
- ml4t/backtest/risk/__init__.py +75 -0
- ml4t/backtest/risk/portfolio/__init__.py +44 -0
- ml4t/backtest/risk/portfolio/limits.py +605 -0
- ml4t/backtest/risk/portfolio/manager.py +252 -0
- ml4t/backtest/risk/position/__init__.py +34 -0
- ml4t/backtest/risk/position/composite.py +103 -0
- ml4t/backtest/risk/position/dynamic.py +505 -0
- ml4t/backtest/risk/position/protocol.py +38 -0
- ml4t/backtest/risk/position/signal.py +48 -0
- ml4t/backtest/risk/position/static.py +246 -0
- ml4t/backtest/risk/types.py +151 -0
- ml4t/backtest/sessions.py +424 -0
- ml4t/backtest/spec_bridge.py +31 -0
- ml4t/backtest/strategies/__init__.py +44 -0
- ml4t/backtest/strategies/templates.py +455 -0
- ml4t/backtest/strategy.py +48 -0
- ml4t/backtest/types.py +706 -0
- ml4t_backtest-0.1.0.dist-info/METADATA +473 -0
- ml4t_backtest-0.1.0.dist-info/RECORD +68 -0
- ml4t_backtest-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|
ml4t/backtest/_compat.py
ADDED
|
@@ -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
|
+
}
|