回测基本一致

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
+176
View File
@@ -0,0 +1,176 @@
"""Instrument configuration — the symbol as data (doc 05 §1).
One object describes a symbol under one broker. The engine reads
**everything** about the instrument from here; nothing about ticks, spread,
or swap is ever hard-coded elsewhere. Get these fields from MT5's symbol
specification (``Market Watch → right-click symbol → Specification``), not
from memory — a wrong ``tick_value`` or ``contract_size`` scales every PnL
by a constant and makes the whole backtest meaningless.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class SpreadMode(str, Enum):
"""How spread is applied on each bar.
- ``BAR_COLUMN``: use the real historical per-bar spread stored in the data.
- ``FIXED_POINTS``: a constant point spread (fallback when history is
unreliable, e.g. crypto).
- ``ANNUAL_AVG``: an annual-average spread model.
"""
BAR_COLUMN = "bar_column"
FIXED_POINTS = "fixed_points"
ANNUAL_AVG = "annual_avg"
class SwapMode(str, Enum):
"""How overnight swap is charged.
- ``FIXED_PER_LOT``: ``swap = rate × lot × multiplier`` per calendar day.
- ``ANNUAL_PCT``: ``swap = price × annual_pct / 365 × lot × multiplier``.
"""
FIXED_PER_LOT = "fixed_per_lot"
ANNUAL_PCT = "annual_pct"
class InstrumentProfile(str, Enum):
"""Cost-stress profile of an instrument (doc 05 §1).
- ``REAL``: the broker's actual conditions — the default for ranking.
- ``WORST_CASE``: wider spread, harsher swap — a finalist that survives
this is robust to cost.
- ``BEST_CASE``: tighter spread — shows the ceiling.
"""
REAL = "real"
WORST_CASE = "worst_case"
BEST_CASE = "best_case"
@dataclass(frozen=True)
class InstrumentConfig:
"""All symbol mechanics for one symbol under one broker.
Frozen so configs are safe to share between the engine and the MT5 bridge
without accidental mutation. Keep three variants (real / worst_case /
best_case) per symbol for cost-stress testing (doc 05 §1).
"""
# --- identity ---
name: str # human label, e.g. "EURUSD IC Markets"
symbol: str # broker symbol code, e.g. "EURUSD"
# --- price mechanics (from MT5 symbol spec) ---
point: float # smallest price increment, e.g. 0.00001
digits: int # price decimal places
tick_size: float # price step
tick_value: float # money per lot per tick (the critical PnL scalar)
contract_size: float # units per lot
# --- volume mechanics (from MT5 symbol spec) ---
volume_min: float # minimum lot
volume_step: float # lot rounding step
volume_max: float # maximum lot
# --- spread model (doc 05 §1) ---
spread_mode: SpreadMode = SpreadMode.BAR_COLUMN
spread_fixed_points: float = 0.0 # used only with FIXED_POINTS
# --- swap model (doc 05 §1, doc 03 §6) ---
swap_mode: SwapMode = SwapMode.FIXED_PER_LOT
swap_long: float = 0.0 # per-lot-per-day rate (fixed mode)
swap_short: float = 0.0 # per-lot-per-day rate (fixed mode)
swap_annual_pct: float = 0.0 # annual rate on notional (percent mode)
triple_swap_weekday: int = 3 # 0=Mon ... 3=Wed ... 6=Sun (×3 charge day)
# --- profile tag (which variant this config is) ---
profile: InstrumentProfile = InstrumentProfile.REAL
# --- optional fallback spread for the bar-column mode when missing ---
spread_bar_column_fallback: float = 0.0
def round_volume(self, lots: float) -> float:
"""Round ``lots`` to the broker's volume step and clamp to [min, max].
Mirrors MT5's lot rounding. Use this in the engine and the .set
generator so the two tiers agree.
"""
if self.volume_step <= 0:
clamped = max(self.volume_min, min(self.volume_max, lots))
else:
stepped = round(lots / self.volume_step) * self.volume_step
clamped = max(self.volume_min, min(self.volume_max, stepped))
return round(clamped, 8)
def spread_points(self, bar_spread: Optional[float] = None) -> float:
"""Return the spread in price points for a bar.
With ``BAR_COLUMN`` mode, ``bar_spread`` is the per-bar value from the
data (falling back to ``spread_bar_column_fallback`` when ``None``).
With ``FIXED_POINTS`` mode, ``bar_spread`` is ignored.
"""
if self.spread_mode is SpreadMode.FIXED_POINTS:
return self.spread_fixed_points
if self.spread_mode is SpreadMode.ANNUAL_AVG:
# Placeholder — annual-average model to be filled per instrument.
return self.spread_fixed_points
# BAR_COLUMN
if bar_spread is not None:
return float(bar_spread)
return self.spread_bar_column_fallback
def get_profile(
base: InstrumentConfig,
profile: InstrumentProfile,
*,
worst_spread_mult: float = 1.5,
best_spread_mult: float = 0.5,
worst_swap_mult: float = 1.3,
best_swap_mult: float = 0.7,
) -> InstrumentConfig:
"""Derive a cost-stress variant of ``base`` for the requested profile.
Returns ``base`` unchanged for ``REAL``. For ``WORST_CASE`` / ``BEST_CASE``
it widens / tightens spread and swap by the given multipliers. The base's
``spread_mode`` is preserved; if it is ``BAR_COLUMN`` the multiplier is
baked into ``spread_bar_column_fallback`` and used when the bar column is
missing (a real per-bar spread cannot be multiplied without the data, so
cost stress on bar-column mode is approximated via the fallback).
For a true cost-stress run, prefer building three explicit config objects
per symbol by hand and registering them; this helper is a convenience.
"""
if profile is InstrumentProfile.REAL or base.profile is profile:
return base
mult_spread = worst_spread_mult if profile is InstrumentProfile.WORST_CASE else best_spread_mult
mult_swap = worst_swap_mult if profile is InstrumentProfile.WORST_CASE else best_swap_mult
return InstrumentConfig(
name=base.name,
symbol=base.symbol,
point=base.point,
digits=base.digits,
tick_size=base.tick_size,
tick_value=base.tick_value,
contract_size=base.contract_size,
volume_min=base.volume_min,
volume_step=base.volume_step,
volume_max=base.volume_max,
spread_mode=base.spread_mode,
spread_fixed_points=base.spread_fixed_points * mult_spread,
swap_mode=base.swap_mode,
swap_long=base.swap_long * mult_swap,
swap_short=base.swap_short * mult_swap,
swap_annual_pct=base.swap_annual_pct * mult_swap,
triple_swap_weekday=base.triple_swap_weekday,
profile=profile,
spread_bar_column_fallback=base.spread_bar_column_fallback * mult_spread,
)