回测基本一致
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user