Files
2026-06-26 20:50:07 +08:00

177 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)