Skip to content
Merged

Dev #15

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
benchmark_walkforward_kernels,
logging_callback,
score_strategy_output,
select_is_plateau_robust_record,
select_flat_minima_record,
stationary_bootstrap_sharpes,
synthetic_walkforward_sharpes,
Expand Down
17 changes: 9 additions & 8 deletions core/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,31 +63,32 @@ def daily_equity(self) -> pd.Series:
def daily_returns(self) -> pd.Series:
return self.daily_equity.pct_change().dropna()

def full_report(self, trading_days: int = 365) -> Dict:
def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict:
"""Return the standard QuantBT metrics dictionary for this result."""
from .scopes import scoped_result
from ..metrics.performance import full_report

return full_report(self, trading_days=trading_days)
return full_report(scoped_result(self, scope=scope), trading_days=trading_days)

def show_metrics(self, trading_days: int = 365) -> Dict:
def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict:
"""Print a legacy-style metrics report and return the metrics dict."""
from ..endpoint import format_metrics_report

report = self.full_report(trading_days=trading_days)
report = self.full_report(trading_days=trading_days, scope=scope)
print(format_metrics_report(report))
return report

def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6)):
def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = "auto"):
"""Plot cumulative return and drawdown for this result."""
from ..viz import quick_plot

return quick_plot(self, theme=theme, figsize=figsize)
return quick_plot(self, theme=theme, figsize=figsize, scope=scope)

def tearsheet(self, theme: str = "dark", benchmark=None):
def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"):
"""Render the QuantBT tearsheet for this result."""
from ..viz import tearsheet

return tearsheet(self, theme=theme, benchmark=benchmark)
return tearsheet(self, theme=theme, benchmark=benchmark, scope=scope)

@classmethod
def from_legacy(cls, result: BacktestResult) -> "BacktestResultV2":
Expand Down
96 changes: 96 additions & 0 deletions core/scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
Reporting scope helpers.

Walk-forward and train/test split runs store a full stitched timeline, but the
natural performance report is the OOS/test portion only. These helpers keep
endpoint-level and result-level metrics/plots consistent.
"""

from __future__ import annotations

import pandas as pd

from .results import BacktestResultV2
from .types import BacktestResult


def scoped_result(result, scope: str = "auto"):
"""
Return `result` or an OOS/test-sliced copy for reporting.

