Files
Auto-Trader/agent/backtest/engines/base.py
T
santhreal d88a591272 fix(backtest): print scalar metrics as strict JSON
Explosive equity paths can set annual_return to +inf. The engine stdout
dump used default allow_nan=True and emitted bare Infinity tokens.
Sanitize non-finite scalars to null and dump with allow_nan=False,
matching options and run_card.

Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
2026-07-21 21:16:54 -07:00

1122 lines
43 KiB
Python

"""Base backtest engine with shared bar-by-bar execution loop.
All market engines inherit from BaseEngine and override market-rule methods.
The shared run_backtest() handles: data loading → signal generation →
pre-compute target weights (with optimizer) → bar-by-bar execution with
market rule enforcement → metrics → artifacts.
"""
from __future__ import annotations
import importlib
import json
import logging
import math
import re as _re
import sys
from abc import ABC, abstractmethod
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import numpy as np
import pandas as pd
from backtest.loaders.rsshub_events import (
FeedSpec,
RSSHubEventProvider,
enrich_price_frames_with_events,
feed_specs_from_config,
)
from backtest.loaders.tushare_fundamentals import (
TushareFundamentalProvider,
enrich_price_frames_with_fundamentals,
)
from backtest.metrics import (
by_exit_reason_stats,
by_symbol_stats,
calc_metrics,
calc_trade_turnover_series,
)
def _json_safe_scalar_metrics(metrics: Dict[str, Any]) -> Dict[str, Any]:
"""Scalar metrics for stdout JSON; non-finite floats become null."""
return {
k: (None if isinstance(v, float) and not math.isfinite(v) else v)
for k, v in metrics.items()
if not isinstance(v, dict)
}
from backtest.models import EquitySnapshot, Position, TradeRecord
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _OpenOrder:
"""A fully priced opening order that can be committed atomically."""
symbol: str
direction: int
price: float
size: float
leverage: float
margin: float
commission: float
@property
def cost(self) -> float:
"""Cash consumed by the fill."""
return self.margin + self.commission
def _run_card_data_sources(config: Dict[str, Any], loader: Any) -> List[str]:
"""Return source names for run-card evidence."""
configured = config.get("_run_card_effective_sources")
if isinstance(configured, list):
return [str(source) for source in configured if str(source).strip()]
if isinstance(configured, str) and configured.strip():
return [configured.strip()]
loader_name = getattr(loader, "name", None)
if loader_name:
return [str(loader_name)]
source = config.get("source")
return [str(source)] if source else []
# ─── Market detection (lightweight, for signal alignment only) ───
_CRYPTO_RE = _re.compile(r"^[A-Z]+-USDT$|^[A-Z]+/USDT$", _re.I)
_FOREX_RE = _re.compile(r"^[A-Z]{3}/[A-Z]{3}$|^[A-Z]{6}\.FX$")
def _detect_market_for_align(code: str) -> str:
"""Lightweight market detection for ffill_limit calculation."""
if _CRYPTO_RE.match(code):
return "crypto"
if _FOREX_RE.match(code):
return "forex"
return "equity"
# ─── Forward-fill helpers (numpy, avoid pandas overhead) ───
def _ffill_1d(col: np.ndarray, limit: int) -> None:
"""In-place forward-fill a 1D array with limit."""
count = 0
last_valid = np.nan
for i in range(len(col)):
if np.isnan(col[i]):
count += 1
if count <= limit and not np.isnan(last_valid):
col[i] = last_valid
else:
last_valid = col[i]
count = 0
def _ffill_2d(arr: np.ndarray, limit: int = 5) -> np.ndarray:
"""Forward-fill NaN values column-wise with limit."""
out = arr.copy()
for col in range(out.shape[1]):
_ffill_1d(out[:, col], limit)
return out
# ─── Signal alignment (reused from daily_portfolio logic) ───
def _align(
data_map: Dict[str, pd.DataFrame],
signal_map: Dict[str, pd.Series],
codes: List[str],
optimizer: Optional[Callable] = None,
) -> tuple:
"""Build aligned date index, close matrix, target-position matrix, return matrix.
Signal is shifted by 1 bar (next-bar-open semantics) then normalised so
``sum(abs(weights)) <= 1.0``.
Args:
data_map: code -> OHLCV DataFrame.
signal_map: code -> signal Series.
codes: Valid instrument codes.
optimizer: Optional weight optimiser ``(ret, pos, dates) -> pos``.
Returns:
(dates, close_df, positions_df, returns_df)
"""
# Build unified sorted date index from all symbols' trading calendars
all_idx_arrays = [data_map[c].index.values for c in codes]
merged = np.unique(np.concatenate(all_idx_arrays))
dates = pd.DatetimeIndex(merged)
n_dates = len(dates)
n_codes = len(codes)
# Use int64 view for O(log n) searchsorted lookups
dates_i8 = dates.values.view("i8")
# ffill with limit to avoid masking long suspensions (e.g. 3-week halt)
# Cross-market needs larger limit (Chinese New Year can be 9-10 bars)
ffill_limit = 10 if len({_detect_market_for_align(c) for c in codes}) > 1 else 5
# Build close matrix via numpy direct fill + searchsorted index mapping
close_arr = np.full((n_dates, n_codes), np.nan)
for j, c in enumerate(codes):
series = data_map[c]["close"]
row_idx = np.searchsorted(dates_i8, series.index.values.view("i8"))
close_arr[row_idx, j] = series.values
# Vectorized ffill with limit using pandas (C-optimized internals)
_tmp = pd.DataFrame(close_arr)
close_arr = _tmp.ffill(limit=ffill_limit).values
# Drop symbols that are entirely NaN (no data overlap with date range)
all_nan_mask = np.all(np.isnan(close_arr), axis=0)
all_nan_cols = [codes[j] for j in range(n_codes) if all_nan_mask[j]]
if all_nan_cols:
logger.warning("Symbols dropped (no usable price data): %s", all_nan_cols)
keep_mask = ~all_nan_mask
codes = [codes[j] for j in range(n_codes) if keep_mask[j]]
if not codes:
raise ValueError("All symbols have no data in the requested date range")
close_arr = close_arr[:, keep_mask]
n_codes = len(codes)
# Build position matrix: shift on each symbol's OWN calendar, then fill
pos_arr = np.full((n_dates, n_codes), np.nan)
for j, c in enumerate(codes):
# Get signal values aligned to own trading calendar.
# copy=True guarantees a writable array: with copy=False, an already
# float64 source returns a read-only view (e.g. pandas copy-on-write),
# which the in-place nan_to_num/clip below would reject.
own_idx = data_map[c].index
sig_vals = signal_map[c].reindex(own_idx).values.astype(np.float64, copy=True)
# fillna(0) + clip in numpy
np.nan_to_num(sig_vals, copy=False, nan=0.0)
np.clip(sig_vals, -1.0, 1.0, out=sig_vals)
# shift(1) + fillna(0): prepend 0, drop last
shifted_vals = np.empty_like(sig_vals)
shifted_vals[0] = 0.0
shifted_vals[1:] = sig_vals[:-1]
# Place into unified grid via searchsorted
row_idx = np.searchsorted(dates_i8, own_idx.values.view("i8"))
pos_arr[row_idx, j] = shifted_vals
# Vectorized ffill with limit using pandas (C-optimized)
_tmp = pd.DataFrame(pos_arr)
# ``.values`` may be a read-only view under pandas copy-on-write; take the
# copy-returning nan_to_num (not in-place) so the fill never writes a
# read-only destination.
pos_arr = np.nan_to_num(_tmp.ffill(limit=ffill_limit).values, nan=0.0)
# Construct DataFrames for return
close = pd.DataFrame(close_arr, index=dates, columns=codes)
pos = pd.DataFrame(pos_arr, index=dates, columns=codes)
ret = close.pct_change().fillna(0.0)
if optimizer is not None:
pos = optimizer(ret, pos, dates)
scale = pos.abs().sum(axis=1).clip(lower=1.0)
pos = pos.div(scale, axis=0)
return dates, close, pos, ret
def _load_optimizer(config: Dict[str, Any]) -> Optional[Callable]:
"""Dynamically load an optimizer function from config.
Args:
config: Backtest configuration.
Returns:
Optimizer callable, or None.
"""
opt_name = config.get("optimizer")
if not opt_name:
return None
opt_params = config.get("optimizer_params") or {}
try:
mod = importlib.import_module(f"backtest.optimizers.{opt_name}")
return lambda ret, pos, dates: mod.optimize(ret, pos, dates, **opt_params)
except (ImportError, AttributeError) as e:
print(f"[WARN] Failed to load optimizer '{opt_name}': {e}, falling back to equal weight")
return None
def _normalise_fundamental_fields(config: Dict[str, Any]) -> dict[str, list[str]]:
"""Read the optional statement-table field map from backtest config."""
raw_fields = config.get("fundamental_fields")
if raw_fields in (None, {}):
return {}
if not isinstance(raw_fields, dict):
raise ValueError("fundamental_fields must map table names to field-name lists")
normalized: dict[str, list[str]] = {}
for table, fields in raw_fields.items():
if not isinstance(table, str) or not table.strip():
raise ValueError("fundamental_fields table names must be non-empty strings")
if fields is None:
continue
if isinstance(fields, str) or not isinstance(fields, Iterable):
raise ValueError(f"fundamental_fields[{table!r}] must be a list of field names")
field_list = list(fields)
if not field_list:
continue
invalid = [field for field in field_list if not isinstance(field, str) or not field.strip()]
if invalid:
raise ValueError(f"fundamental_fields[{table!r}] contains invalid field names")
normalized[table.strip()] = field_list
return normalized
def _maybe_enrich_fundamentals(
data_map: Dict[str, pd.DataFrame],
config: Dict[str, Any],
) -> Dict[str, pd.DataFrame]:
"""Attach configured Tushare statement fields before signal generation."""
fields_by_table = _normalise_fundamental_fields(config)
if not fields_by_table:
return data_map
try:
provider = TushareFundamentalProvider()
return enrich_price_frames_with_fundamentals(
data_map,
provider,
fields_by_table,
as_of=config.get("end_date", ""),
periods=config.get("fundamental_periods"),
)
except Exception as exc:
raise RuntimeError(
f"fundamental_fields requested but Tushare enrichment failed: {exc}"
) from exc
def _event_feed_specs(config: Dict[str, Any]) -> List[FeedSpec]:
"""Parse the optional ``event_feeds`` feed definitions from backtest config.
``event_feeds`` is a list of feed-definition dicts (there is no built-in
catalogue) — each with ``name``/``route_template``/``event_type`` and an
optional ``code_style``. An empty/absent value means "no event enrichment".
"""
raw_feeds = config.get("event_feeds")
if raw_feeds in (None, [], {}):
return []
if not isinstance(raw_feeds, (list, tuple)):
raise ValueError("event_feeds must be a list of feed definitions")
return feed_specs_from_config(raw_feeds)
def _maybe_enrich_events(
data_map: Dict[str, pd.DataFrame],
config: Dict[str, Any],
) -> Dict[str, pd.DataFrame]:
"""Attach a point-in-time-safe ``event_score`` column before signal generation."""
specs = _event_feed_specs(config)
if not specs:
return data_map
try:
provider = RSSHubEventProvider(feeds=specs)
if not provider.is_available():
raise RuntimeError(f"RSSHub base URL not configured (set ${'RSSHUB_BASE_URL'})")
return enrich_price_frames_with_events(
data_map,
provider,
as_of=config.get("end_date", ""),
decay_lambda=float(config.get("event_decay_lambda", 0.1)),
lookback=int(config.get("event_lookback", 30)),
)
except Exception as exc:
raise RuntimeError(
f"event_feeds requested but RSSHub enrichment failed: {exc}"
) from exc
# ─── Base Engine ───
class BaseEngine(ABC):
"""Abstract base for all market engines.
Subclasses override market-rule methods:
- can_execute: whether a trade is allowed by market rules
- round_size: lot-size rounding
- calc_commission: fee structure
- apply_slippage: slippage model
- on_bar: per-bar hooks (funding fees, liquidation, etc.)
"""
def __init__(self, config: dict):
self.config = config
self.initial_capital: float = config.get("initial_cash", 1_000_000)
self.default_leverage: float = config.get("leverage", 1.0)
self.capital: float = self.initial_capital
self.positions: Dict[str, Position] = {}
self.trades: List[TradeRecord] = []
self.equity_snapshots: List[EquitySnapshot] = []
self._bar_idx: int = 0
self._active_symbol: str = "" # set by _rebalance/_close_position for subclass use
# ── Market rule interface (subclass must implement) ──
@abstractmethod
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""Whether market rules allow this trade.
Args:
symbol: Instrument identifier.
direction: 1 (long), -1 (short), 0 (close).
bar: Current bar data (OHLCV + extras).
Returns:
True if allowed.
"""
@abstractmethod
def round_size(self, raw_size: float, price: float) -> float:
"""Round position size per market lot rules.
Args:
raw_size: Desired size.
price: Current price.
Returns:
Rounded size.
"""
@abstractmethod
def calc_commission(self, size: float, price: float, direction: int, is_open: bool) -> float:
"""Calculate commission for a trade.
Args:
size: Trade size.
price: Execution price.
direction: 1 or -1.
is_open: True for opening, False for closing.
Returns:
Commission amount.
"""
@abstractmethod
def apply_slippage(self, price: float, direction: int) -> float:
"""Apply slippage to execution price.
Args:
price: Raw price.
direction: 1 (buying / covering short) or -1 (selling / shorting).
Returns:
Slipped price.
"""
def on_bar(self, symbol: str, bar: pd.Series, timestamp: pd.Timestamp) -> None:
"""Per-bar market-rule hook (funding fees, liquidation, etc.).
Default: no-op. Override in subclass as needed.
"""
# ── PnL / margin calculation hooks ──
# Override in FuturesBaseEngine to inject contract multiplier.
def _calc_pnl(
self, symbol: str, direction: int, size: float,
entry_price: float, exit_price: float,
) -> float:
"""Realised PnL for a closed position."""
return direction * size * (exit_price - entry_price)
def _calc_margin(
self, symbol: str, size: float, price: float, leverage: float,
) -> float:
"""Margin (collateral) required for a position."""
return size * price / leverage
def _calc_raw_size(
self, symbol: str, target_notional: float, price: float,
) -> float:
"""Convert target notional exposure to number of units/contracts."""
return target_notional / price
def _leverage_for_symbol(self, symbol: str) -> float:
"""Return leverage used to size and margin one symbol."""
del symbol
return self.default_leverage
# ── Main entry ──
def run_backtest(
self,
config: Dict[str, Any],
loader: Any,
signal_engine: Any,
run_dir: Path,
bars_per_year: int = 252,
) -> Dict[str, Any]:
"""Full backtest pipeline.
Signature matches ``daily_portfolio.run_backtest`` for drop-in replacement.
Args:
config: Backtest configuration dict.
loader: DataLoader with ``fetch()`` method.
signal_engine: SignalEngine with ``generate()`` method.
run_dir: Artifacts output directory.
bars_per_year: Annualisation factor.
Returns:
Metrics dictionary.
"""
codes = config.get("codes", [])
interval = config.get("interval", "1D")
extra_fields = config.get("extra_fields") or None
# 1. Load data
data_map = loader.fetch(
codes,
config.get("start_date", ""),
config.get("end_date", ""),
fields=extra_fields,
interval=interval,
)
if not data_map:
print(json.dumps({"error": "No data fetched"}))
sys.exit(1)
data_map = _maybe_enrich_fundamentals(data_map, config)
data_map = _maybe_enrich_events(data_map, config)
# 2. Generate signals
signal_map = signal_engine.generate(data_map)
if not isinstance(signal_map, dict):
print(json.dumps({"error": (
f"SignalEngine.generate() must return Dict[str, pd.Series], "
f"got {type(signal_map).__name__}. "
"Return a dict mapping symbol codes to pandas Series of signals."
)}))
sys.exit(1)
for _code, _sig in signal_map.items():
if not isinstance(_sig, pd.Series):
print(json.dumps({"error": (
f"SignalEngine.generate() returned {type(_sig).__name__} for '{_code}', "
"expected pd.Series. Each value must be a pandas Series with DatetimeIndex."
)}))
sys.exit(1)
valid_codes = sorted(c for c in signal_map if c in data_map)
if not valid_codes:
print(json.dumps({"error": "No valid signals generated"}))
sys.exit(1)
# 3. Pre-compute target weights (with optimizer)
opt_fn = _load_optimizer(config)
dates, close_df, target_pos, ret_df = _align(
data_map, signal_map, valid_codes, optimizer=opt_fn,
)
# Sync codes after _align may have dropped all-NaN symbols
valid_codes = [c for c in valid_codes if c in target_pos.columns]
# 4. Bar-by-bar execution
self._execute_bars(dates, data_map, close_df, target_pos, valid_codes)
# 5. Build output series
equity_series = pd.Series(
[s.equity for s in self.equity_snapshots],
index=[s.timestamp for s in self.equity_snapshots],
)
bench_ret = ret_df.mean(axis=1) if ret_df.shape[1] > 0 else pd.Series(0.0, index=dates)
benchmark_metadata = {}
# ── External benchmark fetch ──────────────────────────────────────────
bench_ticker = config.get("benchmark")
if bench_ticker and bench_ticker != "auto":
from backtest.benchmark import resolve_benchmark
bench_source = config.get("source", "yfinance")
bench_result = resolve_benchmark(
strategy_codes=codes,
source=bench_source,
start_date=config.get("start_date", ""),
end_date=config.get("end_date", ""),
interval=interval,
explicit=bench_ticker,
# Explicit source: fetch the benchmark through its own loader
# (keeps e.g. source=local offline). Auto keeps the yfinance
# default — its loader only wraps the preloaded strategy data.
loader=loader if bench_source != "auto" else None,
)
if bench_result is not None:
bench_ret = bench_result.ret_series.reindex(dates).fillna(0.0)
benchmark_metadata = {
"benchmark_ticker": bench_result.ticker,
"benchmark_return": bench_result.total_ret,
}
# ── External benchmark fetch ──────────────────────────────────────────
bench_equity = self.initial_capital * (1 + bench_ret).cumprod()
# 6. Metrics
realized_turnover = calc_trade_turnover_series(self.trades, equity_series)
m = calc_metrics(
equity_series,
self.trades,
self.initial_capital,
bars_per_year,
bench_ret,
target_pos,
turnover_series=realized_turnover,
)
m.update(benchmark_metadata)
m["by_symbol"] = by_symbol_stats(self.trades)
m["by_exit_reason"] = by_exit_reason_stats(self.trades)
# 7. Validation (optional — triggered by config["validation"])
if config.get("validation"):
from backtest.validation import run_validation, write_validation_json
v_results = run_validation(
config, equity_series, self.trades, self.initial_capital, bars_per_year,
)
m["validation"] = v_results
# Write validation.json through the shared strict writer so a
# non-finite validation metric is serialized as null rather than an
# invalid bare NaN/Infinity token (matching the standalone
# `python -m backtest.validation` path and run_card). The writer
# also creates the artifacts dir, which step 8 otherwise creates.
write_validation_json(run_dir / "artifacts" / "validation.json", v_results)
# 8. Artifacts
self._write_artifacts(
run_dir, data_map, dates, equity_series, bench_equity, bench_ret,
target_pos, m, valid_codes,
)
# 9. Trust Layer run card
from backtest.run_card import write_run_card
write_run_card(
run_dir,
config,
m,
data_sources=_run_card_data_sources(config, loader),
strategy_path=run_dir / "code" / "signal_engine.py",
warnings=config.get("content_filter_warnings") or None,
)
# Print scalar metrics (skip nested dicts for JSON compat).
# Explosive annual_return may be +inf; match options/run_card and emit
# null instead of a bare Infinity token (invalid RFC-8259 JSON).
print(json.dumps(_json_safe_scalar_metrics(m), indent=2, allow_nan=False))
return m
# ── Execution loop ──
def _execute_bars(
self,
dates: pd.DatetimeIndex,
data_map: Dict[str, pd.DataFrame],
close_df: pd.DataFrame,
target_pos: pd.DataFrame,
codes: List[str],
) -> None:
"""Bar-by-bar execution with market rule enforcement."""
# Pre-extract numpy arrays for O(1) indexed access instead of DataFrame.at[]
# Explicit column reindex ensures array column order matches codes parameter,
# regardless of DataFrame internal column ordering (which may be alphabetical).
_target_arr = target_pos[codes].values # (n_dates, n_codes) ndarray
_close_arr = close_df[codes].values # (n_dates, n_codes) ndarray
_code_to_col = {c: j for j, c in enumerate(codes)}
# Store as instance attrs for use in _calc_equity / _safe_price
self._close_arr = _close_arr
self._code_to_col = _code_to_col
for i, ts in enumerate(dates):
self._bar_idx = i
# a. Value the book at prices observable when orders execute.
# Rebalances happen at the bar open, so using close_df[ts] here
# would let the yet-unknown decision-bar close affect order size.
equity = self._calc_open_equity(data_map, close_df, ts)
target_weights: Dict[str, Optional[float]] = {}
for c in codes:
try:
val = _target_arr[i, _code_to_col[c]]
target_weights[c] = float(val) if not np.isnan(val) else 0.0
except Exception as exc:
target_weights[c] = None
logger.warning("Target weight failed for %s at %s: %s", c, ts, exc)
# b. Release capital before opening replacement positions. A
# single mixed close/open pass makes rotations depend on symbol
# iteration order when the new name is visited before the old one.
for c in codes:
target_w = target_weights[c]
current_pos = self.positions.get(c)
if target_w is None or current_pos is None:
continue
target_dir = 1 if target_w > 1e-9 else (-1 if target_w < -1e-9 else 0)
if target_dir == 0 or target_dir != current_pos.direction:
try:
self._rebalance(c, 0.0, data_map.get(c), ts, equity)
except Exception as exc:
logger.warning(
"Rebalance close failed for %s at %s: %s", c, ts, exc
)
# c. Price every opening order before committing any of them. If
# the requested basket does not fit after fees/lot rounding, apply
# one common scale factor to all target weights. This preserves
# portfolio proportions and makes fills independent of input code
# order; sequential cash clipping would privilege the first name.
open_targets: list[tuple[str, float, Optional[pd.DataFrame]]] = []
for c in sorted(codes):
target_w = target_weights[c]
if target_w is None:
continue
target_dir = 1 if target_w > 1e-9 else (-1 if target_w < -1e-9 else 0)
current_pos = self.positions.get(c)
if current_pos is not None and (
target_dir == 0 or target_dir != current_pos.direction
):
continue
if current_pos is None and target_dir != 0:
open_targets.append((c, target_w, data_map.get(c)))
def _plans(scale: float) -> list[_OpenOrder]:
result: list[_OpenOrder] = []
for c, target_w, frame in open_targets:
try:
order = self._plan_open_order(
c, target_w * scale, frame, ts, equity
)
except Exception as exc:
logger.warning(
"Rebalance open plan failed for %s at %s: %s",
c,
ts,
exc,
)
continue
if order is not None:
result.append(order)
return result
planned = _plans(1.0)
if sum(order.cost for order in planned) > self.capital + 1e-9:
low, high = 0.0, 1.0
for _ in range(50):
mid = (low + high) / 2.0
candidate = _plans(mid)
if sum(order.cost for order in candidate) <= self.capital + 1e-9:
low, planned = mid, candidate
else:
high = mid
for order in planned:
self._execute_open_order(order, ts)
# d. Apply close/within-bar hooks after open execution. Hooks use
# the current bar's close for funding, swaps, and liquidation, so
# running them first could liquidate a position that was scheduled
# to exit at the open (or charge a position before it was opened).
for c in codes:
if ts in data_map[c].index:
self.on_bar(c, data_map[c].loc[ts], ts)
# e. Record equity snapshot
snap_equity = self._calc_equity(close_df, ts)
if self.positions and type(self)._calc_pnl is BaseEngine._calc_pnl:
_syms = list(self.positions.keys())
_eps = np.array([p.entry_price for p in self.positions.values()])
_dirs = np.array([p.direction for p in self.positions.values()])
_sizes = np.array([p.size for p in self.positions.values()])
_cps = np.array([
self._safe_price(
close_df, ts, s, ep,
_arr=_close_arr, _row=i, _col=_code_to_col.get(s),
)
for s, ep in zip(_syms, _eps)
])
total_unrealized = float(np.sum(_dirs * _sizes * (_cps - _eps)))
else:
total_unrealized = 0.0
for p in self.positions.values():
cp = self._safe_price(
close_df, ts, p.symbol, p.entry_price,
_arr=_close_arr, _row=i, _col=_code_to_col.get(p.symbol),
)
total_unrealized += self._calc_pnl(p.symbol, p.direction, p.size, p.entry_price, cp)
self.equity_snapshots.append(EquitySnapshot(
timestamp=ts,
capital=self.capital,
unrealized=total_unrealized,
equity=snap_equity,
positions=len(self.positions),
))
# f. Force close all remaining positions
if len(dates) > 0:
last_ts = dates[-1]
_last_row = len(dates) - 1
for c in list(self.positions.keys()):
pos = self.positions[c]
mark_price = self._safe_price(
close_df, last_ts, c, pos.entry_price,
_arr=_close_arr, _row=_last_row, _col=_code_to_col.get(c),
)
self._active_symbol = c
exit_price = self.apply_slippage(mark_price, -pos.direction)
self._close_position(c, exit_price, last_ts, "end_of_backtest")
# The final snapshot feeds metrics and artifacts. Replace its
# pre-liquidation mark with post-liquidation cash so terminal
# slippage and exit commission are reflected in reported equity.
if self.equity_snapshots:
self.equity_snapshots[-1] = EquitySnapshot(
timestamp=last_ts,
capital=self.capital,
unrealized=0.0,
equity=self.capital,
positions=0,
)
# Clean up temporary instance attributes
self._close_arr = None
self._code_to_col = None
def _calc_open_equity(
self,
data_map: Dict[str, pd.DataFrame],
close_df: pd.DataFrame,
ts: pd.Timestamp,
) -> float:
"""Value current positions at the execution bar's observable open.
For a symbol that has a bar at ``ts``, its open is the mark available
when next-bar-open orders execute. Symbols without a bar on the
unified calendar retain the aligned close fallback, which is the most
recent price carried by ``_align``.
"""
if not self.positions:
return self.capital
equity = self.capital
for sym, pos in self.positions.items():
_arr = getattr(self, "_close_arr", None)
_row = getattr(self, "_bar_idx", None)
_c2c = getattr(self, "_code_to_col", None)
current_price = self._safe_price(
close_df, ts, sym, pos.entry_price,
_arr=_arr, _row=_row, _col=(_c2c.get(sym) if _c2c else None),
)
frame = data_map.get(sym)
if frame is not None and ts in frame.index:
open_price = frame.loc[ts].get("open")
if (
open_price is not None
and pd.notna(open_price)
and float(open_price) > 0
):
current_price = float(open_price)
margin = self._calc_margin(sym, pos.size, pos.entry_price, pos.leverage)
unrealized = self._calc_pnl(
sym, pos.direction, pos.size, pos.entry_price, current_price
)
equity += margin + unrealized
return equity
def _calc_equity(self, close_df: pd.DataFrame, ts: pd.Timestamp) -> float:
"""Total equity = free cash + sum(margin + unrealised) per position.
Uses vectorized numpy path when _calc_pnl/_calc_margin are not
overridden by a subclass (FuturesBaseEngine, CompositeEngine).
"""
if not self.positions:
return self.capital
_base_pnl = type(self)._calc_pnl is BaseEngine._calc_pnl
_base_margin = type(self)._calc_margin is BaseEngine._calc_margin
# Use array fast-path when available
_arr = getattr(self, "_close_arr", None)
_row = getattr(self, "_bar_idx", None)
_c2c = getattr(self, "_code_to_col", None)
if _base_pnl and _base_margin:
syms = list(self.positions.keys())
sizes = np.array([p.size for p in self.positions.values()])
entry_prices = np.array([p.entry_price for p in self.positions.values()])
directions = np.array([p.direction for p in self.positions.values()])
leverages = np.array([p.leverage for p in self.positions.values()])
current_prices = np.array([
self._safe_price(
close_df, ts, s, ep,
_arr=_arr, _row=_row, _col=(_c2c.get(s) if _c2c else None),
)
for s, ep in zip(syms, entry_prices)
])
margins = sizes * entry_prices / leverages
pnls = directions * sizes * (current_prices - entry_prices)
return self.capital + float(np.sum(margins + pnls))
equity = self.capital
for sym, pos in self.positions.items():
cp = self._safe_price(
close_df, ts, sym, pos.entry_price,
_arr=_arr, _row=_row, _col=(_c2c.get(sym) if _c2c else None),
)
margin = self._calc_margin(sym, pos.size, pos.entry_price, pos.leverage)
unrealized = self._calc_pnl(sym, pos.direction, pos.size, pos.entry_price, cp)
equity += margin + unrealized
return equity
def _rebalance(
self,
symbol: str,
target_weight: float,
df: Optional[pd.DataFrame],
ts: pd.Timestamp,
equity: float,
) -> None:
"""Adjust position for *symbol* toward *target_weight*."""
self._active_symbol = symbol
target_dir = 1 if target_weight > 1e-9 else (-1 if target_weight < -1e-9 else 0)
current_pos = self.positions.get(symbol)
# Nothing to do
if current_pos is None and target_dir == 0:
return
if df is None or ts not in df.index:
return
bar = df.loc[ts]
# Close if target is flat or direction changed
if current_pos is not None:
need_close = target_dir == 0 or target_dir != current_pos.direction
if need_close:
if self.can_execute(symbol, 0, bar):
open_price = float(bar.get("open", bar.get("close", 0)))
price = self.apply_slippage(open_price, -current_pos.direction)
self._close_position(symbol, price, ts, "signal")
else:
return # blocked (e.g. limit-down can't sell)
if target_dir != 0 and symbol not in self.positions:
order = self._plan_open_order(symbol, target_weight, df, ts, equity)
if order is not None and order.cost <= self.capital + 1e-9:
self._execute_open_order(order, ts)
def _plan_open_order(
self,
symbol: str,
target_weight: float,
df: Optional[pd.DataFrame],
ts: pd.Timestamp,
equity: float,
) -> Optional[_OpenOrder]:
"""Price an opening order without mutating portfolio state."""
self._active_symbol = symbol
direction = 1 if target_weight > 1e-9 else (-1 if target_weight < -1e-9 else 0)
if direction == 0 or symbol in self.positions or df is None or ts not in df.index:
return None
bar = df.loc[ts]
if not self.can_execute(symbol, direction, bar):
return None
open_price = float(bar.get("open", bar.get("close", 0)))
if open_price <= 0:
return None
price = self.apply_slippage(open_price, direction)
leverage = self._leverage_for_symbol(symbol)
target_notional = abs(target_weight) * equity * leverage
size = self.round_size(
self._calc_raw_size(symbol, target_notional, price), price
)
if size <= 0:
return None
margin = self._calc_margin(symbol, size, price, leverage)
commission = self.calc_commission(
size, price, direction, is_open=True
)
return _OpenOrder(
symbol=symbol,
direction=direction,
price=price,
size=size,
leverage=leverage,
margin=margin,
commission=commission,
)
def _execute_open_order(self, order: _OpenOrder, ts: pd.Timestamp) -> None:
"""Commit a previously priced opening order."""
if order.cost > self.capital + 1e-7:
raise RuntimeError(
f"planned order for {order.symbol} exceeds available capital"
)
self.capital -= order.cost
self.positions[order.symbol] = Position(
symbol=order.symbol,
direction=order.direction,
entry_price=order.price,
entry_time=ts,
size=order.size,
leverage=order.leverage,
entry_bar_idx=self._bar_idx,
entry_commission=order.commission,
)
def _close_position(
self,
symbol: str,
exit_price: float,
exit_time: pd.Timestamp,
reason: str,
) -> None:
"""Close position, record trade, return capital."""
self._active_symbol = symbol
pos = self.positions.pop(symbol, None)
if pos is None:
return
pnl = self._calc_pnl(symbol, pos.direction, pos.size, pos.entry_price, exit_price)
margin = self._calc_margin(symbol, pos.size, pos.entry_price, pos.leverage)
exit_margin = self._calc_margin(symbol, pos.size, exit_price, pos.leverage)
pnl_pct = pnl / margin * 100 if margin > 1e-9 else 0.0
exit_comm = self.calc_commission(pos.size, exit_price, pos.direction, is_open=False)
self.capital += margin + pnl - exit_comm
holding_bars = max(self._bar_idx - pos.entry_bar_idx, 0)
self.trades.append(TradeRecord(
symbol=symbol,
direction=pos.direction,
entry_price=pos.entry_price,
exit_price=exit_price,
entry_time=pos.entry_time,
exit_time=exit_time,
size=pos.size,
leverage=pos.leverage,
pnl=pnl,
pnl_pct=pnl_pct,
exit_reason=reason,
holding_bars=holding_bars,
commission=pos.entry_commission + exit_comm,
entry_margin=margin,
exit_margin=exit_margin,
))
# ── Artifacts ──
def _write_artifacts(
self,
run_dir: Path,
data_map: Dict[str, pd.DataFrame],
dates: pd.DatetimeIndex,
equity_series: pd.Series,
bench_equity: pd.Series,
bench_ret: pd.Series,
target_pos: pd.DataFrame,
metrics: dict,
codes: List[str],
) -> None:
"""Write CSV artifacts compatible with daily_portfolio format."""
out = run_dir / "artifacts"
out.mkdir(parents=True, exist_ok=True)
# OHLCV per symbol
for code, df in data_map.items():
df.to_csv(out / f"ohlcv_{code}.csv")
# Equity curve
port_ret = equity_series.pct_change().fillna(0.0)
peak = equity_series.cummax()
dd = (equity_series - peak) / peak.replace(0, 1)
eq_df = pd.DataFrame({
"ret": port_ret,
"equity": equity_series,
"drawdown": dd,
"benchmark_equity": bench_equity.reindex(dates),
"active_ret": port_ret - bench_ret.reindex(dates).fillna(0.0),
}, index=dates)
eq_df.index.name = "timestamp"
eq_df.to_csv(out / "equity.csv")
# Position weights (target, for compatibility)
target_pos.index.name = "timestamp"
target_pos.to_csv(out / "positions.csv")
# Trades (compatible format)
trade_rows = []
for t in self.trades:
# Entry event
trade_rows.append({
"timestamp": str(t.entry_time.date()) if hasattr(t.entry_time, "date") else str(t.entry_time),
"code": t.symbol,
"side": "buy" if t.direction == 1 else "sell",
"price": round(t.entry_price, 4),
"qty": round(t.size, 6),
"reason": "signal",
"pnl": 0.0,
"holding_days": 0,
"return_pct": 0.0,
})
# Exit event
try:
hold_days = (t.exit_time - t.entry_time).days
except Exception:
hold_days = 0
trade_rows.append({
"timestamp": str(t.exit_time.date()) if hasattr(t.exit_time, "date") else str(t.exit_time),
"code": t.symbol,
"side": "sell" if t.direction == 1 else "buy",
"price": round(t.exit_price, 4),
"qty": round(t.size, 6),
"reason": t.exit_reason,
"pnl": round(t.pnl, 4),
"holding_days": hold_days,
"return_pct": round(t.pnl_pct, 2),
})
trade_cols = ["timestamp", "code", "side", "price", "qty", "reason", "pnl", "holding_days", "return_pct"]
pd.DataFrame(trade_rows or [], columns=trade_cols).to_csv(out / "trades.csv", index=False)
# Metrics
flat_metrics = {k: v for k, v in metrics.items() if not isinstance(v, dict)}
pd.DataFrame([flat_metrics]).to_csv(out / "metrics.csv", index=False)
# ── Helpers ──
@staticmethod
def _safe_price(
close_df: pd.DataFrame,
ts: pd.Timestamp,
symbol: str,
fallback: float,
*,
_arr: "np.ndarray | None" = None,
_row: "int | None" = None,
_col: "int | None" = None,
) -> float:
"""Get close price with fallback. Uses array fast-path when available."""
# Fast path: pre-computed array indexing (O(1) vs DataFrame.at hash lookup)
if _arr is not None and _row is not None and _col is not None:
val = _arr[_row, _col]
return float(val) if not np.isnan(val) else fallback
# Original path (backward compatible for subclasses)
if ts in close_df.index and symbol in close_df.columns:
val = close_df.at[ts, symbol]
if pd.notna(val):
return float(val)
return fallback