177 lines
6.9 KiB
Python
177 lines
6.9 KiB
Python
"""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,
|
||
)
|