`auto` means OOS/test for walk-forward artifacts and full result for normal
backtests. Use `full` to audit the complete stitched timeline.
"""
normalized = str(scope or "auto").lower().strip()
if normalized == "auto":
normalized = "oos" if "walk_forward" in result.metadata else "full"
if normalized == "full":
return result
if normalized in {"test", "oos"}:
return _slice_result_to_walk_forward_oos(result, scope=normalized)
raise ValueError("scope must be auto, full, test, or oos")


def _slice_result_to_walk_forward_oos(result, scope: str):
wf_meta = result.metadata.get("walk_forward")
if not wf_meta:
raise ValueError(f"scope={scope!r} is only available for walk_forward/train_test_split results")
fold_table = wf_meta.get("fold_table")
if fold_table is None or len(fold_table) == 0:
raise ValueError("walk-forward result does not contain a fold_table")

idx = result.equity.index
mask = pd.Series(False, index=idx)
for _, row in fold_table.iterrows():
start = pd.Timestamp(row["test_start"])
end = pd.Timestamp(row["test_end"])
mask |= (idx >= start) & (idx <= end)
if not bool(mask.any()):
raise ValueError("walk-forward OOS/test scope contains no bars in result index")

sliced_metadata = dict(result.metadata)
sliced_wf_meta = dict(wf_meta)
sliced_wf_meta["report_scope"] = scope
sliced_metadata["walk_forward"] = sliced_wf_meta

if isinstance(result, BacktestResultV2):
return BacktestResultV2(
equity=result.equity.loc[mask].copy(),
returns=result.returns.loc[mask].copy(),
positions=result.positions.loc[mask].copy(),
closes=result.closes.loc[mask].copy(),
symbols=list(result.symbols),
initial_capital=float(result.initial_capital),
leverage=float(result.leverage),
liquidated=bool(result.liquidated),
liquidation_bar=int(result.liquidation_bar),
orders=getattr(result, "orders", ()),
fills=getattr(result, "fills", ()),
trades=getattr(result, "trades", ()),
fees=_slice_indexed_like(result.fees, mask),
funding=_slice_indexed_like(result.funding, mask),
margin=_slice_indexed_like(result.margin, mask),
diagnostics=_slice_indexed_like(result.diagnostics, mask),
metadata=sliced_metadata,
)

return BacktestResult(
equity=result.equity.loc[mask].copy(),
returns=result.returns.loc[mask].copy(),
positions=result.positions.loc[mask].copy(),
closes=result.closes.loc[mask].copy(),
symbols=list(result.symbols),
initial_capital=float(result.initial_capital),
leverage=float(result.leverage),
liquidated=bool(result.liquidated),
liquidation_bar=int(result.liquidation_bar),
metadata=sliced_metadata,
)


def _slice_indexed_like(obj, mask: pd.Series):
if obj is None:
return obj
if isinstance(obj, (pd.Series, pd.DataFrame)) and obj.index.equals(mask.index):
return obj.loc[mask].copy()
return obj
17 changes: 9 additions & 8 deletions core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,28 +59,29 @@ def daily_equity(self) -> pd.Series:
def daily_returns(self) -> pd.Series:
return self.daily_equity.pct_change().dropna()

def full_report(self, trading_days: int = 365) -> Dict:
def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict:
"""Return the standard QuantBT metrics dictionary for this result."""
from .scopes import scoped_result
from ..metrics.performance import full_report

return full_report(self, trading_days=trading_days)
return full_report(scoped_result(self, scope=scope), trading_days=trading_days)

def show_metrics(self, trading_days: int = 365) -> Dict:
def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict:
"""Print a legacy-style metrics report and return the metrics dict."""
from ..endpoint import format_metrics_report

report = self.full_report(trading_days=trading_days)
report = self.full_report(trading_days=trading_days, scope=scope)
print(format_metrics_report(report))
return report

def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6)):
def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = "auto"):
"""Plot cumulative return and drawdown for this result."""
from ..viz import quick_plot

return quick_plot(self, theme=theme, figsize=figsize)
return quick_plot(self, theme=theme, figsize=figsize, scope=scope)

def tearsheet(self, theme: str = "dark", benchmark=None):
def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"):
"""Render the QuantBT tearsheet for this result."""
from ..viz import tearsheet

return tearsheet(self, theme=theme, benchmark=benchmark)
return tearsheet(self, theme=theme, benchmark=benchmark, scope=scope)
52 changes: 40 additions & 12 deletions docs/endpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,11 @@ All time indexes are normalized to UTC and aligned to the run index.
result = bt.backtest(...)
result = bt.simulate(...)

rpt = bt.full_report(trading_days=365)
rpt = bt.show_metrics(trading_days=365)
rpt = bt.full_report(trading_days=365, scope="auto")
rpt = bt.show_metrics(trading_days=365, scope="auto")

bt.quick_plot(theme="dark", figsize=(14, 6))
bt.tearsheet(theme="dark")
bt.quick_plot(theme="dark", figsize=(14, 6), scope="auto")
bt.tearsheet(theme="dark", scope="auto")

bt.latest_orders
bt.fills
Expand All @@ -193,6 +193,12 @@ bt.export_fills("fills.csv")
`show_metrics()` prints a stable legacy-style text report and returns the same
dictionary as `full_report()`:

`scope="auto"` reports the natural tested window for endpoint and result helper
methods. Normal endpoints use the full result; `walk_forward()` and
`train_test_split()` report/plot OOS-test bars only so CAGR, Sharpe, Sortino,
and Calmar are annualized on the period actually traded. Pass `scope="full"` to
audit the complete stitched timeline with flat train bars included.

```text
Initial Capital $ 20,000
Final Equity $ 106,884.96
Expand All @@ -215,9 +221,9 @@ dictionary as `full_report()`:
The latest result object also exposes the same convenience methods:

```python
result.full_report()
result.show_metrics()
result.quick_plot()
result.full_report(scope="auto")
result.show_metrics(scope="auto")
result.quick_plot(scope="auto")
result.tearsheet()
```

Expand Down Expand Up @@ -784,8 +790,12 @@ Requirements:
- `export_nautilus_report_bundle(...)` writes raw Nautilus account/order/fill/
position reports, normalized trade logs, a run manifest, equity/returns CSVs,
`config.json`, and optional QuantStats daily HTML. `config.json` is filled
from endpoint/result metadata even when no explicit `config=` is passed.
QuantStats uses daily equity returns by default with
from endpoint/result metadata even when no explicit `config=` is passed. The
report config uses grouped fields such as `effective_account`,
`effective_sizing`, `effective_fees`, and `effective_execution` so there is
one clear effective view; extra `config={...}` values are saved under
`annotations` and do not override execution metadata. QuantStats uses daily
equity returns by default with
`quantstats_periods_per_year=365` for crypto.

Nautilus metadata:
Expand Down Expand Up @@ -850,6 +860,13 @@ wfo = QuantBTEndpoint.walk_forward(
"garch_vol_multiplier": 1.0,
# mode_3_flat_minima: "flat_top_fraction", "flat_eps",
# "flat_min_samples", "flat_selector"
# train-only selector for strict train/test split:
# "candidate_selection_metric": "is_plateau_robust",
# "scoring_backend": "endpoint", # endpoint | proxy
# "plateau_quantile": 0.25,
# "plateau_median_weight": 0.25,
# "plateau_std_penalty": 0.50,
# "plateau_size_bonus": 0.01,
# crypto default annualization: 365; equities often use 252
"scoring_trading_days": 365,
# optional under-trading penalty; None disables it
Expand Down Expand Up @@ -964,9 +981,20 @@ Important rules:
- for all optimization modes, Optuna receives only in-sample or synthetic
in-sample objectives; OOS scoring is delayed until after the top IS candidate
set is frozen, reducing indirect look-ahead bias;
- candidate selection is controlled by `top_is_fraction` or `top_is_k`; OOS
candidate ranking uses `candidate_selection_metric`, defaulting to
`robust_decay`;
- candidate selection is controlled by `top_is_fraction` or `top_is_k`;
`candidate_selection_metric` defaults to `robust_decay`. For strict
train/test split validation, use `candidate_selection_metric="is_plateau_robust"`
to select final params from the dense train-only plateau; OOS is then used
only for final reporting/audit, not for parameter selection;
- endpoint-created WFO for single-symbol routes defaults to
`scoring_backend="endpoint"` for `mode_1_decay` and `mode_3_flat_minima`.
This means Optuna scores the train fold with the same selected QuantBT route
(`pct_equity`, `signal_notional`, or `dca_ladder`) instead of the fast proxy.
Set `scoring_backend="proxy"` when you explicitly want approximate scoring;
`mode_2_sbb` always uses proxy return paths because it needs synthetic
bootstrap/GARCH simulations. Proxy scoring uses the signal exactly as emitted
by the strategy adapter; execution lag, if desired, belongs in the strategy
layer;
- optimization-time scoring uses a transparent return proxy on strategy output;
final accounting still comes from the stitched QuantBT backtest;
- optimization Sharpe annualization uses `scoring_trading_days` from
Expand Down
6 changes: 4 additions & 2 deletions docs/nautilus_backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ Report bundle:
`fills_report.csv`, `positions_report.csv`, normalized `trade_log.csv`,
`fill_log.txt`, `run_manifest.json`, `metrics_summary.json`,
`config.json`, equity/returns CSVs, and optional `quantstats_daily.html`.
`config.json` is auto-filled from endpoint/result run metadata, including
capital, leverage, fees, slippage, sizing, funding, instrument, and timeframe.
`config.json` is auto-filled from endpoint/result run metadata using grouped
`effective_*` sections for capital, leverage, fees, slippage, sizing,
funding, instrument, and timeframe. Extra `config={...}` values are saved
under `annotations`, not mixed into the effective execution settings.
- QuantStats input is daily-resampled from the equity curve by default, which
is safer for multi-year intraday runs than treating raw 15m returns as daily
returns. The default annualization is `quantstats_periods_per_year=365` for
Expand Down
Loading
Loading