quantbt-engine 1.0.7__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.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nautilus trustee report bundle exporter.
|
|
3
|
+
|
|
4
|
+
This module is intentionally outside the backtest engines. It consumes a
|
|
5
|
+
completed Nautilus-backed `BacktestResultV2` and writes human/audit artifacts:
|
|
6
|
+
raw Nautilus reports, normalized trade logs, a run manifest, and optional
|
|
7
|
+
QuantStats HTML.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import platform
|
|
17
|
+
import subprocess
|
|
18
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from ..core.results import BacktestResultV2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
REPORT_FILES = (
|
|
27
|
+
"equity_curve.csv",
|
|
28
|
+
"returns.csv",
|
|
29
|
+
"account_report.csv",
|
|
30
|
+
"orders_report.csv",
|
|
31
|
+
"fills_report.csv",
|
|
32
|
+
"positions_report.csv",
|
|
33
|
+
"trade_log.csv",
|
|
34
|
+
"fill_log.txt",
|
|
35
|
+
"metrics_summary.json",
|
|
36
|
+
"run_manifest.json",
|
|
37
|
+
"config.json",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def export_nautilus_report_bundle(
|
|
42
|
+
result: BacktestResultV2,
|
|
43
|
+
output_dir: str | Path,
|
|
44
|
+
strategy_id: str,
|
|
45
|
+
config: Optional[Dict[str, Any]] = None,
|
|
46
|
+
benchmark_returns: Optional[pd.Series] = None,
|
|
47
|
+
make_quantstats: bool = True,
|
|
48
|
+
quantstats_frequency: str = "1D",
|
|
49
|
+
quantstats_periods_per_year: int = 365,
|
|
50
|
+
print_fills: bool = False,
|
|
51
|
+
fill_log_limit: int = 500,
|
|
52
|
+
fill_log_mode: str = "fills_only",
|
|
53
|
+
) -> Path:
|
|
54
|
+
"""
|
|
55
|
+
Export a professional evidence bundle for a Nautilus-backed backtest.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
result:
|
|
60
|
+
Completed `BacktestResultV2`, normally returned by
|
|
61
|
+
`QuantBTEndpoint.nautilus_validation(...).simulate(...)`.
|
|
62
|
+
output_dir:
|
|
63
|
+
Parent folder where `report_{strategy_id}_{timestamp}` is created.
|
|
64
|
+
strategy_id:
|
|
65
|
+
Stable identifier for the run/report.
|
|
66
|
+
config:
|
|
67
|
+
Optional JSON-serializable run config to save as `config.json`.
|
|
68
|
+
benchmark_returns:
|
|
69
|
+
Optional benchmark returns passed through to QuantStats when supported.
|
|
70
|
+
make_quantstats:
|
|
71
|
+
Generate `quantstats_daily.html` when quantstats is installed.
|
|
72
|
+
quantstats_frequency:
|
|
73
|
+
Resampling frequency for QuantStats input. Default is daily.
|
|
74
|
+
quantstats_periods_per_year:
|
|
75
|
+
Annualization factor passed to QuantStats. Default is 365 for crypto.
|
|
76
|
+
print_fills:
|
|
77
|
+
Print bounded fill/order event lines to stdout while exporting.
|
|
78
|
+
fill_log_limit:
|
|
79
|
+
Maximum number of human-readable event lines to write/print.
|
|
80
|
+
fill_log_mode:
|
|
81
|
+
`fills_only`, `order_events`, or `bars_debug`. `bars_debug` is bounded
|
|
82
|
+
by `fill_log_limit` and uses position-change rows, not every no-op bar.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
Path
|
|
87
|
+
The created report directory.
|
|
88
|
+
"""
|
|
89
|
+
if not isinstance(result, BacktestResultV2):
|
|
90
|
+
raise TypeError("export_nautilus_report_bundle requires BacktestResultV2")
|
|
91
|
+
if not strategy_id:
|
|
92
|
+
raise ValueError("strategy_id is required")
|
|
93
|
+
if fill_log_limit < 0:
|
|
94
|
+
raise ValueError("fill_log_limit must be >= 0")
|
|
95
|
+
|
|
96
|
+
run_id = _run_id(strategy_id)
|
|
97
|
+
report_dir = Path(output_dir) / f"report_{_slug(strategy_id)}_{run_id}"
|
|
98
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
metadata = dict(result.metadata or {})
|
|
101
|
+
config_payload = _config_payload_from_result(result=result, metadata=metadata)
|
|
102
|
+
if config:
|
|
103
|
+
config_payload["annotations"] = {**dict(config_payload.get("annotations", {})), **dict(config)}
|
|
104
|
+
|
|
105
|
+
account_report = _report_frame(metadata.get("account_report"))
|
|
106
|
+
orders_report = _report_frame(metadata.get("orders_report", metadata.get("order_report")))
|
|
107
|
+
fills_report = _report_frame(metadata.get("fills_report"))
|
|
108
|
+
positions_report = _report_frame(metadata.get("positions_report"))
|
|
109
|
+
|
|
110
|
+
equity_curve = _equity_frame(result)
|
|
111
|
+
returns_frame = pd.DataFrame({"timestamp": result.returns.index, "returns": result.returns.to_numpy(dtype=float)})
|
|
112
|
+
trade_log = build_nautilus_trade_log(positions_report, fills_report, strategy_id=strategy_id)
|
|
113
|
+
fill_lines = format_nautilus_event_log(
|
|
114
|
+
fills_report=fills_report,
|
|
115
|
+
orders_report=orders_report,
|
|
116
|
+
positions=result.positions,
|
|
117
|
+
mode=fill_log_mode,
|
|
118
|
+
limit=fill_log_limit,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
_write_frame(equity_curve, report_dir / "equity_curve.csv")
|
|
122
|
+
_write_frame(returns_frame, report_dir / "returns.csv")
|
|
123
|
+
_write_frame(account_report, report_dir / "account_report.csv")
|
|
124
|
+
_write_frame(orders_report, report_dir / "orders_report.csv")
|
|
125
|
+
_write_frame(fills_report, report_dir / "fills_report.csv")
|
|
126
|
+
_write_frame(positions_report, report_dir / "positions_report.csv")
|
|
127
|
+
_write_frame(trade_log, report_dir / "trade_log.csv")
|
|
128
|
+
(report_dir / "fill_log.txt").write_text("\n".join(fill_lines) + ("\n" if fill_lines else ""), encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
if print_fills:
|
|
131
|
+
for line in fill_lines:
|
|
132
|
+
print(line)
|
|
133
|
+
|
|
134
|
+
metrics_summary = _metrics_summary(result=result, trade_log=trade_log, metadata=metadata)
|
|
135
|
+
manifest = _run_manifest(
|
|
136
|
+
result=result,
|
|
137
|
+
strategy_id=strategy_id,
|
|
138
|
+
run_id=run_id,
|
|
139
|
+
metadata=metadata,
|
|
140
|
+
config=config_payload,
|
|
141
|
+
account_report=account_report,
|
|
142
|
+
orders_report=orders_report,
|
|
143
|
+
fills_report=fills_report,
|
|
144
|
+
positions_report=positions_report,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if make_quantstats:
|
|
148
|
+
_try_write_quantstats_html(
|
|
149
|
+
result=result,
|
|
150
|
+
output_path=report_dir / "quantstats_daily.html",
|
|
151
|
+
frequency=quantstats_frequency,
|
|
152
|
+
periods_per_year=int(quantstats_periods_per_year),
|
|
153
|
+
benchmark_returns=benchmark_returns,
|
|
154
|
+
manifest=manifest,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
_write_json(report_dir / "metrics_summary.json", metrics_summary)
|
|
158
|
+
_write_json(report_dir / "run_manifest.json", manifest)
|
|
159
|
+
_write_json(report_dir / "config.json", config_payload)
|
|
160
|
+
|
|
161
|
+
return report_dir
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_nautilus_trade_log(
|
|
165
|
+
positions_report: Optional[pd.DataFrame],
|
|
166
|
+
fills_report: Optional[pd.DataFrame],
|
|
167
|
+
strategy_id: str,
|
|
168
|
+
) -> pd.DataFrame:
|
|
169
|
+
"""Build a stable closed-trade table from Nautilus positions/fills reports."""
|
|
170
|
+
columns = [
|
|
171
|
+
"strategy_id",
|
|
172
|
+
"symbol",
|
|
173
|
+
"exchange",
|
|
174
|
+
"instrument_id",
|
|
175
|
+
"position_type",
|
|
176
|
+
"open_datetime",
|
|
177
|
+
"close_datetime",
|
|
178
|
+
"entry_price",
|
|
179
|
+
"exit_price",
|
|
180
|
+
"quantity",
|
|
181
|
+
"realized_pnl",
|
|
182
|
+
"fees",
|
|
183
|
+
"duration_seconds",
|
|
184
|
+
"return_pct",
|
|
185
|
+
"order_ids",
|
|
186
|
+
]
|
|
187
|
+
positions = _report_frame(positions_report)
|
|
188
|
+
if positions.empty:
|
|
189
|
+
return pd.DataFrame(columns=columns)
|
|
190
|
+
|
|
191
|
+
fills = _report_frame(fills_report)
|
|
192
|
+
rows: List[Dict[str, Any]] = []
|
|
193
|
+
for _, pos in positions.iterrows():
|
|
194
|
+
close_dt = _coerce_timestamp_scalar(pos.get("ts_closed", pos.get("closed_time")))
|
|
195
|
+
if pd.isna(close_dt):
|
|
196
|
+
continue
|
|
197
|
+
instrument_id = str(pos.get("instrument_id", ""))
|
|
198
|
+
symbol, exchange = _instrument_parts(instrument_id)
|
|
199
|
+
open_dt = _coerce_timestamp_scalar(pos.get("ts_opened", pos.get("opened_time")))
|
|
200
|
+
entry_price = _coerce_float(pos.get("avg_px_open", pos.get("entry_price", np.nan)))
|
|
201
|
+
exit_price = _coerce_float(pos.get("avg_px_close", pos.get("exit_price", np.nan)))
|
|
202
|
+
quantity = _coerce_float(pos.get("quantity", pos.get("signed_qty", pos.get("qty", np.nan))))
|
|
203
|
+
realized_pnl = _coerce_money(pos.get("realized_pnl", 0.0))
|
|
204
|
+
fees = _fees_for_position(fills=fills, instrument_id=instrument_id, open_dt=open_dt, close_dt=close_dt)
|
|
205
|
+
side = _position_type(pos)
|
|
206
|
+
duration = _duration_seconds(open_dt, close_dt)
|
|
207
|
+
return_pct = _position_return_pct(side=side, entry_price=entry_price, exit_price=exit_price)
|
|
208
|
+
|
|
209
|
+
rows.append(
|
|
210
|
+
{
|
|
211
|
+
"strategy_id": strategy_id,
|
|
212
|
+
"symbol": symbol,
|
|
213
|
+
"exchange": exchange,
|
|
214
|
+
"instrument_id": instrument_id,
|
|
215
|
+
"position_type": side,
|
|
216
|
+
"open_datetime": open_dt,
|
|
217
|
+
"close_datetime": close_dt,
|
|
218
|
+
"entry_price": entry_price,
|
|
219
|
+
"exit_price": exit_price,
|
|
220
|
+
"quantity": quantity,
|
|
221
|
+
"realized_pnl": realized_pnl,
|
|
222
|
+
"fees": fees,
|
|
223
|
+
"duration_seconds": duration,
|
|
224
|
+
"return_pct": return_pct,
|
|
225
|
+
"order_ids": _order_ids_for_position(fills=fills, instrument_id=instrument_id, open_dt=open_dt, close_dt=close_dt),
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
out = pd.DataFrame(rows, columns=columns)
|
|
230
|
+
if not out.empty:
|
|
231
|
+
out = out.sort_values(["close_datetime", "open_datetime"], kind="stable").reset_index(drop=True)
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def format_nautilus_event_log(
|
|
236
|
+
fills_report: Optional[pd.DataFrame] = None,
|
|
237
|
+
orders_report: Optional[pd.DataFrame] = None,
|
|
238
|
+
positions: Optional[pd.DataFrame] = None,
|
|
239
|
+
mode: str = "fills_only",
|
|
240
|
+
limit: int = 500,
|
|
241
|
+
) -> List[str]:
|
|
242
|
+
"""Return bounded human-readable event lines for console/file logging."""
|
|
243
|
+
mode = str(mode).lower().strip()
|
|
244
|
+
if mode not in {"fills_only", "order_events", "bars_debug"}:
|
|
245
|
+
raise ValueError("fill_log_mode must be fills_only, order_events, or bars_debug")
|
|
246
|
+
if limit <= 0:
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
if mode == "bars_debug":
|
|
250
|
+
return _position_change_lines(positions, limit=limit)
|
|
251
|
+
|
|
252
|
+
report = _report_frame(fills_report)
|
|
253
|
+
source = "FILL"
|
|
254
|
+
if (report.empty or mode == "order_events") and orders_report is not None:
|
|
255
|
+
order_report = _report_frame(orders_report)
|
|
256
|
+
if not order_report.empty:
|
|
257
|
+
report = order_report
|
|
258
|
+
source = "ORDER"
|
|
259
|
+
if report.empty:
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
lines: List[str] = []
|
|
263
|
+
for _, row in report.head(limit).iterrows():
|
|
264
|
+
ts = _event_timestamp(row)
|
|
265
|
+
side = _event_side(row)
|
|
266
|
+
qty = _event_qty(row)
|
|
267
|
+
instrument = str(row.get("instrument_id", row.get("instrument", "")))
|
|
268
|
+
price = _event_price(row)
|
|
269
|
+
fee = _event_fee(row)
|
|
270
|
+
lines.append(f"{ts} {source} {side} {qty:g} {instrument} @ {price:g} fee={fee:g}")
|
|
271
|
+
return lines
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _try_write_quantstats_html(
|
|
275
|
+
result: BacktestResultV2,
|
|
276
|
+
output_path: Path,
|
|
277
|
+
frequency: str,
|
|
278
|
+
periods_per_year: int,
|
|
279
|
+
benchmark_returns: Optional[pd.Series],
|
|
280
|
+
manifest: Dict[str, Any],
|
|
281
|
+
) -> None:
|
|
282
|
+
manifest["quantstats_frequency"] = frequency
|
|
283
|
+
manifest["quantstats_periods_per_year"] = int(periods_per_year)
|
|
284
|
+
try:
|
|
285
|
+
import quantstats as qs
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
manifest["quantstats_status"] = f"skipped: {type(exc).__name__}: {exc}"
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
returns = _resampled_returns_for_quantstats(result.equity, frequency=frequency)
|
|
291
|
+
if returns.empty:
|
|
292
|
+
manifest["quantstats_status"] = "skipped: empty returns"
|
|
293
|
+
return
|
|
294
|
+
try:
|
|
295
|
+
qs.reports.html(
|
|
296
|
+
returns,
|
|
297
|
+
benchmark=benchmark_returns,
|
|
298
|
+
output=str(output_path),
|
|
299
|
+
title=f"QuantBT Nautilus Report - {manifest.get('strategy_id', '')}",
|
|
300
|
+
periods_per_year=periods_per_year,
|
|
301
|
+
)
|
|
302
|
+
manifest["quantstats_status"] = "written"
|
|
303
|
+
manifest["quantstats_file"] = output_path.name
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
manifest["quantstats_status"] = f"failed: {type(exc).__name__}: {exc}"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _resampled_returns_for_quantstats(equity: pd.Series, frequency: str = "1D") -> pd.Series:
|
|
309
|
+
eq = equity.copy()
|
|
310
|
+
eq.index = pd.to_datetime(eq.index, utc=True)
|
|
311
|
+
eq = pd.to_numeric(eq, errors="coerce").dropna()
|
|
312
|
+
if eq.empty:
|
|
313
|
+
return pd.Series(dtype=float)
|
|
314
|
+
sampled = eq.resample(frequency).last().dropna()
|
|
315
|
+
return sampled.pct_change().replace([np.inf, -np.inf], np.nan).dropna()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _equity_frame(result: BacktestResultV2) -> pd.DataFrame:
|
|
319
|
+
equity = pd.to_numeric(result.equity, errors="coerce")
|
|
320
|
+
returns = pd.to_numeric(result.returns.reindex(result.equity.index), errors="coerce").fillna(0.0)
|
|
321
|
+
peak = equity.cummax()
|
|
322
|
+
drawdown = (equity / peak.replace(0.0, np.nan) - 1.0).fillna(0.0)
|
|
323
|
+
return pd.DataFrame(
|
|
324
|
+
{
|
|
325
|
+
"timestamp": result.equity.index,
|
|
326
|
+
"equity": equity.to_numpy(dtype=float),
|
|
327
|
+
"returns": returns.to_numpy(dtype=float),
|
|
328
|
+
"drawdown": drawdown.to_numpy(dtype=float),
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _metrics_summary(result: BacktestResultV2, trade_log: pd.DataFrame, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
334
|
+
equity = pd.to_numeric(result.equity, errors="coerce").dropna()
|
|
335
|
+
final_equity = float(equity.iloc[-1]) if not equity.empty else float("nan")
|
|
336
|
+
total_return_pct = (final_equity / float(result.initial_capital) - 1.0) * 100.0 if result.initial_capital else float("nan")
|
|
337
|
+
drawdown = result.drawdown if len(result.equity) else pd.Series(dtype=float)
|
|
338
|
+
orders_report = _report_frame(metadata.get("orders_report", metadata.get("order_report")))
|
|
339
|
+
status_counts = _order_status_counts(orders_report)
|
|
340
|
+
return {
|
|
341
|
+
"initial_capital": float(result.initial_capital),
|
|
342
|
+
"final_equity": final_equity,
|
|
343
|
+
"total_return_pct": float(total_return_pct),
|
|
344
|
+
"max_drawdown_pct": float(drawdown.max() * 100.0) if not drawdown.empty else 0.0,
|
|
345
|
+
"input_mode": metadata.get("input_mode"),
|
|
346
|
+
"order_count_input": _safe_int(metadata.get("order_count_input")),
|
|
347
|
+
"orders_count": int(metadata.get("orders_count", 0) or 0),
|
|
348
|
+
"fills_count": int(metadata.get("fills_count", 0) or 0),
|
|
349
|
+
"positions_count": int(metadata.get("positions_count", 0) or 0),
|
|
350
|
+
"cancelled_count": status_counts["cancelled"],
|
|
351
|
+
"rejected_count": status_counts["rejected"],
|
|
352
|
+
"trade_log_count": int(len(trade_log)),
|
|
353
|
+
"liquidated": bool(result.liquidated),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _run_manifest(
|
|
358
|
+
result: BacktestResultV2,
|
|
359
|
+
strategy_id: str,
|
|
360
|
+
run_id: str,
|
|
361
|
+
metadata: Dict[str, Any],
|
|
362
|
+
config: Dict[str, Any],
|
|
363
|
+
account_report: pd.DataFrame,
|
|
364
|
+
orders_report: pd.DataFrame,
|
|
365
|
+
fills_report: pd.DataFrame,
|
|
366
|
+
positions_report: pd.DataFrame,
|
|
367
|
+
) -> Dict[str, Any]:
|
|
368
|
+
idx = result.equity.index
|
|
369
|
+
status_counts = _order_status_counts(orders_report)
|
|
370
|
+
return {
|
|
371
|
+
"strategy_id": strategy_id,
|
|
372
|
+
"run_id": run_id,
|
|
373
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
374
|
+
"backend": metadata.get("backend", "nautilus"),
|
|
375
|
+
"engine": metadata.get("engine", "NautilusTrader BacktestEngine"),
|
|
376
|
+
"execution_model": "event-driven bar execution",
|
|
377
|
+
"instrument_id": metadata.get("instrument_id") or _first_symbol(result),
|
|
378
|
+
"timeframe": metadata.get("timeframe") or _bar_timeframe(metadata.get("bar_type")),
|
|
379
|
+
"data_start": str(idx[0]) if len(idx) else None,
|
|
380
|
+
"data_end": str(idx[-1]) if len(idx) else None,
|
|
381
|
+
"bar_count": int(len(idx)),
|
|
382
|
+
"signal_count": metadata.get("signal_count"),
|
|
383
|
+
"signal_changes": metadata.get("signal_changes"),
|
|
384
|
+
"input_mode": metadata.get("input_mode"),
|
|
385
|
+
"order_count_input": _safe_int(metadata.get("order_count_input")),
|
|
386
|
+
"initial_capital": float(result.initial_capital),
|
|
387
|
+
"leverage": float(result.leverage),
|
|
388
|
+
"alloc_per_trade": metadata.get("trade_notional", metadata.get("alloc_per_trade")),
|
|
389
|
+
"sizing_mode": metadata.get("sizing_mode"),
|
|
390
|
+
"fee_rate": metadata.get("fee_rate"),
|
|
391
|
+
"use_funding": metadata.get("use_funding"),
|
|
392
|
+
"orders_count": int(metadata.get("orders_count", len(orders_report)) or 0),
|
|
393
|
+
"fills_count": int(metadata.get("fills_count", len(fills_report)) or 0),
|
|
394
|
+
"positions_count": int(metadata.get("positions_count", len(positions_report)) or 0),
|
|
395
|
+
"cancelled_count": status_counts["cancelled"],
|
|
396
|
+
"rejected_count": status_counts["rejected"],
|
|
397
|
+
"account_report_rows": int(len(account_report)),
|
|
398
|
+
"account_final_equity": _safe_float(metadata.get("account_final_equity")),
|
|
399
|
+
"reconstructed_final_equity": _safe_float(metadata.get("reconstructed_final_equity")),
|
|
400
|
+
"account_reconstructed_diff": _safe_float(metadata.get("account_reconstructed_diff")),
|
|
401
|
+
"quantbt_git_commit": _git_commit(),
|
|
402
|
+
"nautilus_version": _package_version("nautilus_trader"),
|
|
403
|
+
"python_version": platform.python_version(),
|
|
404
|
+
"data_hash": _hash_frame(result.closes),
|
|
405
|
+
"signal_hash": metadata.get("signal_hash"),
|
|
406
|
+
"config_keys": sorted(config.keys()),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _config_payload_from_result(result: BacktestResultV2, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
411
|
+
run_config = dict(metadata.get("run_config") or {})
|
|
412
|
+
account = dict(run_config.get("account") or {})
|
|
413
|
+
execution = dict(run_config.get("execution") or {})
|
|
414
|
+
fees = dict(run_config.get("fees") or {})
|
|
415
|
+
sizing = dict(run_config.get("sizing") or {})
|
|
416
|
+
funding = dict(run_config.get("funding") or {})
|
|
417
|
+
nautilus = dict(run_config.get("nautilus") or {})
|
|
418
|
+
|
|
419
|
+
requested_fee_rate = _safe_float(metadata.get("fee_rate"))
|
|
420
|
+
if requested_fee_rate is None:
|
|
421
|
+
requested_fee_rate = _safe_float(fees.get("one_way_fee_rate"))
|
|
422
|
+
requested_slippage = _safe_float(metadata.get("slippage"))
|
|
423
|
+
if requested_slippage is None:
|
|
424
|
+
requested_slippage = _safe_float(execution.get("legacy_slippage_rate"))
|
|
425
|
+
requested_slippage_bps = _safe_float(metadata.get("slippage_bps"))
|
|
426
|
+
if requested_slippage_bps is None:
|
|
427
|
+
requested_slippage_bps = _safe_float(execution.get("slippage_bps"))
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
"schema_version": 2,
|
|
431
|
+
"backend": metadata.get("backend", "nautilus"),
|
|
432
|
+
"engine": metadata.get("engine", "NautilusTrader BacktestEngine"),
|
|
433
|
+
"instrument": {
|
|
434
|
+
"instrument_id": metadata.get("instrument_id") or _first_symbol(result),
|
|
435
|
+
"bar_type": metadata.get("bar_type"),
|
|
436
|
+
"timeframe": metadata.get("timeframe") or _bar_timeframe(metadata.get("bar_type")),
|
|
437
|
+
},
|
|
438
|
+
"effective_account": {
|
|
439
|
+
"initial_capital": _safe_float(metadata.get("initial_capital")) or float(result.initial_capital),
|
|
440
|
+
"leverage": _safe_float(metadata.get("leverage")) or float(result.leverage),
|
|
441
|
+
"maintenance_ratio": _safe_float(metadata.get("maintenance_ratio")),
|
|
442
|
+
"margin_mode": account.get("margin_mode"),
|
|
443
|
+
"oms_mode": account.get("oms_mode"),
|
|
444
|
+
"base_currency": account.get("base_currency"),
|
|
445
|
+
},
|
|
446
|
+
"effective_sizing": {
|
|
447
|
+
"sizing_mode": metadata.get("sizing_mode") or sizing.get("hedge_type"),
|
|
448
|
+
"hedge_type": sizing.get("hedge_type"),
|
|
449
|
+
"trade_notional": metadata.get("trade_notional"),
|
|
450
|
+
"alloc_per_trade": metadata.get("alloc_per_trade", sizing.get("alloc_per_trade")),
|
|
451
|
+
"use_pyramiding": metadata.get("use_pyramiding", sizing.get("use_pyramiding")),
|
|
452
|
+
"contract_size": sizing.get("contract_size"),
|
|
453
|
+
"contract_size_note": "contract_size is a multiplier for notional/PnL, not the exchange lot size",
|
|
454
|
+
"quantity_constraints": {
|
|
455
|
+
"qty_step": metadata.get("qty_step"),
|
|
456
|
+
"lot_size": metadata.get("lot_size", metadata.get("qty_step")),
|
|
457
|
+
"min_qty": metadata.get("min_qty"),
|
|
458
|
+
"min_notional": metadata.get("min_notional"),
|
|
459
|
+
"price_increment": metadata.get("price_increment"),
|
|
460
|
+
"note": "Use qty_step/lot_size/min_qty/min_notional for Binance-style fractional order constraints.",
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
"effective_fees": {
|
|
464
|
+
"requested_fee_rate": requested_fee_rate,
|
|
465
|
+
"requested_fee_convention": "one_way",
|
|
466
|
+
"requested_fee_source": "endpoint.fee_rate",
|
|
467
|
+
"legacy_fee_round_trip_ignored": fees.get("round_trip_fee"),
|
|
468
|
+
"applied_by": "NautilusTrader MakerTakerFeeModel",
|
|
469
|
+
"custom_fee_rate_applied_to_nautilus": False,
|
|
470
|
+
},
|
|
471
|
+
"effective_execution": {
|
|
472
|
+
"requested_slippage_rate": requested_slippage,
|
|
473
|
+
"requested_slippage_bps": requested_slippage_bps,
|
|
474
|
+
"requested_slippage_source": "endpoint.slippage",
|
|
475
|
+
"applied_by": "NautilusTrader bar market execution",
|
|
476
|
+
"custom_slippage_applied_to_nautilus": False,
|
|
477
|
+
"fill_price_policy": execution.get("fill_price_policy"),
|
|
478
|
+
"same_bar_policy": execution.get("same_bar_policy"),
|
|
479
|
+
"allow_partial_fill": execution.get("allow_partial_fill"),
|
|
480
|
+
"reject_on_insufficient_margin": execution.get("reject_on_insufficient_margin"),
|
|
481
|
+
},
|
|
482
|
+
"funding": {
|
|
483
|
+
"use_funding": metadata.get("use_funding", funding.get("use_funding")),
|
|
484
|
+
"funding_rate": metadata.get("funding_rate", funding.get("funding_rate")),
|
|
485
|
+
},
|
|
486
|
+
"nautilus": nautilus,
|
|
487
|
+
"diagnostics": {
|
|
488
|
+
"orders_count": int(metadata.get("orders_count", 0) or 0),
|
|
489
|
+
"fills_count": int(metadata.get("fills_count", 0) or 0),
|
|
490
|
+
"positions_count": int(metadata.get("positions_count", 0) or 0),
|
|
491
|
+
},
|
|
492
|
+
"annotations": {},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _report_frame(value: Any) -> pd.DataFrame:
|
|
497
|
+
if isinstance(value, pd.DataFrame):
|
|
498
|
+
return value.copy()
|
|
499
|
+
if value is None:
|
|
500
|
+
return pd.DataFrame()
|
|
501
|
+
try:
|
|
502
|
+
return pd.DataFrame(value).copy()
|
|
503
|
+
except Exception:
|
|
504
|
+
return pd.DataFrame()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _order_status_counts(orders_report: pd.DataFrame) -> Dict[str, int]:
|
|
508
|
+
if orders_report.empty or "status" not in orders_report:
|
|
509
|
+
return {"cancelled": 0, "rejected": 0}
|
|
510
|
+
status = orders_report["status"].astype(str).str.upper()
|
|
511
|
+
return {
|
|
512
|
+
"cancelled": int(status.isin({"CANCELED", "CANCELLED"}).sum()),
|
|
513
|
+
"rejected": int(status.eq("REJECTED").sum()),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _safe_int(value: Any) -> Optional[int]:
|
|
518
|
+
try:
|
|
519
|
+
if value is None or pd.isna(value):
|
|
520
|
+
return None
|
|
521
|
+
return int(value)
|
|
522
|
+
except (TypeError, ValueError):
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _write_frame(frame: pd.DataFrame, path: Path) -> None:
|
|
527
|
+
frame.copy().to_csv(path, index=False)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _write_json(path: Path, payload: Dict[str, Any]) -> None:
|
|
531
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _run_id(strategy_id: str) -> str:
|
|
535
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
536
|
+
return f"{ts}_{_hash_text(strategy_id + ts)[:8]}"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _slug(value: str) -> str:
|
|
540
|
+
cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in str(value).strip())
|
|
541
|
+
return cleaned.strip("_") or "strategy"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _hash_text(value: str) -> str:
|
|
545
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _hash_frame(frame: pd.DataFrame) -> str:
|
|
549
|
+
try:
|
|
550
|
+
payload = {
|
|
551
|
+
"rows": int(len(frame)),
|
|
552
|
+
"columns": list(frame.columns),
|
|
553
|
+
"start": str(frame.index[0]) if len(frame) else None,
|
|
554
|
+
"end": str(frame.index[-1]) if len(frame) else None,
|
|
555
|
+
}
|
|
556
|
+
return _hash_text(json.dumps(payload, sort_keys=True, default=str))
|
|
557
|
+
except Exception:
|
|
558
|
+
return "unavailable"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _git_commit() -> str:
|
|
562
|
+
try:
|
|
563
|
+
root = Path(__file__).resolve().parents[1]
|
|
564
|
+
completed = subprocess.run(
|
|
565
|
+
["git", "rev-parse", "HEAD"],
|
|
566
|
+
cwd=str(root),
|
|
567
|
+
check=True,
|
|
568
|
+
capture_output=True,
|
|
569
|
+
text=True,
|
|
570
|
+
)
|
|
571
|
+
return completed.stdout.strip()
|
|
572
|
+
except Exception:
|
|
573
|
+
return "unavailable"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _package_version(package: str) -> str:
|
|
577
|
+
try:
|
|
578
|
+
import importlib.metadata as metadata
|
|
579
|
+
|
|
580
|
+
return metadata.version(package)
|
|
581
|
+
except Exception:
|
|
582
|
+
return "unavailable"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _first_symbol(result: BacktestResultV2) -> Optional[str]:
|
|
586
|
+
return result.symbols[0] if result.symbols else None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _bar_timeframe(bar_type: Any) -> Optional[str]:
|
|
590
|
+
if not bar_type:
|
|
591
|
+
return None
|
|
592
|
+
parts = str(bar_type).split("-")
|
|
593
|
+
if len(parts) >= 4:
|
|
594
|
+
return "-".join(parts[-4:-2])
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _instrument_parts(instrument_id: str) -> Tuple[Optional[str], Optional[str]]:
|
|
599
|
+
if not instrument_id:
|
|
600
|
+
return None, None
|
|
601
|
+
if "." in instrument_id:
|
|
602
|
+
main, exchange = instrument_id.split(".", 1)
|
|
603
|
+
else:
|
|
604
|
+
main, exchange = instrument_id, None
|
|
605
|
+
symbol = main.split("-")[0] if "-" in main else main
|
|
606
|
+
return symbol, exchange
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _position_type(row: pd.Series) -> Optional[str]:
|
|
610
|
+
entry = str(row.get("entry", row.get("side", ""))).upper()
|
|
611
|
+
if entry in {"BUY", "LONG"}:
|
|
612
|
+
return "LONG"
|
|
613
|
+
if entry in {"SELL", "SHORT"}:
|
|
614
|
+
return "SHORT"
|
|
615
|
+
side = str(row.get("position_side", "")).upper()
|
|
616
|
+
if side in {"LONG", "SHORT"}:
|
|
617
|
+
return side
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _coerce_timestamp_scalar(value: Any) -> pd.Timestamp:
|
|
622
|
+
return pd.to_datetime(value, utc=True, errors="coerce")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _coerce_float(value: Any) -> float:
|
|
626
|
+
try:
|
|
627
|
+
return float(value)
|
|
628
|
+
except Exception:
|
|
629
|
+
return _coerce_money(value)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _safe_float(value: Any) -> Optional[float]:
|
|
633
|
+
try:
|
|
634
|
+
if value is None:
|
|
635
|
+
return None
|
|
636
|
+
out = float(value)
|
|
637
|
+
if np.isnan(out):
|
|
638
|
+
return None
|
|
639
|
+
return out
|
|
640
|
+
except Exception:
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _coerce_money(value: Any) -> float:
|
|
645
|
+
if value is None:
|
|
646
|
+
return 0.0
|
|
647
|
+
try:
|
|
648
|
+
return float(value)
|
|
649
|
+
except Exception:
|
|
650
|
+
text = str(value)
|
|
651
|
+
if text.startswith("[") and text.endswith("]"):
|
|
652
|
+
text = text.strip("[]").strip().strip("'").replace("'", "")
|
|
653
|
+
for token in text.replace(",", "").split():
|
|
654
|
+
try:
|
|
655
|
+
return float(token)
|
|
656
|
+
except Exception:
|
|
657
|
+
continue
|
|
658
|
+
return 0.0
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _duration_seconds(open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> Optional[float]:
|
|
662
|
+
if pd.isna(open_dt) or pd.isna(close_dt):
|
|
663
|
+
return None
|
|
664
|
+
return float((close_dt - open_dt).total_seconds())
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _position_return_pct(side: Optional[str], entry_price: float, exit_price: float) -> Optional[float]:
|
|
668
|
+
if not np.isfinite(entry_price) or not np.isfinite(exit_price) or entry_price == 0.0:
|
|
669
|
+
return None
|
|
670
|
+
raw = (exit_price / entry_price - 1.0) * 100.0
|
|
671
|
+
return float(raw if side != "SHORT" else -raw)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _fees_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> float:
|
|
675
|
+
subset = _fills_for_position(fills, instrument_id, open_dt, close_dt)
|
|
676
|
+
if subset.empty:
|
|
677
|
+
return 0.0
|
|
678
|
+
fee_cols = [col for col in ("commissions", "commission", "fee") if col in subset.columns]
|
|
679
|
+
if not fee_cols:
|
|
680
|
+
return 0.0
|
|
681
|
+
return float(subset[fee_cols[0]].apply(_coerce_money).sum())
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _order_ids_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> str:
|
|
685
|
+
subset = _fills_for_position(fills, instrument_id, open_dt, close_dt)
|
|
686
|
+
for col in ("client_order_id", "order_id", "venue_order_id"):
|
|
687
|
+
if col in subset.columns:
|
|
688
|
+
return ",".join(str(x) for x in subset[col].dropna().unique())
|
|
689
|
+
return ""
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _fills_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> pd.DataFrame:
|
|
693
|
+
if fills.empty or pd.isna(open_dt) or pd.isna(close_dt):
|
|
694
|
+
return pd.DataFrame()
|
|
695
|
+
out = fills.copy()
|
|
696
|
+
if "instrument_id" in out.columns:
|
|
697
|
+
out = out[out["instrument_id"].astype(str) == instrument_id]
|
|
698
|
+
ts_col = _timestamp_column(out)
|
|
699
|
+
if ts_col is None:
|
|
700
|
+
return pd.DataFrame()
|
|
701
|
+
out["_ts"] = pd.to_datetime(out[ts_col], utc=True, errors="coerce")
|
|
702
|
+
return out[(out["_ts"] >= open_dt) & (out["_ts"] <= close_dt)]
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _timestamp_column(frame: pd.DataFrame) -> Optional[str]:
|
|
706
|
+
for col in ("ts_last", "ts_event", "ts_init", "timestamp", "time"):
|
|
707
|
+
if col in frame.columns:
|
|
708
|
+
return col
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _event_timestamp(row: pd.Series) -> str:
|
|
713
|
+
for col in ("ts_last", "ts_event", "ts_init", "timestamp", "time"):
|
|
714
|
+
if col in row.index:
|
|
715
|
+
ts = _coerce_timestamp_scalar(row.get(col))
|
|
716
|
+
if not pd.isna(ts):
|
|
717
|
+
return str(ts)
|
|
718
|
+
return "NaT"
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _event_side(row: pd.Series) -> str:
|
|
722
|
+
for col in ("side", "order_side", "entry"):
|
|
723
|
+
if col in row.index and str(row.get(col, "")).strip():
|
|
724
|
+
return str(row.get(col)).upper()
|
|
725
|
+
return "UNKNOWN"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _event_qty(row: pd.Series) -> float:
|
|
729
|
+
for col in ("filled_qty", "last_qty", "quantity", "qty"):
|
|
730
|
+
if col in row.index:
|
|
731
|
+
return abs(_coerce_float(row.get(col)))
|
|
732
|
+
return 0.0
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _event_price(row: pd.Series) -> float:
|
|
736
|
+
for col in ("avg_px", "last_px", "price", "avg_px_open"):
|
|
737
|
+
if col in row.index:
|
|
738
|
+
return _coerce_float(row.get(col))
|
|
739
|
+
return 0.0
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _event_fee(row: pd.Series) -> float:
|
|
743
|
+
for col in ("commissions", "commission", "fee"):
|
|
744
|
+
if col in row.index:
|
|
745
|
+
return _coerce_money(row.get(col))
|
|
746
|
+
return 0.0
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _position_change_lines(positions: Optional[pd.DataFrame], limit: int) -> List[str]:
|
|
750
|
+
if positions is None or positions.empty:
|
|
751
|
+
return []
|
|
752
|
+
pos = positions.copy().fillna(0.0)
|
|
753
|
+
changed = pos.diff().abs().sum(axis=1).fillna(pos.abs().sum(axis=1)) > 0.0
|
|
754
|
+
lines: List[str] = []
|
|
755
|
+
for ts, row in pos.loc[changed].head(limit).iterrows():
|
|
756
|
+
state = ", ".join(f"{col}={float(value):g}" for col, value in row.items())
|
|
757
|
+
lines.append(f"{pd.Timestamp(ts)} POSITION {state}")
|
|
758
|
+
return lines
|