"""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, )