回测基本一致

This commit is contained in:
2026-06-26 20:50:07 +08:00
parent 49be922517
commit 0dcbfe0781
58 changed files with 4843 additions and 40 deletions
+20
View File
@@ -0,0 +1,20 @@
"""The frozen bar-by-bar fill simulator (doc 02 §2, doc 03).
The engine is **strategy-agnostic**: it consumes bars + signal arrays +
stop/target arrays and simulates fills. All strategy math (when to enter,
where to put stops) lives in the caller. Once an engine reproduces your EA
within the expected fidelity gap (doc 03 §8) it is **frozen** (doc 04 Rule 1)
— never edit a validated engine to test an idea; fork it instead.
"""
from .engine import Direction, Engine, Position, Result, Trade
from .metrics import Metrics, compute_metrics
__all__ = [
"Direction",
"Engine",
"Position",
"Result",
"Trade",
"Metrics",
"compute_metrics",
]
+172
View File
@@ -0,0 +1,172 @@
"""Engine contract and result types (doc 02 §2, doc 03).
The single most important design decision lives here: **the engine knows
nothing about your strategy.** Its input is pre-computed — bars, entry
signals, stop/target prices. The engine never decides *where* a stop goes;
it only decides *whether* price touched it. That seam separates "the
strategy" (caller) from "the simulator" (engine).
The intra-bar 4-sub-tick model and pessimistic ordering convention are
described in doc 03 §2. Implement them in concrete engine subclasses (e.g.
``shared/core/grid_engine.py`` once your EA is brought in, doc 03 §3).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Any, Protocol, runtime_checkable
import numpy as np
import pandas as pd
from ..instruments.config import InstrumentConfig
class Direction(IntEnum):
"""Trade direction. ``1`` long, ``-1`` short."""
LONG = 1
SHORT = -1
# Pessimistic intra-bar sub-tick order (doc 03 §2).
# For a LONG position (stop below, target above): OPEN → LOW → HIGH → CLOSE
# For a SHORT position (stop above, target below): OPEN → HIGH → LOW → CLOSE
# The pessimistic assumption: price visits the point that hurts an open
# position *before* the point that helps it — so a bar that could touch both
# stop and target resolves to the stop (the realistic worst case).
SUBTICK_ORDER_LONG = ("open", "low", "high", "close")
SUBTICK_ORDER_SHORT = ("open", "high", "low", "close")
@dataclass
class Trade:
"""One closed trade (a position opened then exited).
``pnl`` includes accumulated swap. ``exit_reason`` documents why the
trade closed (stop / target / basket-stop / signal-flip / end-of-data).
"""
direction: Direction
entry_time: pd.Timestamp
exit_time: pd.Timestamp
entry_price: float
exit_price: float
lots: float
pnl: float # net of swap
swap: float # accumulated swap (also folded into pnl)
exit_reason: str = ""
@dataclass
class Position:
"""An open position held by the engine between entry and exit.
Multi-position baskets (grid/martingale) are modelled as a list of
``Position`` objects that share a single basket stop (doc 03 §3, §5).
``sl`` / ``tp`` are mutable because break-even and trailing stops update
them over the position's life (doc 03 §6).
"""
direction: Direction
entry_time: pd.Timestamp
entry_price: float
lots: float
open_swap: float = 0.0 # swap accumulated so far on this position
sl: float = 0.0 # current stop-loss price (0 = none)
tp: float = 0.0 # current take-profit price (0 = none)
@dataclass
class Result:
"""Engine output (doc 02 §2).
``trades`` is the list of closed trades; ``equity_curve`` is sampled
periodically (e.g. hourly) so memory stays bounded on multi-year runs.
"""
trades: list[Trade] = field(default_factory=list)
equity_curve: pd.DataFrame = field(default_factory=lambda: pd.DataFrame(columns=["timestamp", "balance", "equity"]))
final_balance: float = 0.0
initial_deposit: float = 0.0
# Optional floating (open) state at end-of-data, for diagnostics.
open_positions: list[Position] = field(default_factory=list)
# Free-form diagnostics (max floating drawdown, series count, …).
diagnostics: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class Engine(Protocol):
"""Strategy-agnostic bar-by-bar fill simulator.
Implementations take **pre-computed** signal + stop/target arrays and
simulate fills bar-by-bar with the pessimistic 4-sub-tick model. The
engine must **not** compute signals, stops, or sizing beyond what the
caller passes in — that boundary is what makes it freezable (doc 04).
"""
def run(
self,
bars: pd.DataFrame,
signals_long: np.ndarray,
signals_short: np.ndarray,
sl_prices: np.ndarray,
tp_prices: np.ndarray,
instrument: InstrumentConfig,
sizing: "SizingInputs",
initial_deposit: float,
) -> Result:
"""Run the engine over ``bars`` and return a :class:`Result`.
Parameters
----------
bars
DataFrame with columns ``[timestamp, open, high, low, close,
spread]``. ``spread`` is in price points per bar (may be 0 / NaN
if the instrument uses ``FIXED_POINTS``).
signals_long, signals_short
Boolean arrays, **edge-detected** — ``True`` only on the
transition bar, not forward-filled, or the engine re-enters
every bar (doc 02 §3).
sl_prices, tp_prices
The stop-loss / take-profit **price** for an entry on that bar.
``NaN`` where no stop / target applies. The engine never decides
*where* a stop goes — only whether price touched it.
instrument
Per-symbol mechanics (tick value, spread, swap, lot steps).
sizing
Lot / money mode inputs (doc 05 §4).
initial_deposit
Account starting balance.
Notes
-----
**Look-ahead guard:** a signal computed *from* a bar's close must
execute on the *next* bar's open, never the same bar's close.
"""
...
@dataclass
class SizingInputs:
"""Position-sizing inputs (doc 05 §4, doc 03 §4).
Mirror your EA's sizing exactly or PnL will be off by a constant factor.
Modes, in priority order:
1. ``risk_on_stop``: ``lot = max_loss_money / (stop_distance_points × tick_value)``.
2. ``fixed_lot`` (when ``lot != 0``): ``lot = configured_lot`` (optionally
scaled by ``balance / reference_balance`` when ``reference_balance > 0``).
3. ``money`` (when ``lot == 0``): ``lot = amount / open_price / contract_size``.
**Money-mode guard:** ``lot`` must be ``0`` to enable money mode — a stray
non-zero fixed lot silently sizes every trade wrong.
"""
lot: float = 0.0 # fixed lot; MUST be 0 for money mode
lot_amount: float = 0.0 # cash base for money mode
lot_balance: float = 0.0 # 0 = static (research default); >0 = dynamic
reference_balance: float = 0.0 # scaling window for fixed-lot compounding
risk_money: float = 0.0 # max loss money for risk-on-stop mode
max_loss_money: float = 0.0 # alias used by some EAs
+131
View File
@@ -0,0 +1,131 @@
"""Compute standard backtest metrics from a :class:`Result` (doc 02 §2).
A separate :func:`compute_metrics` turns the engine's trade list + equity
curve into the numbers you optimize on: Net Profit, Profit Factor, Win Rate,
max Balance/Equity Drawdown, Sharpe, APR, trade count.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
import pandas as pd
from .engine import Result
@dataclass
class Metrics:
"""Standard backtest metrics.
Use ``equity_dd_max`` (Equity Drawdown Maximal, peak-to-trough) — not
Absolute — when judging risk; the peak-to-trough is what matters.
"""
net_profit: float = 0.0
gross_profit: float = 0.0
gross_loss: float = 0.0
profit_factor: float = 0.0 # gross_profit / |gross_loss| (inf-safe)
win_rate: float = 0.0 # wins / total_trades
total_trades: int = 0
wins: int = 0
losses: int = 0
avg_win: float = 0.0
avg_loss: float = 0.0
expectancy: float = 0.0 # avg pnl per trade
max_balance_dd: float = 0.0 # in account currency
max_equity_dd: float = 0.0 # in account currency (the one to watch)
max_balance_dd_pct: float = 0.0
max_equity_dd_pct: float = 0.0
sharpe: float = 0.0 # annualized, 0 if undefined
apr: float = 0.0 # annualized percent return
# Free-form extras for strategy-specific diagnostics.
extras: dict[str, Any] = field(default_factory=dict)
def compute_metrics(result: Result, *, periods_per_year: int = 252) -> Metrics:
"""Compute :class:`Metrics` from a :class:`Result`.
Parameters
----------
result
Engine output (trade list + equity curve).
periods_per_year
Annualization factor for Sharpe (default 252 trading days). Adjust
to match your equity-curve sampling (e.g. 252 for daily, 252*24 for
hourly sampling).
"""
trades = result.trades
m = Metrics()
m.total_trades = len(trades)
if m.total_trades == 0:
# No trades — equity curve is just the flat deposit.
_compute_drawdowns(result, m)
return m
pnls = np.array([t.pnl for t in trades], dtype=float)
wins = pnls[pnls > 0]
losses = pnls[pnls < 0]
m.net_profit = float(pnls.sum())
m.gross_profit = float(wins.sum()) if wins.size else 0.0
m.gross_loss = float(losses.sum()) if losses.size else 0.0
m.wins = int(wins.size)
m.losses = int(losses.size)
m.win_rate = m.wins / m.total_trades
m.avg_win = float(wins.mean()) if wins.size else 0.0
m.avg_loss = float(losses.mean()) if losses.size else 0.0
m.expectancy = float(pnls.mean())
m.profit_factor = (
m.gross_profit / abs(m.gross_loss) if m.gross_loss != 0.0 else float("inf")
)
_compute_drawdowns(result, m)
_compute_risk_ratios(result, m, periods_per_year)
return m
def _compute_drawdowns(result: Result, m: Metrics) -> None:
"""Peak-to-trough drawdowns from the equity curve."""
ec = result.equity_curve
if ec is None or ec.empty or "equity" not in ec.columns:
return
equity = ec["equity"].to_numpy(dtype=float)
if equity.size == 0:
return
running_max = np.maximum.accumulate(equity)
dd = running_max - equity
m.max_equity_dd = float(dd.max())
m.max_equity_dd_pct = (
float(dd.max() / running_max.max()) if running_max.max() > 0 else 0.0
)
if "balance" in ec.columns:
balance = ec["balance"].to_numpy(dtype=float)
if balance.size:
rb = np.maximum.accumulate(balance)
ddb = rb - balance
m.max_balance_dd = float(ddb.max())
m.max_balance_dd_pct = (
float(ddb.max() / rb.max()) if rb.max() > 0 else 0.0
)
def _compute_risk_ratios(result: Result, m: Metrics, periods_per_year: int) -> None:
"""Annualized Sharpe and APR from the equity curve."""
ec = result.equity_curve
if ec is None or ec.empty or "equity" not in ec.columns:
return
equity = ec["equity"].to_numpy(dtype=float)
if equity.size < 2 or result.initial_deposit <= 0:
return
returns = np.diff(equity) / equity[:-1]
returns = returns[np.isfinite(returns)]
if returns.size > 1 and returns.std() > 0:
m.sharpe = float(returns.mean() / returns.std() * np.sqrt(periods_per_year))
final = equity[-1]
total_return = (final / result.initial_deposit) - 1.0
# APR from total return assuming ``periods_per_year`` samples per year.
n_years = max(equity.size / periods_per_year, 1e-9)
m.apr = float(((1.0 + total_return) ** (1.0 / n_years) - 1.0) * 100.0)