forked from biomaster/XAU_system
3570 lines
128 KiB
Python
3570 lines
128 KiB
Python
#!/usr/bin/env python3
| |||
"""
| |||
XAUUSD five-strategy OCO trailing portfolio for MetaTrader 5.
| |||
| |||
Required user-maintained files:
| |||
1. xauusd_five_strategy_oco_bot_risk_vector_v2.py
| |||
2. timetable.csv (UTC macro-news schedule, next to this script)
| |||
3. traded_codes.csv (event-code allowlist, next to this script)
| |||
| |||
Runtime folders/files are created automatically:
| |||
logs/YYYY.txt balance changes only (UTC; initial snapshot once)
| |||
logs/YYYY_trades.log yearly closed-trade ledger (UTC)
| |||
logs/runtime.log rotating operational/error log
| |||
state/strategy_state.json restart-safe internal state
| |||
| |||
Five independent subsystems
| |||
---------------------------
| |||
1. NEWS
| |||
Scheduled events are read from timetable.csv in UTC and filtered by traded_codes.csv.
| |||
offset 2.00; initial SL 30.00; activation 5.00;
| |||
adaptive trail clip(0.25 * RMS(close changes, 5), 0.50, 2.25);
| |||
release-minute expiry 60 s; maximum hold 120 min;
| |||
risk-based volume: 6.5% of balance at the initial stop.
| |||
| |||
2. NY0830
| |||
Every weekday at 08:30 America/New_York.
| |||
offset 6.00; initial SL 60.00; activation 5.00;
| |||
adaptive trail clip(2.00 * RMS(close changes, 20), 3.00, 12.00);
| |||
at +8.00 lock +5.00; pending expiry 15 min; maximum hold 300 min;
| |||
risk-based volume: 10.5% of balance at the initial stop.
| |||
| |||
3. TOKYO0845
| |||
Every weekday at 08:45 Asia/Tokyo.
| |||
offset 3.00; initial SL 30.00; activation 3.00;
| |||
adaptive trail clip(30 * Wilder ATR(30), 12.50, 25.00);
| |||
at +14.00 lock +6.00; at +32.00 switch permanently to trail 12.00;
| |||
pending expiry 15 min; maximum hold 480 min;
| |||
risk-based volume: 3.5% of balance at the initial stop.
| |||
| |||
4. TOKYO1500
| |||
Every weekday at 15:00 Asia/Tokyo.
| |||
offset 3.00; initial SL 40.00; activation 3.00;
| |||
adaptive trail min(15 * median true range(10), 12.50);
| |||
at +10.00 lock +6.00; at +20.00 switch permanently to trail 11.00;
| |||
pending expiry 15 min; maximum hold 180 min;
| |||
risk-based volume: 0.5% of balance at the initial stop.
| |||
| |||
5. NYSE1545
| |||
Every weekday at 15:45 America/New_York.
| |||
offset 2.00; initial SL 40.00;
| |||
frozen pre-event activation clip(3 * SMA(true range, 10), 2.50, 5.00);
| |||
adaptive trail clip(0.30 * SMA(true range, 10), 0.40, 0.85);
| |||
pending expiry 5 min; maximum hold 480 min;
| |||
risk-based volume: 2.0% of balance at the initial stop.
| |||
| |||
Concurrency
| |||
-----------
| |||
Each session subsystem may have at most one pending OCO pair or one open position.
| |||
NEWS is keyed by event minute: every distinct enabled-code timetable minute may have
| |||
its own pending OCO pair or open position, even while earlier NEWS positions remain
| |||
open. Rows without an enabled code in traded_codes.csv are loaded but not traded.
| |||
Simultaneous macro releases must be combined into one timetable row, so one UTC
| |||
minute always produces at most one NEWS OCO pair. New setups are also governed by
| |||
a 23.5% aggregate portfolio initial-stop-risk cap; each intact OCO pair counts once.
| |||
| |||
Time-zone and scheduling guarantees
| |||
-----------------------------------
| |||
* All internal timestamps and timetable timestamps are UTC-aware.
| |||
* The host Windows/VPS local time zone is never used for strategy scheduling.
| |||
* America/New_York ZoneInfo handles US daylight-saving changes automatically.
| |||
* Asia/Tokyo ZoneInfo is used for the two Japanese session events.
| |||
* MT5 broker-clock timestamps are converted dynamically to and from true UTC
| |||
using the live quote clock; no fixed UTC+2/UTC+3 offset is hard-coded.
| |||
* timetable.csv and traded_codes.csv are re-read after edits; temporarily
| |||
incomplete/invalid replacements do not replace the last valid in-memory data.
| |||
| |||
Execution and recovery
| |||
----------------------
| |||
* Orders are submitted approximately 10 seconds before each event.
| |||
* There is no live-spread rejection filter.
| |||
* Every pending leg carries a visible broker-side initial stop-loss.
| |||
* OCO is client-enforced: after one leg fills, the sibling is removed.
| |||
* If both legs fill during a disconnection, the later position is closed after
| |||
reconnection; every filled position remains protected by its attached SL.
| |||
* Trailing uses the final actual bid/ask tick of each fully completed M1 candle.
| |||
Distance is volatility-adaptive under the deployment-safe formulas; the entry
| |||
candle is excluded, fixed profit floors/late trails remain, and SL never loosens.
| |||
* After restart, trailing state is rebuilt from MT5 tick history. Existing
| |||
version-1 state files remain valid; no trailing-state migration is required.
| |||
* MT5 network status is checked explicitly. A connection retcode aborts
| |||
filling/expiry fallbacks immediately and causes a clean reconnect attempt.
| |||
* A hedging account is mandatory.
| |||
| |||
Installation:
| |||
pip install MetaTrader5 tzdata
| |||
| |||
Run while the intended MT5 terminal/account is open and Algo Trading is enabled:
| |||
python xauusd_five_strategy_oco_bot_risk_vector_v2.py
| |||
"""
| |||
| |||
from __future__ import annotations
| |||
| |||
import csv
| |||
import json
| |||
import logging
| |||
from logging.handlers import RotatingFileHandler
| |||
import math
| |||
import os
| |||
from pathlib import Path
| |||
import signal
| |||
import sys
| |||
import time
| |||
from dataclasses import dataclass
| |||
from datetime import datetime, timedelta, timezone
| |||
from typing import Any, Iterable, Optional
| |||
from types import SimpleNamespace
| |||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
| |||
| |||
try:
| |||
import MetaTrader5 as mt5
| |||
except ImportError as exc: # pragma: no cover - runtime environment specific
| |||
raise SystemExit(
| |||
"MetaTrader5 package is not installed. Run: pip install MetaTrader5"
| |||
) from exc
| |||
| |||
| |||
# =============================================================================
| |||
# USER CONFIGURATION
| |||
# =============================================================================
| |||
| |||
# Exact broker symbol is tried first; common suffix/prefix variants are detected.
| |||
PREFERRED_SYMBOL = "XAUUSD"
| |||
| |||
# -----------------------------------------------------------------------------
| |||
# RISK SIZING — MAIN VARIABLES TO ADJUST
| |||
# -----------------------------------------------------------------------------
| |||
# Values are percentages of the current realized account balance. Volume is
| |||
# floored to the broker's lot step so the target risk is never exceeded.
| |||
NEWS_RISK_PERCENT = 6.5
| |||
NY0830_RISK_PERCENT = 10.5
| |||
TOKYO0845_RISK_PERCENT = 3.5
| |||
TOKYO1500_RISK_PERCENT = 0.5
| |||
NYSE1545_RISK_PERCENT = 2.0
| |||
| |||
# Aggregate nominal initial-stop risk of all this bot's live positions and OCO
| |||
# groups. An intact two-leg OCO pair counts once because only one leg is intended
| |||
# to fill. A position plus an unremoved sibling is counted conservatively as both.
| |||
PORTFOLIO_RISK_CAP_PERCENT = 23.5
| |||
| |||
# Conservative allowance used only for position sizing and cap accounting. The
| |||
# backtest used $0.15 adverse entry plus $0.15 adverse exit slippage per ounce.
| |||
RISK_COST_BUFFER_PER_OUNCE = 0.30
| |||
| |||
# "SKIP" preserves the requested percentage when the calculated volume is below
| |||
# the broker minimum. "FORCE_MIN" trades the broker minimum and may exceed it.
| |||
MINIMUM_LOT_POLICY = "SKIP"
| |||
| |||
# While a setup is blocked by risk, the bot keeps checking until shortly before
| |||
# the event. This allows an existing position to close and free risk capacity.
| |||
RISK_SKIP_FINALIZE_SECONDS = 0.75
| |||
| |||
# Optional terminal/account locks. Set these before live deployment when multiple
| |||
# terminals or accounts exist on the VPS. None accepts the currently active one.
| |||
MT5_TERMINAL_PATH: Optional[str] = None
| |||
EXPECTED_ACCOUNT_LOGIN: Optional[int] = None
| |||
EXPECTED_SERVER: Optional[str] = None
| |||
| |||
# Unique IDs. Do not reuse these magic numbers in another EA/script on the account.
| |||
NEWS_MAGIC = 271800
| |||
NY0830_MAGIC = 271801
| |||
TOKYO0845_MAGIC = 271802
| |||
TOKYO1500_MAGIC = 271803
| |||
NYSE1545_MAGIC = 271804
| |||
OUR_MAGICS = {
| |||
NEWS_MAGIC,
| |||
NY0830_MAGIC,
| |||
TOKYO0845_MAGIC,
| |||
TOKYO1500_MAGIC,
| |||
NYSE1545_MAGIC,
| |||
}
| |||
| |||
# Price both pending legs from the latest live quote approximately ten seconds
| |||
# before an event. This leaves operational time for both acknowledgements while
| |||
# remaining close to the final pre-event quote used in the M1 backtests.
| |||
PLACEMENT_LEAD_SECONDS = 10.0
| |||
| |||
# Maximum market-order deviation accepted by the client request, in symbol points.
| |||
# For a 0.01 XAUUSD point, 15 points = 0.15 USD.
| |||
MAX_DEVIATION_POINTS = 15
| |||
| |||
# Main-loop cadence. It automatically sleeps less when an event is near.
| |||
NORMAL_POLL_SECONDS = 0.25
| |||
NEAR_EVENT_POLL_SECONDS = 0.05
| |||
| |||
# Reject stale quotes instead of creating orders from an old market price.
| |||
MAX_TICK_AGE_SECONDS = 10.0
| |||
| |||
# Trailing is allowed to defer locally when the quote feed pauses. This prevents
| |||
# an expected short quote gap from aborting reconciliation of the remaining
| |||
# subsystems or producing a full top-level traceback on every loop iteration.
| |||
TRAIL_TICK_WARNING_INTERVAL_SECONDS = 30.0
| |||
| |||
# Timetable is reloaded automatically after an edit. The mtime check is cheap
| |||
# and a five-second interval keeps last-minute calendar corrections practical.
| |||
TIMETABLE_RELOAD_SECONDS = 5
| |||
| |||
# Runtime connection and history behaviour.
| |||
RECONNECT_RETRY_SECONDS = 5
| |||
TRADE_LOG_LOOKBACK_DAYS = 45
| |||
STATE_RETENTION_DAYS = 120
| |||
| |||
# Optional hard volume cap. Set to None to use the broker's maximum.
| |||
MAX_LOT: Optional[float] = None
| |||
| |||
# Strategy parameters, in XAUUSD price units (USD per ounce).
| |||
NEWS_OFFSET = 2.00
| |||
NEWS_INITIAL_SL = 30.00
| |||
NEWS_TRAIL_ACTIVATION = 5.00
| |||
NEWS_TRAIL_DISTANCE = 0.75
| |||
NEWS_TRAIL_ESTIMATOR = "CLOSE_RMS"
| |||
NEWS_TRAIL_WINDOW = 5
| |||
NEWS_TRAIL_MULTIPLIER = 0.25
| |||
NEWS_TRAIL_FLOOR = 0.50
| |||
NEWS_TRAIL_CAP = 2.25
| |||
NEWS_EXPIRY_SECONDS = 60
| |||
NEWS_MAX_HOLD_MINUTES = 120
| |||
NEWS_MIN_LOT = 0.01
| |||
| |||
# NEWS event types are controlled by traded_codes.csv beside this script.
| |||
# The bot reloads that file automatically. A timetable minute is tradable when at
| |||
# least one of its component event codes is enabled there. The full timetable is
| |||
# still loaded; The5ers High Stakes continues to use every enabled timetable row
| |||
# for blackout protection, regardless of traded_codes.csv.
| |||
TRADED_CODES_RELOAD_SECONDS = 5
| |||
| |||
SESSION_MIN_LOT = 0.01
| |||
| |||
NY0830_OFFSET = 6.00
| |||
NY0830_INITIAL_SL = 60.00
| |||
NY0830_TRAIL_ACTIVATION = 5.00
| |||
NY0830_TRAIL_DISTANCE = 6.00
| |||
NY0830_TRAIL_ESTIMATOR = "CLOSE_RMS"
| |||
NY0830_TRAIL_WINDOW = 20
| |||
NY0830_TRAIL_MULTIPLIER = 2.00
| |||
NY0830_TRAIL_FLOOR = 3.00
| |||
NY0830_TRAIL_CAP = 12.00
| |||
NY0830_PROFIT_FLOOR_THRESHOLD = 8.00
| |||
NY0830_PROFIT_FLOOR = 5.00
| |||
NY0830_EXPIRY_SECONDS = 15 * 60
| |||
NY0830_MAX_HOLD_MINUTES = 300
| |||
| |||
TOKYO0845_OFFSET = 3.00
| |||
TOKYO0845_INITIAL_SL = 30.00
| |||
TOKYO0845_TRAIL_ACTIVATION = 3.00
| |||
TOKYO0845_TRAIL_DISTANCE = 25.00
| |||
TOKYO0845_TRAIL_ESTIMATOR = "ATR_WILDER"
| |||
TOKYO0845_TRAIL_WINDOW = 30
| |||
TOKYO0845_TRAIL_MULTIPLIER = 30.00
| |||
TOKYO0845_TRAIL_FLOOR = 12.50
| |||
TOKYO0845_TRAIL_CAP = 25.00
| |||
TOKYO0845_PROFIT_FLOOR_THRESHOLD = 14.00
| |||
TOKYO0845_PROFIT_FLOOR = 6.00
| |||
TOKYO0845_LATE_TRAIL_THRESHOLD = 32.00
| |||
TOKYO0845_LATE_TRAIL_DISTANCE = 12.00
| |||
TOKYO0845_EXPIRY_SECONDS = 15 * 60
| |||
TOKYO0845_MAX_HOLD_MINUTES = 480
| |||
| |||
TOKYO1500_OFFSET = 3.00
| |||
TOKYO1500_INITIAL_SL = 40.00
| |||
TOKYO1500_TRAIL_ACTIVATION = 3.00
| |||
TOKYO1500_TRAIL_DISTANCE = 12.50
| |||
TOKYO1500_TRAIL_ESTIMATOR = "TR_MEDIAN"
| |||
TOKYO1500_TRAIL_WINDOW = 10
| |||
TOKYO1500_TRAIL_MULTIPLIER = 15.00
| |||
TOKYO1500_TRAIL_FLOOR = 0.00
| |||
TOKYO1500_TRAIL_CAP = 12.50
| |||
TOKYO1500_PROFIT_FLOOR_THRESHOLD = 10.00
| |||
TOKYO1500_PROFIT_FLOOR = 6.00
| |||
TOKYO1500_LATE_TRAIL_THRESHOLD = 20.00
| |||
TOKYO1500_LATE_TRAIL_DISTANCE = 11.00
| |||
TOKYO1500_EXPIRY_SECONDS = 15 * 60
| |||
TOKYO1500_MAX_HOLD_MINUTES = 180
| |||
| |||
NYSE1545_OFFSET = 2.00
| |||
NYSE1545_INITIAL_SL = 40.00
| |||
NYSE1545_TRAIL_ACTIVATION = 5.00
| |||
NYSE1545_TRAIL_DISTANCE = 0.85
| |||
NYSE1545_TRAIL_ESTIMATOR = "TR_SMA"
| |||
NYSE1545_TRAIL_WINDOW = 10
| |||
NYSE1545_TRAIL_MULTIPLIER = 0.30
| |||
NYSE1545_TRAIL_FLOOR = 0.40
| |||
NYSE1545_TRAIL_CAP = 0.85
| |||
NYSE1545_ACTIVATION_ESTIMATOR = "TR_SMA"
| |||
NYSE1545_ACTIVATION_WINDOW = 10
| |||
NYSE1545_ACTIVATION_MULTIPLIER = 3.00
| |||
NYSE1545_ACTIVATION_FLOOR = 2.50
| |||
NYSE1545_ACTIVATION_CAP = 5.00
| |||
NYSE1545_EXPIRY_SECONDS = 5 * 60
| |||
NYSE1545_MAX_HOLD_MINUTES = 480
| |||
| |||
# No trade is opened if account currency is not USD, because risk sizing and the
| |||
# cost buffer are defined in USD account terms.
| |||
REQUIRE_USD_ACCOUNT = True
| |||
| |||
# =============================================================================
| |||
# PATHS AND GLOBALS
| |||
# =============================================================================
| |||
| |||
UTC = timezone.utc
| |||
try:
| |||
NY_TZ = ZoneInfo("America/New_York")
| |||
TOKYO_TZ = ZoneInfo("Asia/Tokyo")
| |||
except ZoneInfoNotFoundError as exc: # common on a fresh Windows Python install
| |||
raise SystemExit(
| |||
"IANA timezone data is missing. Install it with: pip install tzdata"
| |||
) from exc
| |||
BASE_DIR = Path(__file__).resolve().parent
| |||
TIMETABLE_PATH = BASE_DIR / "timetable.csv"
| |||
TRADED_CODES_PATH = BASE_DIR / "traded_codes.csv"
| |||
LOG_DIR = BASE_DIR / "logs"
| |||
STATE_DIR = BASE_DIR / "state"
| |||
STATE_PATH = STATE_DIR / "strategy_state.json"
| |||
LOCK_PATH = STATE_DIR / "xauusd_combined_oco_bot.lock"
| |||
| |||
LOG_DIR.mkdir(parents=True, exist_ok=True)
| |||
STATE_DIR.mkdir(parents=True, exist_ok=True)
| |||
| |||
LOGGER = logging.getLogger("xauusd_oco")
| |||
STOP_REQUESTED = False
| |||
SYMBOL = PREFERRED_SYMBOL
| |||
SYMBOL_INFO: Any = None
| |||
LOCK_HANDLE: Any = None
| |||
LAST_TIMETABLE_RELOAD_MONOTONIC = 0.0
| |||
TIMETABLE_SIGNATURE: Optional[tuple[int, int]] = None
| |||
TIMETABLE_READY = False
| |||
LAST_TRADED_CODES_RELOAD_MONOTONIC = 0.0
| |||
TRADED_CODES_SIGNATURE: Optional[tuple[int, int]] = None
| |||
TRADED_CODES_READY = False
| |||
TRADED_NEWS_CODES: frozenset[str] = frozenset()
| |||
NEWS_EVENTS: list["NewsEvent"] = []
| |||
LAST_DEAL_SCAN_MONOTONIC = 0.0
| |||
LAST_LOGGED_BALANCE_TEXT: Optional[str] = None
| |||
LAST_BALANCE_LOG_YEAR: Optional[int] = None
| |||
LAST_HORIZON_WARNING_DATE: Optional[str] = None
| |||
BROKER_CLOCK_BASIS = "broker_epoch_v2_offset_aware"
| |||
BROKER_UTC_OFFSET_SECONDS: Optional[int] = None
| |||
MAX_BROKER_UTC_OFFSET_SECONDS = 14 * 60 * 60
| |||
COMPLETED_CLOSE_CACHE: dict[int, Optional[tuple[datetime, float, float]]] = {}
| |||
M1_RATE_CACHE: dict[int, Optional[list["CompletedM1Bar"]]] = {}
| |||
M1_RATE_RETRY_AFTER: dict[int, float] = {}
| |||
M1_RATE_SOURCE_WARNINGS: set[int] = set()
| |||
VOLATILITY_WARNING_KEYS: set[tuple[str, int]] = set()
| |||
RISK_BLOCK_WARNING_KEYS: set[tuple[str, str]] = set()
| |||
TRAIL_TICK_WARNING_LAST_MONOTONIC: dict[int, float] = {}
| |||
| |||
| |||
# =============================================================================
| |||
# DATA CLASSES
| |||
# =============================================================================
| |||
| |||
@dataclass(frozen=True)
| |||
class StrategyConfig:
| |||
name: str
| |||
magic: int
| |||
group_prefix: str
| |||
offset: float
| |||
initial_sl: float
| |||
trail_activation: float
| |||
trail_distance: float
| |||
expiry_seconds: int
| |||
max_hold_minutes: int
| |||
risk_percent: float
| |||
min_lot: float
| |||
profit_floor_threshold: Optional[float] = None
| |||
profit_floor: Optional[float] = None
| |||
late_trail_threshold: Optional[float] = None
| |||
late_trail_distance: Optional[float] = None
| |||
trail_estimator: Optional[str] = None
| |||
trail_window: int = 0
| |||
trail_multiplier: float = 0.0
| |||
trail_floor: float = 0.0
| |||
trail_cap: float = 0.0
| |||
activation_estimator: Optional[str] = None
| |||
activation_window: int = 0
| |||
activation_multiplier: float = 0.0
| |||
activation_floor: float = 0.0
| |||
activation_cap: float = 0.0
| |||
| |||
| |||
@dataclass(frozen=True)
| |||
class LotSizingResult:
| |||
sizing_balance: float
| |||
target_risk_usd: float
| |||
risk_per_lot_usd: float
| |||
raw_lot: float
| |||
lot: float
| |||
actual_risk_usd: float
| |||
| |||
| |||
NEWS_CONFIG = StrategyConfig(
| |||
name="NEWS",
| |||
magic=NEWS_MAGIC,
| |||
group_prefix="N",
| |||
offset=NEWS_OFFSET,
| |||
initial_sl=NEWS_INITIAL_SL,
| |||
trail_activation=NEWS_TRAIL_ACTIVATION,
| |||
trail_distance=NEWS_TRAIL_DISTANCE,
| |||
expiry_seconds=NEWS_EXPIRY_SECONDS,
| |||
max_hold_minutes=NEWS_MAX_HOLD_MINUTES,
| |||
risk_percent=NEWS_RISK_PERCENT,
| |||
min_lot=NEWS_MIN_LOT,
| |||
trail_estimator=NEWS_TRAIL_ESTIMATOR,
| |||
trail_window=NEWS_TRAIL_WINDOW,
| |||
trail_multiplier=NEWS_TRAIL_MULTIPLIER,
| |||
trail_floor=NEWS_TRAIL_FLOOR,
| |||
trail_cap=NEWS_TRAIL_CAP,
| |||
)
| |||
| |||
NY0830_CONFIG = StrategyConfig(
| |||
name="NY0830",
| |||
magic=NY0830_MAGIC,
| |||
group_prefix="Y",
| |||
offset=NY0830_OFFSET,
| |||
initial_sl=NY0830_INITIAL_SL,
| |||
trail_activation=NY0830_TRAIL_ACTIVATION,
| |||
trail_distance=NY0830_TRAIL_DISTANCE,
| |||
expiry_seconds=NY0830_EXPIRY_SECONDS,
| |||
max_hold_minutes=NY0830_MAX_HOLD_MINUTES,
| |||
risk_percent=NY0830_RISK_PERCENT,
| |||
min_lot=SESSION_MIN_LOT,
| |||
profit_floor_threshold=NY0830_PROFIT_FLOOR_THRESHOLD,
| |||
profit_floor=NY0830_PROFIT_FLOOR,
| |||
trail_estimator=NY0830_TRAIL_ESTIMATOR,
| |||
trail_window=NY0830_TRAIL_WINDOW,
| |||
trail_multiplier=NY0830_TRAIL_MULTIPLIER,
| |||
trail_floor=NY0830_TRAIL_FLOOR,
| |||
trail_cap=NY0830_TRAIL_CAP,
| |||
)
| |||
| |||
TOKYO0845_CONFIG = StrategyConfig(
| |||
name="TOKYO0845",
| |||
magic=TOKYO0845_MAGIC,
| |||
group_prefix="A",
| |||
offset=TOKYO0845_OFFSET,
| |||
initial_sl=TOKYO0845_INITIAL_SL,
| |||
trail_activation=TOKYO0845_TRAIL_ACTIVATION,
| |||
trail_distance=TOKYO0845_TRAIL_DISTANCE,
| |||
expiry_seconds=TOKYO0845_EXPIRY_SECONDS,
| |||
max_hold_minutes=TOKYO0845_MAX_HOLD_MINUTES,
| |||
risk_percent=TOKYO0845_RISK_PERCENT,
| |||
min_lot=SESSION_MIN_LOT,
| |||
profit_floor_threshold=TOKYO0845_PROFIT_FLOOR_THRESHOLD,
| |||
profit_floor=TOKYO0845_PROFIT_FLOOR,
| |||
late_trail_threshold=TOKYO0845_LATE_TRAIL_THRESHOLD,
| |||
late_trail_distance=TOKYO0845_LATE_TRAIL_DISTANCE,
| |||
trail_estimator=TOKYO0845_TRAIL_ESTIMATOR,
| |||
trail_window=TOKYO0845_TRAIL_WINDOW,
| |||
trail_multiplier=TOKYO0845_TRAIL_MULTIPLIER,
| |||
trail_floor=TOKYO0845_TRAIL_FLOOR,
| |||
trail_cap=TOKYO0845_TRAIL_CAP,
| |||
)
| |||
| |||
TOKYO1500_CONFIG = StrategyConfig(
| |||
name="TOKYO1500",
| |||
magic=TOKYO1500_MAGIC,
| |||
group_prefix="T",
| |||
offset=TOKYO1500_OFFSET,
| |||
initial_sl=TOKYO1500_INITIAL_SL,
| |||
trail_activation=TOKYO1500_TRAIL_ACTIVATION,
| |||
trail_distance=TOKYO1500_TRAIL_DISTANCE,
| |||
expiry_seconds=TOKYO1500_EXPIRY_SECONDS,
| |||
max_hold_minutes=TOKYO1500_MAX_HOLD_MINUTES,
| |||
risk_percent=TOKYO1500_RISK_PERCENT,
| |||
min_lot=SESSION_MIN_LOT,
| |||
profit_floor_threshold=TOKYO1500_PROFIT_FLOOR_THRESHOLD,
| |||
profit_floor=TOKYO1500_PROFIT_FLOOR,
| |||
late_trail_threshold=TOKYO1500_LATE_TRAIL_THRESHOLD,
| |||
late_trail_distance=TOKYO1500_LATE_TRAIL_DISTANCE,
| |||
trail_estimator=TOKYO1500_TRAIL_ESTIMATOR,
| |||
trail_window=TOKYO1500_TRAIL_WINDOW,
| |||
trail_multiplier=TOKYO1500_TRAIL_MULTIPLIER,
| |||
trail_floor=TOKYO1500_TRAIL_FLOOR,
| |||
trail_cap=TOKYO1500_TRAIL_CAP,
| |||
)
| |||
| |||
NYSE1545_CONFIG = StrategyConfig(
| |||
name="NYSE1545",
| |||
magic=NYSE1545_MAGIC,
| |||
group_prefix="C",
| |||
offset=NYSE1545_OFFSET,
| |||
initial_sl=NYSE1545_INITIAL_SL,
| |||
trail_activation=NYSE1545_TRAIL_ACTIVATION,
| |||
trail_distance=NYSE1545_TRAIL_DISTANCE,
| |||
expiry_seconds=NYSE1545_EXPIRY_SECONDS,
| |||
max_hold_minutes=NYSE1545_MAX_HOLD_MINUTES,
| |||
risk_percent=NYSE1545_RISK_PERCENT,
| |||
min_lot=SESSION_MIN_LOT,
| |||
trail_estimator=NYSE1545_TRAIL_ESTIMATOR,
| |||
trail_window=NYSE1545_TRAIL_WINDOW,
| |||
trail_multiplier=NYSE1545_TRAIL_MULTIPLIER,
| |||
trail_floor=NYSE1545_TRAIL_FLOOR,
| |||
trail_cap=NYSE1545_TRAIL_CAP,
| |||
activation_estimator=NYSE1545_ACTIVATION_ESTIMATOR,
| |||
activation_window=NYSE1545_ACTIVATION_WINDOW,
| |||
activation_multiplier=NYSE1545_ACTIVATION_MULTIPLIER,
| |||
activation_floor=NYSE1545_ACTIVATION_FLOOR,
| |||
activation_cap=NYSE1545_ACTIVATION_CAP,
| |||
)
| |||
| |||
ALL_CONFIGS = (
| |||
NEWS_CONFIG,
| |||
NY0830_CONFIG,
| |||
TOKYO0845_CONFIG,
| |||
TOKYO1500_CONFIG,
| |||
NYSE1545_CONFIG,
| |||
)
| |||
CONFIG_BY_MAGIC = {config.magic: config for config in ALL_CONFIGS}
| |||
SESSION_CONFIGS = (
| |||
NY0830_CONFIG,
| |||
TOKYO0845_CONFIG,
| |||
TOKYO1500_CONFIG,
| |||
NYSE1545_CONFIG,
| |||
)
| |||
CONFIG_BY_MAGIC = {config.magic: config for config in ALL_CONFIGS}
| |||
| |||
| |||
@dataclass(frozen=True)
| |||
class DailySessionSpec:
| |||
config: StrategyConfig
| |||
timezone: ZoneInfo
| |||
hour: int
| |||
minute: int
| |||
event_id_prefix: str
| |||
display_name: str
| |||
| |||
| |||
DAILY_SESSION_SPECS = (
| |||
DailySessionSpec(
| |||
NY0830_CONFIG,
| |||
NY_TZ,
| |||
8,
| |||
30,
| |||
"NY0830",
| |||
"NY 08:30 ET",
| |||
),
| |||
DailySessionSpec(
| |||
TOKYO0845_CONFIG,
| |||
TOKYO_TZ,
| |||
8,
| |||
45,
| |||
"TOKYO0845",
| |||
"Tokyo 08:45 JST",
| |||
),
| |||
DailySessionSpec(
| |||
TOKYO1500_CONFIG,
| |||
TOKYO_TZ,
| |||
15,
| |||
0,
| |||
"TOKYO1500",
| |||
"Tokyo 15:00 JST",
| |||
),
| |||
DailySessionSpec(
| |||
NYSE1545_CONFIG,
| |||
NY_TZ,
| |||
15,
| |||
45,
| |||
"NYSE1545",
| |||
"NYSE 15:45 ET",
| |||
),
| |||
)
| |||
| |||
| |||
@dataclass(frozen=True)
| |||
class NewsEvent:
| |||
event_id: str
| |||
event_name: str
| |||
when_utc: datetime
| |||
enabled: bool
| |||
event_codes: tuple[str, ...]
| |||
tradable: bool
| |||
| |||
| |||
# =============================================================================
| |||
# LOGGING, LOCKING, STATE
| |||
# =============================================================================
| |||
| |||
def setup_logging() -> None:
| |||
LOGGER.setLevel(logging.INFO)
| |||
LOGGER.handlers.clear()
| |||
| |||
formatter = logging.Formatter(
| |||
"%(asctime)sZ | %(levelname)-8s | %(message)s",
| |||
datefmt="%Y-%m-%d %H:%M:%S",
| |||
)
| |||
formatter.converter = time.gmtime
| |||
| |||
file_handler = RotatingFileHandler(
| |||
LOG_DIR / "runtime.log",
| |||
maxBytes=5_000_000,
| |||
backupCount=5,
| |||
encoding="utf-8",
| |||
)
| |||
file_handler.setFormatter(formatter)
| |||
LOGGER.addHandler(file_handler)
| |||
| |||
console_handler = logging.StreamHandler(sys.stdout)
| |||
console_handler.setFormatter(formatter)
| |||
LOGGER.addHandler(console_handler)
| |||
| |||
| |||
def acquire_single_instance_lock() -> None:
| |||
"""Hold an OS file lock for the lifetime of the process."""
| |||
global LOCK_HANDLE
| |||
LOCK_HANDLE = open(LOCK_PATH, "a+b")
| |||
try:
| |||
if os.name == "nt":
| |||
import msvcrt
| |||
| |||
LOCK_HANDLE.seek(0)
| |||
if LOCK_HANDLE.tell() == 0:
| |||
LOCK_HANDLE.write(b"0")
| |||
LOCK_HANDLE.flush()
| |||
LOCK_HANDLE.seek(0)
| |||
msvcrt.locking(LOCK_HANDLE.fileno(), msvcrt.LK_NBLCK, 1)
| |||
else: # pragma: no cover - production target is Windows
| |||
import fcntl
| |||
| |||
fcntl.flock(LOCK_HANDLE.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
| |||
except (OSError, IOError) as exc:
| |||
raise SystemExit("Another instance of this bot is already running.") from exc
| |||
| |||
LOCK_HANDLE.seek(0)
| |||
LOCK_HANDLE.truncate()
| |||
LOCK_HANDLE.write(str(os.getpid()).encode("ascii"))
| |||
LOCK_HANDLE.flush()
| |||
| |||
| |||
def default_state() -> dict[str, Any]:
| |||
return {
| |||
"version": 1,
| |||
"processed_events": {},
| |||
"active_groups": {},
| |||
"trail": {},
| |||
"logged_position_ids": [],
| |||
}
| |||
| |||
| |||
def load_state() -> dict[str, Any]:
| |||
if not STATE_PATH.exists():
| |||
return default_state()
| |||
try:
| |||
data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
| |||
if not isinstance(data, dict):
| |||
raise ValueError("state root is not an object")
| |||
base = default_state()
| |||
for key, value in base.items():
| |||
data.setdefault(key, value)
| |||
return data
| |||
except Exception:
| |||
LOGGER.exception("State file is unreadable; backing it up and rebuilding.")
| |||
backup = STATE_PATH.with_suffix(f".corrupt.{int(time.time())}.json")
| |||
try:
| |||
STATE_PATH.replace(backup)
| |||
except OSError:
| |||
pass
| |||
return default_state()
| |||
| |||
| |||
def save_state(state: dict[str, Any]) -> None:
| |||
tmp = STATE_PATH.with_suffix(".tmp")
| |||
payload = json.dumps(state, indent=2, sort_keys=True, ensure_ascii=False)
| |||
tmp.write_text(payload, encoding="utf-8")
| |||
os.replace(tmp, STATE_PATH)
| |||
| |||
| |||
def prune_state(state: dict[str, Any], now_utc: datetime) -> None:
| |||
cutoff = now_utc - timedelta(days=STATE_RETENTION_DAYS)
| |||
processed = state.get("processed_events", {})
| |||
for key in list(processed):
| |||
stamp = processed[key].get("updated_utc") if isinstance(processed[key], dict) else None
| |||
try:
| |||
dt = parse_utc(stamp) if stamp else now_utc
| |||
except Exception:
| |||
dt = now_utc
| |||
if dt < cutoff:
| |||
del processed[key]
| |||
| |||
# Trail state for positions that no longer exist is cleared elsewhere.
| |||
logged = state.get("logged_position_ids", [])
| |||
if len(logged) > 10_000:
| |||
state["logged_position_ids"] = logged[-10_000:]
| |||
| |||
| |||
# =============================================================================
| |||
# TIME AND CSV
| |||
# =============================================================================
| |||
| |||
def utc_now() -> datetime:
| |||
return datetime.now(UTC)
| |||
| |||
| |||
def format_utc(dt: datetime) -> str:
| |||
return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
| |||
| |||
| |||
def parse_utc(value: str) -> datetime:
| |||
value = value.strip()
| |||
if value.endswith("Z"):
| |||
value = value[:-1] + "+00:00"
| |||
dt = datetime.fromisoformat(value)
| |||
if dt.tzinfo is None:
| |||
raise ValueError(f"UTC datetime lacks timezone: {value!r}")
| |||
return dt.astimezone(UTC)
| |||
| |||
| |||
def bool_from_csv(value: str) -> bool:
| |||
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
| |||
| |||
| |||
| |||
def refresh_news_tradability() -> None:
| |||
"""Re-evaluate loaded timetable rows after traded_codes.csv changes."""
| |||
global NEWS_EVENTS
| |||
if not NEWS_EVENTS:
| |||
return
| |||
| |||
NEWS_EVENTS = [
| |||
NewsEvent(
| |||
event_id=event.event_id,
| |||
event_name=event.event_name,
| |||
when_utc=event.when_utc,
| |||
enabled=event.enabled,
| |||
event_codes=event.event_codes,
| |||
tradable=event.enabled and traded_news_minute(event.event_codes),
| |||
)
| |||
for event in NEWS_EVENTS
| |||
]
| |||
| |||
| |||
def load_traded_codes(force: bool = False) -> None:
| |||
"""Atomically load the user-maintained traded_codes.csv file.
| |||
| |||
Required columns are ``event_code`` and ``enabled``. Extra columns such as
| |||
``family`` and ``notes`` are ignored. Missing or invalid replacements never
| |||
overwrite the last valid in-memory code set. To intentionally disable all
| |||
NEWS entries, keep at least one row and set every row's enabled field to 0.
| |||
"""
| |||
global TRADED_NEWS_CODES, TRADED_CODES_SIGNATURE
| |||
global LAST_TRADED_CODES_RELOAD_MONOTONIC, TRADED_CODES_READY
| |||
| |||
now_mono = time.monotonic()
| |||
if (
| |||
not force
| |||
and now_mono - LAST_TRADED_CODES_RELOAD_MONOTONIC
| |||
< TRADED_CODES_RELOAD_SECONDS
| |||
):
| |||
return
| |||
LAST_TRADED_CODES_RELOAD_MONOTONIC = now_mono
| |||
| |||
if not TRADED_CODES_PATH.exists():
| |||
raise FileNotFoundError(f"Missing traded-code file: {TRADED_CODES_PATH}")
| |||
| |||
last_change_error: Optional[Exception] = None
| |||
for attempt in range(1, 4):
| |||
before = TRADED_CODES_PATH.stat()
| |||
signature = (before.st_mtime_ns, before.st_size)
| |||
if not force and TRADED_CODES_SIGNATURE == signature:
| |||
return
| |||
| |||
enabled_codes: set[str] = set()
| |||
seen_codes: set[str] = set()
| |||
data_rows = 0
| |||
| |||
try:
| |||
with TRADED_CODES_PATH.open(
| |||
"r", newline="", encoding="utf-8-sig"
| |||
) as handle:
| |||
reader = csv.DictReader(handle)
| |||
required = {"event_code", "enabled"}
| |||
if reader.fieldnames is None or not required.issubset(
| |||
set(reader.fieldnames)
| |||
):
| |||
raise ValueError(
| |||
"traded_codes.csv must contain columns: event_code,enabled"
| |||
)
| |||
| |||
for line_no, row in enumerate(reader, start=2):
| |||
if not any((value or "").strip() for value in row.values()):
| |||
continue
| |||
| |||
data_rows += 1
| |||
event_code = (row.get("event_code") or "").strip().lower()
| |||
enabled_text = (row.get("enabled") or "").strip()
| |||
| |||
if not event_code:
| |||
raise ValueError(
| |||
f"Blank event_code at traded_codes.csv line {line_no}"
| |||
)
| |||
if event_code in seen_codes:
| |||
raise ValueError(
| |||
f"Duplicate event_code {event_code!r} at "
| |||
f"traded_codes.csv line {line_no}"
| |||
)
| |||
| |||
normalized_enabled = enabled_text.lower()
| |||
valid_true = {"1", "true", "yes", "y", "on"}
| |||
valid_false = {"0", "false", "no", "n", "off"}
| |||
if normalized_enabled not in valid_true | valid_false:
| |||
raise ValueError(
| |||
f"Invalid enabled value {enabled_text!r} at "
| |||
f"traded_codes.csv line {line_no}"
| |||
)
| |||
| |||
seen_codes.add(event_code)
| |||
if normalized_enabled in valid_true:
| |||
enabled_codes.add(event_code)
| |||
| |||
if data_rows == 0:
| |||
raise ValueError(
| |||
"traded_codes.csv contains no data rows; retain rows with "
| |||
"enabled=0 to intentionally disable NEWS trading"
| |||
)
| |||
| |||
after = TRADED_CODES_PATH.stat()
| |||
after_signature = (after.st_mtime_ns, after.st_size)
| |||
if signature != after_signature:
| |||
last_change_error = RuntimeError(
| |||
"traded_codes.csv changed while it was being read"
| |||
)
| |||
time.sleep(0.10)
| |||
continue
| |||
| |||
TRADED_NEWS_CODES = frozenset(enabled_codes)
| |||
TRADED_CODES_SIGNATURE = after_signature
| |||
TRADED_CODES_READY = True
| |||
refresh_news_tradability()
| |||
| |||
future_tradable = sum(
| |||
event.tradable
| |||
and event.when_utc > utc_now() - timedelta(minutes=1)
| |||
for event in NEWS_EVENTS
| |||
)
| |||
LOGGER.info(
| |||
"Traded-code file loaded atomically: %d rows, %d enabled codes, "
| |||
"%d current/future tradable timetable minute(s); %s",
| |||
data_rows,
| |||
len(TRADED_NEWS_CODES),
| |||
future_tradable,
| |||
TRADED_CODES_PATH,
| |||
)
| |||
if not TRADED_NEWS_CODES:
| |||
LOGGER.warning(
| |||
"traded_codes.csv currently enables no event codes; NEWS "
| |||
"entries are disabled while session strategies continue."
| |||
)
| |||
return
| |||
except (FileNotFoundError, OSError) as exc:
| |||
last_change_error = exc
| |||
if attempt < 3:
| |||
time.sleep(0.10)
| |||
continue
| |||
raise
| |||
except Exception:
| |||
raise
| |||
| |||
if last_change_error is not None:
| |||
raise last_change_error
| |||
raise RuntimeError("Could not load a stable traded_codes.csv snapshot")
| |||
| |||
| |||
def event_codes_from_csv_row(row: dict[str, str]) -> tuple[str, ...]:
| |||
"""Extract normalized MT5 event codes from a timetable row.
| |||
| |||
Newer exporters may provide a dedicated event_codes column. The current EA
| |||
stores the same value inside notes as ``event_codes=a|b|c``. Both layouts are
| |||
accepted so the filter remains compatible with future exporter revisions.
| |||
"""
| |||
raw = (row.get("event_codes") or "").strip()
| |||
if not raw:
| |||
notes = row.get("notes") or ""
| |||
marker = "event_codes="
| |||
marker_at = notes.lower().find(marker)
| |||
if marker_at >= 0:
| |||
raw = notes[marker_at + len(marker):].split(";", 1)[0].strip()
| |||
| |||
normalized = {
| |||
code.strip().lower()
| |||
for code in raw.replace(",", "|").split("|")
| |||
if code.strip()
| |||
}
| |||
return tuple(sorted(normalized))
| |||
| |||
| |||
def traded_news_minute(event_codes: tuple[str, ...]) -> bool:
| |||
"""Return True when any component code is enabled in traded_codes.csv."""
| |||
return bool(TRADED_NEWS_CODES.intersection(event_codes))
| |||
| |||
| |||
def load_timetable(force: bool = False) -> None:
| |||
"""Load a complete, stable timetable.csv from the bot's own folder.
| |||
| |||
The MT5 exporter publishes the file atomically, but this loader still verifies
| |||
that size and modification time did not change while it was being parsed.
| |||
A failed reload raises without replacing NEWS_EVENTS, so the main loop keeps
| |||
the last valid in-memory calendar.
| |||
"""
| |||
global NEWS_EVENTS, TIMETABLE_SIGNATURE, LAST_TIMETABLE_RELOAD_MONOTONIC
| |||
global TIMETABLE_READY
| |||
| |||
now_mono = time.monotonic()
| |||
if not force and now_mono - LAST_TIMETABLE_RELOAD_MONOTONIC < TIMETABLE_RELOAD_SECONDS:
| |||
return
| |||
LAST_TIMETABLE_RELOAD_MONOTONIC = now_mono
| |||
| |||
if not TIMETABLE_PATH.exists():
| |||
raise FileNotFoundError(f"Missing timetable: {TIMETABLE_PATH}")
| |||
| |||
last_change_error: Optional[Exception] = None
| |||
for attempt in range(1, 4):
| |||
before = TIMETABLE_PATH.stat()
| |||
signature = (before.st_mtime_ns, before.st_size)
| |||
if not force and TIMETABLE_SIGNATURE == signature:
| |||
return
| |||
| |||
rows: list[NewsEvent] = []
| |||
seen_ids: set[str] = set()
| |||
seen_times: set[datetime] = set()
| |||
| |||
try:
| |||
with TIMETABLE_PATH.open("r", newline="", encoding="utf-8-sig") as handle:
| |||
reader = csv.DictReader(handle)
| |||
required = {"event_id", "event_name", "utc_datetime", "enabled"}
| |||
if reader.fieldnames is None or not required.issubset(set(reader.fieldnames)):
| |||
raise ValueError(
| |||
"timetable.csv must contain columns: "
| |||
"event_id,event_name,utc_datetime,enabled"
| |||
)
| |||
| |||
for line_no, row in enumerate(reader, start=2):
| |||
# Permit harmless completely blank lines at the end of a CSV.
| |||
if not any((value or "").strip() for value in row.values()):
| |||
continue
| |||
| |||
event_id = (row.get("event_id") or "").strip()
| |||
event_name = (row.get("event_name") or "").strip()
| |||
utc_text = (row.get("utc_datetime") or "").strip()
| |||
enabled_text = (row.get("enabled") or "").strip()
| |||
event_codes = event_codes_from_csv_row(row)
| |||
| |||
if not event_id or not event_name or not utc_text:
| |||
raise ValueError(
| |||
f"Blank event_id/event_name/utc_datetime at timetable line {line_no}"
| |||
)
| |||
if event_id in seen_ids:
| |||
raise ValueError(f"Duplicate event_id {event_id!r} at line {line_no}")
| |||
| |||
when = parse_utc(utc_text)
| |||
enabled = bool_from_csv(enabled_text)
| |||
if enabled and when in seen_times:
| |||
raise ValueError(
| |||
f"Duplicate enabled UTC minute {format_utc(when)}. "
| |||
"Combine simultaneous releases into one row."
| |||
)
| |||
| |||
seen_ids.add(event_id)
| |||
if enabled:
| |||
seen_times.add(when)
| |||
rows.append(
| |||
NewsEvent(
| |||
event_id=event_id,
| |||
event_name=event_name,
| |||
when_utc=when,
| |||
enabled=enabled,
| |||
event_codes=event_codes,
| |||
tradable=enabled and traded_news_minute(event_codes),
| |||
)
| |||
)
| |||
| |||
after = TIMETABLE_PATH.stat()
| |||
after_signature = (after.st_mtime_ns, after.st_size)
| |||
if signature != after_signature:
| |||
last_change_error = RuntimeError(
| |||
"timetable.csv changed while it was being read"
| |||
)
| |||
time.sleep(0.10)
| |||
continue
| |||
| |||
enabled_count = sum(event.enabled for event in rows)
| |||
future_enabled = sum(
| |||
event.enabled and event.when_utc > utc_now() - timedelta(minutes=1)
| |||
for event in rows
| |||
)
| |||
tradable_count = sum(event.tradable for event in rows)
| |||
future_tradable = sum(
| |||
event.tradable and event.when_utc > utc_now() - timedelta(minutes=1)
| |||
for event in rows
| |||
)
| |||
missing_code_count = sum(
| |||
event.enabled and not event.event_codes for event in rows
| |||
)
| |||
if not rows:
| |||
raise ValueError("timetable.csv contains no event rows")
| |||
if enabled_count == 0:
| |||
raise ValueError("timetable.csv contains no enabled events")
| |||
if future_enabled == 0:
| |||
raise ValueError("timetable.csv contains no current or future enabled events")
| |||
| |||
NEWS_EVENTS = sorted(rows, key=lambda event: event.when_utc)
| |||
TIMETABLE_SIGNATURE = after_signature
| |||
TIMETABLE_READY = True
| |||
LOGGER.info(
| |||
"Timetable loaded atomically: %d rows, %d enabled, %d current/future, "
| |||
"%d traded-code rows, %d traded-code current/future; %s",
| |||
len(NEWS_EVENTS),
| |||
enabled_count,
| |||
future_enabled,
| |||
tradable_count,
| |||
future_tradable,
| |||
TIMETABLE_PATH,
| |||
)
| |||
if missing_code_count:
| |||
LOGGER.warning(
| |||
"%d enabled timetable row(s) have no event_codes and are fail-closed "
| |||
"for NEWS trading.",
| |||
missing_code_count,
| |||
)
| |||
if future_tradable == 0:
| |||
LOGGER.warning(
| |||
"No current or future timetable minute matches the enabled codes in traded_codes.csv; "
| |||
"NEWS trading is disabled until a matching row appears."
| |||
)
| |||
return
| |||
except (FileNotFoundError, OSError) as exc:
| |||
last_change_error = exc
| |||
if attempt < 3:
| |||
time.sleep(0.10)
| |||
continue
| |||
raise
| |||
| |||
raise RuntimeError(
| |||
"Could not obtain a stable timetable.csv after three read attempts"
| |||
) from last_change_error
| |||
| |||
def today_session_event(
| |||
now_utc: datetime,
| |||
spec: DailySessionSpec,
| |||
) -> Optional[tuple[str, datetime, str]]:
| |||
"""Build today's event in its market-local calendar, then convert to UTC.
| |||
| |||
Host/VPS local time is never consulted. New York DST is handled by ZoneInfo;
| |||
Tokyo remains fixed at JST through the same timezone-aware mechanism.
| |||
"""
| |||
local_now = now_utc.astimezone(spec.timezone)
| |||
local_day = local_now.date()
| |||
if local_day.weekday() >= 5:
| |||
return None
| |||
local_event = datetime(
| |||
local_day.year,
| |||
local_day.month,
| |||
local_day.day,
| |||
spec.hour,
| |||
spec.minute,
| |||
tzinfo=spec.timezone,
| |||
)
| |||
event_utc = local_event.astimezone(UTC)
| |||
event_id = f"{spec.event_id_prefix}_{local_day:%Y%m%d}"
| |||
return event_id, event_utc, f"{spec.display_name} {local_day.isoformat()}"
| |||
| |||
| |||
def all_daily_session_events(
| |||
now_utc: datetime,
| |||
) -> list[tuple[DailySessionSpec, str, datetime, str]]:
| |||
events: list[tuple[DailySessionSpec, str, datetime, str]] = []
| |||
for spec in DAILY_SESSION_SPECS:
| |||
event = today_session_event(now_utc, spec)
| |||
if event is not None:
| |||
event_id, event_utc, event_name = event
| |||
events.append((spec, event_id, event_utc, event_name))
| |||
return events
| |||
| |||
| |||
def seconds_to_nearest_event(now_utc: datetime) -> float:
| |||
candidates: list[float] = []
| |||
for event in NEWS_EVENTS:
| |||
if event.tradable and event.when_utc >= now_utc - timedelta(seconds=5):
| |||
candidates.append(abs((event.when_utc - now_utc).total_seconds()))
| |||
break
| |||
for _spec, _event_id, event_utc, _event_name in all_daily_session_events(now_utc):
| |||
candidates.append(abs((event_utc - now_utc).total_seconds()))
| |||
return min(candidates) if candidates else 9_999.0
| |||
| |||
| |||
# =============================================================================
| |||
# MT5 CONNECTION AND SYMBOL HELPERS
| |||
# =============================================================================
| |||
| |||
def mt5_error_text() -> str:
| |||
try:
| |||
return repr(mt5.last_error())
| |||
except Exception:
| |||
return "unknown MT5 error"
| |||
| |||
| |||
def connect_mt5() -> None:
| |||
global SYMBOL, SYMBOL_INFO
| |||
| |||
initialized = (
| |||
mt5.initialize(path=MT5_TERMINAL_PATH)
| |||
if MT5_TERMINAL_PATH
| |||
else mt5.initialize()
| |||
)
| |||
if not initialized:
| |||
raise RuntimeError(f"mt5.initialize() failed: {mt5_error_text()}")
| |||
| |||
account = mt5.account_info()
| |||
terminal = mt5.terminal_info()
| |||
if account is None or terminal is None:
| |||
raise RuntimeError(f"MT5 account/terminal unavailable: {mt5_error_text()}")
| |||
if hasattr(terminal, "connected") and not bool(terminal.connected):
| |||
raise RuntimeError("MT5 terminal is open but not connected to the broker server.")
| |||
| |||
if EXPECTED_ACCOUNT_LOGIN is not None and int(account.login) != int(EXPECTED_ACCOUNT_LOGIN):
| |||
raise RuntimeError(
| |||
f"Wrong MT5 account: connected to {account.login}, expected {EXPECTED_ACCOUNT_LOGIN}."
| |||
)
| |||
if EXPECTED_SERVER is not None and str(account.server) != str(EXPECTED_SERVER):
| |||
raise RuntimeError(
| |||
f"Wrong MT5 server: connected to {account.server!r}, expected {EXPECTED_SERVER!r}."
| |||
)
| |||
| |||
hedging_mode = getattr(mt5, "ACCOUNT_MARGIN_MODE_RETAIL_HEDGING", 2)
| |||
if account.margin_mode != hedging_mode:
| |||
raise RuntimeError(
| |||
"A hedging MT5 account is mandatory. This account is netting/exchange mode, "
| |||
"so the five XAUUSD strategy positions cannot be managed independently."
| |||
)
| |||
if REQUIRE_USD_ACCOUNT and str(account.currency).upper() != "USD":
| |||
raise RuntimeError(
| |||
f"Account currency is {account.currency}, but lot formulas are defined for USD balance."
| |||
)
| |||
if not account.trade_allowed or not account.trade_expert:
| |||
raise RuntimeError("Trading or expert trading is disabled on the account.")
| |||
if hasattr(terminal, "trade_allowed") and not terminal.trade_allowed:
| |||
raise RuntimeError("Algo Trading is disabled in the MT5 terminal.")
| |||
| |||
SYMBOL = resolve_symbol(PREFERRED_SYMBOL)
| |||
if not mt5.symbol_select(SYMBOL, True):
| |||
raise RuntimeError(f"Could not select {SYMBOL} in Market Watch: {mt5_error_text()}")
| |||
SYMBOL_INFO = mt5.symbol_info(SYMBOL)
| |||
if SYMBOL_INFO is None:
| |||
raise RuntimeError(f"symbol_info({SYMBOL}) failed: {mt5_error_text()}")
| |||
| |||
LOGGER.info(
| |||
"Connected: account=%s server=%s balance=%.2f currency=%s symbol=%s digits=%d point=%g",
| |||
account.login,
| |||
account.server,
| |||
account.balance,
| |||
account.currency,
| |||
SYMBOL,
| |||
SYMBOL_INFO.digits,
| |||
SYMBOL_INFO.point,
| |||
)
| |||
try:
| |||
current_tick(require_fresh=False)
| |||
except Exception as exc:
| |||
LOGGER.warning(
| |||
"MT5 broker clock offset is not yet available; it will be detected "
| |||
"from the next usable live quote: %s",
| |||
exc,
| |||
)
| |||
| |||
| |||
def ensure_connection() -> bool:
| |||
"""Verify actual broker connectivity, not merely cached terminal objects."""
| |||
global SYMBOL_INFO
| |||
account = mt5.account_info()
| |||
terminal = mt5.terminal_info()
| |||
connected = (
| |||
account is not None
| |||
and terminal is not None
| |||
and (not hasattr(terminal, "connected") or bool(terminal.connected))
| |||
)
| |||
if connected:
| |||
info = mt5.symbol_info(SYMBOL)
| |||
if info is not None:
| |||
SYMBOL_INFO = info
| |||
return True
| |||
| |||
LOGGER.error("MT5 broker connection unavailable; attempting reconnect: %s", mt5_error_text())
| |||
try:
| |||
mt5.shutdown()
| |||
except Exception:
| |||
pass
| |||
try:
| |||
connect_mt5()
| |||
return True
| |||
except Exception:
| |||
LOGGER.exception("MT5 reconnect failed.")
| |||
return False
| |||
| |||
| |||
def resolve_symbol(preferred: str) -> str:
| |||
exact = mt5.symbol_info(preferred)
| |||
if exact is not None:
| |||
return preferred
| |||
| |||
symbols = mt5.symbols_get()
| |||
if not symbols:
| |||
raise RuntimeError(f"No MT5 symbols available: {mt5_error_text()}")
| |||
| |||
preferred_upper = preferred.upper()
| |||
candidates = [s.name for s in symbols if preferred_upper in s.name.upper()]
| |||
if not candidates:
| |||
raise RuntimeError(f"Could not find a symbol containing {preferred!r}.")
| |||
| |||
# Prefer names beginning with XAUUSD and then the shortest broker suffix.
| |||
candidates.sort(key=lambda name: (not name.upper().startswith(preferred_upper), len(name), name))
| |||
chosen = candidates[0]
| |||
LOGGER.warning("Preferred symbol %s not found; using broker symbol %s.", preferred, chosen)
| |||
return chosen
| |||
| |||
| |||
def _mt5_object_time_msc(value: Any) -> int:
| |||
"""Return an MT5 object's timestamp in the broker's own epoch space.
| |||
| |||
RoboForex may expose tick/position/deal timestamps shifted by the broker-server
| |||
UTC offset. Arithmetic between two MT5 timestamps is exact because the same
| |||
offset is present in both values.
| |||
"""
| |||
raw_msc = int(getattr(value, "time_msc", 0) or 0)
| |||
if raw_msc <= 0:
| |||
raw_msc = int(float(getattr(value, "time", 0) or 0) * 1000)
| |||
return raw_msc
| |||
| |||
| |||
def _broker_clock_datetime(value: Any) -> datetime:
| |||
"""Represent an MT5 timestamp as an aware broker-clock datetime.
| |||
| |||
This is only an internal coordinate for MT5 history queries and completed-M1
| |||
comparisons. It is not used for strategy scheduling or presented as true UTC.
| |||
"""
| |||
return datetime.fromtimestamp(_mt5_object_time_msc(value) / 1000.0, UTC)
| |||
| |||
| |||
def _broker_elapsed_seconds(earlier: Any, later: Any) -> float:
| |||
"""Elapsed real seconds between two MT5 objects, independent of timezone."""
| |||
return (_mt5_object_time_msc(later) - _mt5_object_time_msc(earlier)) / 1000.0
| |||
| |||
| |||
def _broker_offset_candidate(clock_tick: Any) -> tuple[int, float]:
| |||
"""Return the live whole-hour broker offset and quote age in seconds.
| |||
| |||
Some RoboForex terminals expose MT5 epoch fields in broker-server wall-clock
| |||
space rather than true UTC. A live quote supplies both the broker epoch and a
| |||
current wall-clock reference, so their difference identifies the active server
| |||
offset without hard-coding summer/winter time.
| |||
"""
| |||
raw_msc = _mt5_object_time_msc(clock_tick)
| |||
if raw_msc <= 0:
| |||
raise RuntimeError(f"Tick for {SYMBOL} has no usable timestamp")
| |||
| |||
wall_msc = int(time.time() * 1000)
| |||
offset_seconds = int(round((raw_msc - wall_msc) / 3_600_000)) * 3600
| |||
quote_age = (wall_msc + offset_seconds * 1000 - raw_msc) / 1000.0
| |||
return offset_seconds, quote_age
| |||
| |||
| |||
def broker_utc_offset_seconds(clock_tick: Any = None) -> int:
| |||
"""Return the dynamically detected broker-clock offset from true UTC.
| |||
| |||
The cached value is refreshed from every usable live tick. It is deliberately
| |||
constrained to a plausible civil-time range so a weekend-old quote cannot be
| |||
mistaken for a multi-day timezone offset.
| |||
"""
| |||
global BROKER_UTC_OFFSET_SECONDS
| |||
| |||
tick = clock_tick if clock_tick is not None else mt5.symbol_info_tick(SYMBOL)
| |||
if tick is None:
| |||
if BROKER_UTC_OFFSET_SECONDS is not None:
| |||
return BROKER_UTC_OFFSET_SECONDS
| |||
raise RuntimeError(f"Cannot detect {SYMBOL} broker clock: {mt5_error_text()}")
| |||
| |||
candidate, quote_age = _broker_offset_candidate(tick)
| |||
if abs(candidate) > MAX_BROKER_UTC_OFFSET_SECONDS:
| |||
if BROKER_UTC_OFFSET_SECONDS is not None:
| |||
return BROKER_UTC_OFFSET_SECONDS
| |||
raise RuntimeError(
| |||
f"Implausible broker UTC offset {candidate / 3600:+.1f}h; "
| |||
f"the latest {SYMBOL} quote is probably too old"
| |||
)
| |||
| |||
if BROKER_UTC_OFFSET_SECONDS != candidate:
| |||
previous = BROKER_UTC_OFFSET_SECONDS
| |||
BROKER_UTC_OFFSET_SECONDS = candidate
| |||
COMPLETED_CLOSE_CACHE.clear()
| |||
M1_RATE_CACHE.clear()
| |||
M1_RATE_RETRY_AFTER.clear()
| |||
M1_RATE_SOURCE_WARNINGS.clear()
| |||
if previous is None:
| |||
LOGGER.info(
| |||
"Detected MT5 broker clock offset UTC%+.0f (quote age %.2fs)",
| |||
candidate / 3600.0,
| |||
quote_age,
| |||
)
| |||
else:
| |||
LOGGER.warning(
| |||
"MT5 broker clock offset changed UTC%+.0f -> UTC%+.0f; "
| |||
"cleared time-dependent caches.",
| |||
previous / 3600.0,
| |||
candidate / 3600.0,
| |||
)
| |||
return candidate
| |||
| |||
| |||
def utc_to_broker_clock(value_utc: datetime, clock_tick: Any = None) -> datetime:
| |||
"""Convert a true UTC datetime into the MT5 broker-clock coordinate."""
| |||
if value_utc.tzinfo is None:
| |||
raise ValueError("UTC-to-broker conversion requires an aware datetime")
| |||
offset = broker_utc_offset_seconds(clock_tick)
| |||
return value_utc.astimezone(UTC) + timedelta(seconds=offset)
| |||
| |||
| |||
def broker_clock_to_utc(value_broker: datetime, clock_tick: Any = None) -> datetime:
| |||
"""Convert an MT5 broker-clock coordinate into true UTC."""
| |||
if value_broker.tzinfo is None:
| |||
raise ValueError("Broker-to-UTC conversion requires an aware datetime")
| |||
offset = broker_utc_offset_seconds(clock_tick)
| |||
return (value_broker.astimezone(UTC) - timedelta(seconds=offset)).astimezone(UTC)
| |||
| |||
| |||
def _mt5_object_utc_datetime(value: Any, clock_tick: Any = None) -> datetime:
| |||
"""Return an MT5 object's broker-shifted epoch as a true UTC datetime."""
| |||
return broker_clock_to_utc(_broker_clock_datetime(value), clock_tick)
| |||
| |||
| |||
class TransientTickError(RuntimeError):
| |||
"""Temporary missing/stale quote condition that is safe to retry."""
| |||
| |||
| |||
def current_tick(require_fresh: bool = True) -> Any:
| |||
tick = mt5.symbol_info_tick(SYMBOL)
| |||
if tick is None or tick.bid <= 0 or tick.ask <= 0:
| |||
raise TransientTickError(f"No valid tick for {SYMBOL}: {mt5_error_text()}")
| |||
| |||
raw_msc = _mt5_object_time_msc(tick)
| |||
offset = broker_utc_offset_seconds(tick)
| |||
wall_msc = int(time.time() * 1000)
| |||
age = (wall_msc + offset * 1000 - raw_msc) / 1000.0
| |||
if require_fresh and abs(age) > MAX_TICK_AGE_SECONDS:
| |||
direction = "old" if age >= 0 else "ahead of wall clock"
| |||
raise TransientTickError(
| |||
f"Stale {SYMBOL} tick: {abs(age):.1f} seconds {direction}"
| |||
)
| |||
return tick
| |||
| |||
| |||
def normalize_price(price: float) -> float:
| |||
return round(float(price), int(SYMBOL_INFO.digits))
| |||
| |||
| |||
def volume_decimals(step: float) -> int:
| |||
text = f"{step:.10f}".rstrip("0")
| |||
return len(text.split(".")[1]) if "." in text else 0
| |||
| |||
| |||
def normalize_volume(raw: float, minimum: float) -> float:
| |||
info = SYMBOL_INFO
| |||
step = float(info.volume_step)
| |||
broker_min = float(info.volume_min)
| |||
broker_max = float(info.volume_max)
| |||
effective_min = max(minimum, broker_min)
| |||
effective_max = min(broker_max, MAX_LOT) if MAX_LOT is not None else broker_max
| |||
normalized_min = math.ceil((effective_min - 1e-12) / step) * step
| |||
normalized_max = math.floor((effective_max + 1e-12) / step) * step
| |||
if normalized_max + 1e-12 < normalized_min:
| |||
raise RuntimeError(
| |||
f"Volume constraints are impossible: min={normalized_min}, max={normalized_max}, step={step}"
| |||
)
| |||
| |||
policy = str(MINIMUM_LOT_POLICY).strip().upper()
| |||
if policy not in {"SKIP", "FORCE_MIN"}:
| |||
raise RuntimeError(
| |||
f"MINIMUM_LOT_POLICY must be 'SKIP' or 'FORCE_MIN', got {MINIMUM_LOT_POLICY!r}"
| |||
)
| |||
| |||
# Floor to avoid exceeding the requested percentage risk.
| |||
floored = math.floor((raw + 1e-12) / step) * step
| |||
if floored + 1e-12 < normalized_min:
| |||
if policy == "SKIP":
| |||
return 0.0
| |||
floored = normalized_min
| |||
| |||
volume = min(floored, normalized_max)
| |||
return round(volume, volume_decimals(step))
| |||
| |||
| |||
def symbol_contract_size() -> float:
| |||
value = float(getattr(SYMBOL_INFO, "trade_contract_size", 0.0) or 0.0)
| |||
if not math.isfinite(value) or value <= 0:
| |||
raise RuntimeError(f"Invalid {SYMBOL} contract size: {value!r}")
| |||
return value
| |||
| |||
| |||
def nominal_risk_per_lot(config: StrategyConfig) -> float:
| |||
return (float(config.initial_sl) + float(RISK_COST_BUFFER_PER_OUNCE)) * symbol_contract_size()
| |||
| |||
| |||
def nominal_risk_for_volume(config: StrategyConfig, volume: float) -> float:
| |||
return nominal_risk_per_lot(config) * float(volume)
| |||
| |||
| |||
def lot_for_config(config: StrategyConfig) -> LotSizingResult:
| |||
account = mt5.account_info()
| |||
if account is None:
| |||
raise RuntimeError(f"account_info() failed: {mt5_error_text()}")
| |||
sizing_balance = float(account.balance)
| |||
if not math.isfinite(sizing_balance) or sizing_balance <= 0:
| |||
raise RuntimeError(f"Invalid account balance for sizing: {sizing_balance!r}")
| |||
if not math.isfinite(config.risk_percent) or config.risk_percent <= 0:
| |||
raise RuntimeError(f"Invalid {config.name} risk percentage: {config.risk_percent!r}")
| |||
| |||
risk_per_lot = nominal_risk_per_lot(config)
| |||
target_risk = sizing_balance * float(config.risk_percent) / 100.0
| |||
raw_lot = target_risk / risk_per_lot
| |||
lot = normalize_volume(raw_lot, config.min_lot)
| |||
actual_risk = nominal_risk_for_volume(config, lot) if lot > 0 else 0.0
| |||
return LotSizingResult(
| |||
sizing_balance=sizing_balance,
| |||
target_risk_usd=target_risk,
| |||
risk_per_lot_usd=risk_per_lot,
| |||
raw_lot=raw_lot,
| |||
lot=lot,
| |||
actual_risk_usd=actual_risk,
| |||
)
| |||
| |||
| |||
def estimated_sl_loss(
| |||
| |||
order_type: int,
| |||
volume: float,
| |||
entry_price: float,
| |||
stop_price: float,
| |||
) -> Optional[float]:
| |||
"""Return broker-calculated loss at the initial SL, when available."""
| |||
try:
| |||
value = mt5.order_calc_profit(
| |||
int(order_type),
| |||
SYMBOL,
| |||
float(volume),
| |||
float(entry_price),
| |||
float(stop_price),
| |||
)
| |||
except Exception:
| |||
return None
| |||
if value is None:
| |||
return None
| |||
return abs(min(0.0, float(value)))
| |||
| |||
| |||
def orders_for_magic(magic: int) -> list[Any]:
| |||
orders = mt5.orders_get(symbol=SYMBOL)
| |||
if orders is None:
| |||
return []
| |||
return [order for order in orders if int(order.magic) == magic]
| |||
| |||
| |||
def positions_for_magic(magic: int) -> list[Any]:
| |||
positions = mt5.positions_get(symbol=SYMBOL)
| |||
if positions is None:
| |||
return []
| |||
return [position for position in positions if int(position.magic) == magic]
| |||
| |||
| |||
def subsystem_busy(config: StrategyConfig) -> bool:
| |||
return bool(orders_for_magic(config.magic) or positions_for_magic(config.magic))
| |||
| |||
| |||
def group_id_from_comment(comment: str, config: StrategyConfig) -> Optional[str]:
| |||
"""Extract the stable prefix+YYMMDDHHMM event group from an MT5 comment."""
| |||
text = str(comment or "").strip()
| |||
expected_length = 1 + 10
| |||
if len(text) < expected_length:
| |||
return None
| |||
candidate = text[:expected_length]
| |||
if candidate[0] != config.group_prefix or not candidate[1:].isdigit():
| |||
return None
| |||
return candidate
| |||
| |||
| |||
def orders_for_group(config: StrategyConfig, group_id: str) -> list[Any]:
| |||
return [
| |||
order
| |||
for order in orders_for_magic(config.magic)
| |||
if str(getattr(order, "comment", "")).strip().startswith(group_id)
| |||
]
| |||
| |||
| |||
def positions_for_group(config: StrategyConfig, group_id: str) -> list[Any]:
| |||
return [
| |||
position
| |||
for position in positions_for_magic(config.magic)
| |||
if str(getattr(position, "comment", "")).strip().startswith(group_id)
| |||
]
| |||
| |||
| |||
def event_setup_busy(config: StrategyConfig, event_utc: datetime) -> bool:
| |||
"""NEWS is busy only for this event minute; sessions remain subsystem-wide."""
| |||
if config.name != "NEWS":
| |||
return subsystem_busy(config)
| |||
group_id = make_group_id(config, event_utc)
| |||
return bool(orders_for_group(config, group_id) or positions_for_group(config, group_id))
| |||
| |||
| |||
def portfolio_initial_risk_snapshot() -> tuple[float, list[str]]:
| |||
"""Return aggregate nominal initial-stop risk for this bot's live groups.
| |||
| |||
An intact pending OCO pair counts once. Duplicate positions are summed. If a
| |||
position and an unremoved sibling order coexist, both are counted because a
| |||
second fill remains possible until reconciliation removes the sibling.
| |||
"""
| |||
total = 0.0
| |||
details: list[str] = []
| |||
| |||
for config in ALL_CONFIGS:
| |||
position_risk: dict[str, float] = {}
| |||
order_risk: dict[str, float] = {}
| |||
| |||
for position in positions_for_magic(config.magic):
| |||
group_id = group_id_from_comment(getattr(position, "comment", ""), config)
| |||
key = group_id or f"P{config.magic}:{int(position.ticket)}"
| |||
risk = nominal_risk_for_volume(config, float(position.volume))
| |||
position_risk[key] = position_risk.get(key, 0.0) + risk
| |||
| |||
for order in orders_for_magic(config.magic):
| |||
group_id = group_id_from_comment(getattr(order, "comment", ""), config)
| |||
key = group_id or f"O{config.magic}:{int(order.ticket)}"
| |||
risk = nominal_risk_for_volume(config, float(order.volume_current))
| |||
# Two intact OCO legs represent one intended position, so use max.
| |||
order_risk[key] = max(order_risk.get(key, 0.0), risk)
| |||
| |||
for key in set(position_risk) | set(order_risk):
| |||
risk = position_risk.get(key, 0.0) + order_risk.get(key, 0.0)
| |||
total += risk
| |||
details.append(f"{config.name}:{key}={risk:.2f}")
| |||
| |||
return total, sorted(details)
| |||
| |||
| |||
def risk_block_log_once(
| |||
event_key: str,
| |||
reason: str,
| |||
message: str,
| |||
*args: Any,
| |||
) -> None:
| |||
key = (event_key, reason)
| |||
if key in RISK_BLOCK_WARNING_KEYS:
| |||
return
| |||
RISK_BLOCK_WARNING_KEYS.add(key)
| |||
LOGGER.warning(message, *args)
| |||
| |||
| |||
def finalize_risk_skip_if_due(
| |||
state: dict[str, Any],
| |||
event_key: str,
| |||
event_name: str,
| |||
event_utc: datetime,
| |||
status: str,
| |||
detail: str,
| |||
) -> None:
| |||
remaining = (event_utc - utc_now()).total_seconds()
| |||
if remaining <= RISK_SKIP_FINALIZE_SECONDS:
| |||
mark_event(state, event_key, status, event_utc, detail)
| |||
LOGGER.warning("Risk sizing skipped %s event %s: %s", status, event_name, detail)
| |||
| |||
| |||
# =============================================================================
| |||
# ORDER SENDING
| |||
# =============================================================================
| |||
| |||
def accepted_retcode(retcode: int) -> bool:
| |||
accepted = {
| |||
getattr(mt5, "TRADE_RETCODE_DONE", 10009),
| |||
getattr(mt5, "TRADE_RETCODE_PLACED", 10008),
| |||
getattr(mt5, "TRADE_RETCODE_DONE_PARTIAL", 10010),
| |||
}
| |||
return int(retcode) in accepted
| |||
| |||
| |||
def connection_retcode(retcode: int) -> bool:
| |||
return int(retcode) == int(getattr(mt5, "TRADE_RETCODE_CONNECTION", 10031))
| |||
| |||
| |||
def transient_retcode(retcode: int) -> bool:
| |||
names = (
| |||
"TRADE_RETCODE_REQUOTE",
| |||
"TRADE_RETCODE_PRICE_CHANGED",
| |||
"TRADE_RETCODE_PRICE_OFF",
| |||
"TRADE_RETCODE_TIMEOUT",
| |||
"TRADE_RETCODE_CONNECTION",
| |||
"TRADE_RETCODE_TOO_MANY_REQUESTS",
| |||
"TRADE_RETCODE_LOCKED",
| |||
)
| |||
values = {getattr(mt5, name, -999999) for name in names}
| |||
return int(retcode) in values
| |||
| |||
| |||
def order_result_text(result: Any) -> str:
| |||
if result is None:
| |||
return f"None, last_error={mt5_error_text()}"
| |||
return (
| |||
f"retcode={result.retcode} comment={getattr(result, 'comment', '')!r} "
| |||
f"order={getattr(result, 'order', 0)} deal={getattr(result, 'deal', 0)} "
| |||
f"price={getattr(result, 'price', 0)}"
| |||
)
| |||
| |||
| |||
def recover_matching_request(request: dict[str, Any]) -> Any:
| |||
"""Recover an order accepted by the server after an ambiguous client timeout.
| |||
| |||
Retrying an order_send blindly after a timeout can create a duplicate leg. Every
| |||
pending leg has a unique magic/comment pair, so inspect live orders and positions
| |||
before any retry. A lightweight result object is returned when the request is
| |||
already present on the server.
| |||
"""
| |||
comment = str(request.get("comment", "")).strip()
| |||
magic = int(request.get("magic", 0) or 0)
| |||
symbol = str(request.get("symbol", SYMBOL))
| |||
if not comment or magic <= 0:
| |||
return None
| |||
| |||
request_type = int(request.get("type", -1))
| |||
orders = mt5.orders_get(symbol=symbol)
| |||
if orders is not None:
| |||
for order in orders:
| |||
if int(getattr(order, "magic", 0)) != magic:
| |||
continue
| |||
if str(getattr(order, "comment", "")).strip() != comment:
| |||
continue
| |||
if request_type >= 0 and int(getattr(order, "type", -2)) != request_type:
| |||
continue
| |||
LOGGER.warning(
| |||
"Recovered already accepted pending order after ambiguous send: "
| |||
"ticket=%s comment=%s",
| |||
order.ticket,
| |||
comment,
| |||
)
| |||
return SimpleNamespace(
| |||
retcode=getattr(mt5, "TRADE_RETCODE_PLACED", 10008),
| |||
comment="recovered existing pending order",
| |||
order=int(order.ticket),
| |||
deal=0,
| |||
price=float(getattr(order, "price_open", 0.0)),
| |||
)
| |||
| |||
positions = mt5.positions_get(symbol=symbol)
| |||
if positions is not None:
| |||
buy_types = {
| |||
int(getattr(mt5, "ORDER_TYPE_BUY", 0)),
| |||
int(getattr(mt5, "ORDER_TYPE_BUY_LIMIT", 2)),
| |||
int(getattr(mt5, "ORDER_TYPE_BUY_STOP", 4)),
| |||
int(getattr(mt5, "ORDER_TYPE_BUY_STOP_LIMIT", 6)),
| |||
}
| |||
expected_position_type = (
| |||
int(getattr(mt5, "POSITION_TYPE_BUY", 0))
| |||
if request_type in buy_types
| |||
else int(getattr(mt5, "POSITION_TYPE_SELL", 1))
| |||
)
| |||
for position in positions:
| |||
if int(getattr(position, "magic", 0)) != magic:
| |||
continue
| |||
if str(getattr(position, "comment", "")).strip() != comment:
| |||
continue
| |||
if int(getattr(position, "type", -2)) != expected_position_type:
| |||
continue
| |||
LOGGER.warning(
| |||
"Recovered position filled after ambiguous order send: "
| |||
"ticket=%s comment=%s",
| |||
position.ticket,
| |||
comment,
| |||
)
| |||
return SimpleNamespace(
| |||
retcode=getattr(mt5, "TRADE_RETCODE_DONE", 10009),
| |||
comment="recovered filled position",
| |||
order=int(position.ticket),
| |||
deal=0,
| |||
price=float(getattr(position, "price_open", 0.0)),
| |||
)
| |||
return None
| |||
| |||
| |||
def check_and_send(request: dict[str, Any], retries: int = 1) -> Any:
| |||
"""Validate and send one request without duplicating ambiguous fills.
| |||
| |||
A broker-connection failure is not a filling-policy problem. It aborts this
| |||
request immediately, resets the Python/terminal bridge, and lets the main loop
| |||
reconnect and retry the complete pair while the placement window remains open.
| |||
"""
| |||
last_result = None
| |||
for attempt in range(retries + 1):
| |||
recovered = recover_matching_request(request)
| |||
if recovered is not None:
| |||
return recovered
| |||
| |||
check = mt5.order_check(request)
| |||
if check is None:
| |||
LOGGER.error("order_check returned None: %s", mt5_error_text())
| |||
terminal = mt5.terminal_info()
| |||
if terminal is None or (
| |||
hasattr(terminal, "connected") and not bool(terminal.connected)
| |||
):
| |||
try:
| |||
mt5.shutdown()
| |||
except Exception:
| |||
pass
| |||
return SimpleNamespace(
| |||
retcode=getattr(mt5, "TRADE_RETCODE_CONNECTION", 10031),
| |||
comment="order_check unavailable: broker connection lost",
| |||
order=0,
| |||
deal=0,
| |||
price=0.0,
| |||
)
| |||
return None
| |||
if int(check.retcode) != 0:
| |||
LOGGER.error(
| |||
"order_check rejected request: retcode=%s comment=%r request=%s",
| |||
check.retcode,
| |||
check.comment,
| |||
request,
| |||
)
| |||
if connection_retcode(int(check.retcode)):
| |||
try:
| |||
mt5.shutdown()
| |||
except Exception:
| |||
pass
| |||
return check
| |||
return None
| |||
| |||
last_result = mt5.order_send(request)
| |||
if last_result is not None and accepted_retcode(last_result.retcode):
| |||
return last_result
| |||
| |||
LOGGER.error("order_send failed: %s request=%s", order_result_text(last_result), request)
| |||
| |||
if last_result is not None and connection_retcode(last_result.retcode):
| |||
LOGGER.error(
| |||
"Broker connection failure during order submission; "
| |||
"aborting policy fallbacks and forcing reconnect."
| |||
)
| |||
try:
| |||
mt5.shutdown()
| |||
except Exception:
| |||
pass
| |||
return last_result
| |||
| |||
# A timeout can mean acceptance with a lost acknowledgement. Inspect the
| |||
# server before retrying; comments/magic numbers make recovery idempotent.
| |||
time.sleep(0.10 * (attempt + 1))
| |||
recovered = recover_matching_request(request)
| |||
if recovered is not None:
| |||
return recovered
| |||
| |||
if (
| |||
last_result is not None
| |||
and not transient_retcode(last_result.retcode)
| |||
) or attempt >= retries:
| |||
break
| |||
return last_result
| |||
| |||
| |||
def send_pending_request(request: dict[str, Any]) -> Any:
| |||
"""Send a pending leg with broker-compatible expiry/filling fallbacks.
| |||
| |||
Specified server expiry is preferred. If unsupported, the bot uses GTC and
| |||
enforces the same expiry locally. Filling policies vary by broker/symbol, so
| |||
RETURN, IOC and FOK are tried without ever duplicating a successfully accepted
| |||
magic/comment pair.
| |||
"""
| |||
time_variants: list[dict[str, Any]] = [dict(request)]
| |||
if request.get("type_time") == mt5.ORDER_TIME_SPECIFIED:
| |||
fallback = dict(request)
| |||
fallback["type_time"] = mt5.ORDER_TIME_GTC
| |||
fallback.pop("expiration", None)
| |||
time_variants.append(fallback)
| |||
| |||
filling_candidates = [
| |||
request.get("type_filling", getattr(mt5, "ORDER_FILLING_RETURN", 2)),
| |||
getattr(mt5, "ORDER_FILLING_RETURN", 2),
| |||
getattr(mt5, "ORDER_FILLING_IOC", 1),
| |||
getattr(mt5, "ORDER_FILLING_FOK", 0),
| |||
]
| |||
last_result = None
| |||
warned_gtc = False
| |||
tried: set[tuple[int, int]] = set()
| |||
| |||
for variant in time_variants:
| |||
if variant.get("type_time") == mt5.ORDER_TIME_GTC and not warned_gtc:
| |||
LOGGER.warning(
| |||
"Specified pending-order expiration is unavailable; trying GTC. "
| |||
"The bot will enforce expiry locally."
| |||
)
| |||
warned_gtc = True
| |||
for filling in filling_candidates:
| |||
key = (int(variant.get("type_time", -1)), int(filling))
| |||
if key in tried:
| |||
continue
| |||
tried.add(key)
| |||
candidate = dict(variant)
| |||
candidate["type_filling"] = int(filling)
| |||
last_result = check_and_send(candidate, retries=1)
| |||
if last_result is not None and accepted_retcode(last_result.retcode):
| |||
return last_result
| |||
if last_result is not None and connection_retcode(last_result.retcode):
| |||
return last_result
| |||
| |||
return last_result
| |||
| |||
| |||
def remove_pending_order(order: Any, reason: str) -> bool:
| |||
request = {
| |||
"action": mt5.TRADE_ACTION_REMOVE,
| |||
"order": int(order.ticket),
| |||
"symbol": SYMBOL,
| |||
"magic": int(order.magic),
| |||
"comment": reason[:31],
| |||
}
| |||
result = mt5.order_send(request)
| |||
ok = result is not None and accepted_retcode(result.retcode)
| |||
if ok:
| |||
LOGGER.info("Removed pending order ticket=%s reason=%s", order.ticket, reason)
| |||
else:
| |||
LOGGER.error(
| |||
"Failed to remove pending order ticket=%s: %s",
| |||
order.ticket,
| |||
order_result_text(result),
| |||
)
| |||
return ok
| |||
| |||
| |||
def cancel_all_pending(config: StrategyConfig, reason: str) -> None:
| |||
for order in orders_for_magic(config.magic):
| |||
remove_pending_order(order, reason)
| |||
| |||
| |||
def pair_comment(group_id: str, side: str) -> str:
| |||
# Keep below the typical MT5 31-character comment limit.
| |||
return f"{group_id}{side}"[:31]
| |||
| |||
| |||
def make_group_id(config: StrategyConfig, event_utc: datetime) -> str:
| |||
# N2608071230 / Y2608071230, plus B/S in the order comment.
| |||
return f"{config.group_prefix}{event_utc:%y%m%d%H%M}"
| |||
| |||
| |||
def place_oco_pair(
| |||
config: StrategyConfig,
| |||
event_id: str,
| |||
event_name: str,
| |||
event_utc: datetime,
| |||
state: dict[str, Any],
| |||
) -> bool:
| |||
group_id = make_group_id(config, event_utc)
| |||
if event_setup_busy(config, event_utc):
| |||
return False
| |||
| |||
tick = current_tick(require_fresh=True)
| |||
expiry_utc = event_utc + timedelta(seconds=config.expiry_seconds)
| |||
| |||
buy_price = normalize_price(float(tick.ask) + config.offset)
| |||
sell_price = normalize_price(float(tick.bid) - config.offset)
| |||
buy_sl = normalize_price(buy_price - config.initial_sl)
| |||
sell_sl = normalize_price(sell_price + config.initial_sl)
| |||
| |||
sizing = lot_for_config(config)
| |||
lot = sizing.lot
| |||
balance = sizing.sizing_balance
| |||
if lot <= 0:
| |||
detail = (
| |||
f"{config.name} raw lot {sizing.raw_lot:.4f} is below broker/configured "
| |||
f"minimum {max(config.min_lot, float(SYMBOL_INFO.volume_min)):.2f}; "
| |||
f"policy={MINIMUM_LOT_POLICY}"
| |||
)
| |||
risk_block_log_once(
| |||
event_id,
| |||
"minimum_lot",
| |||
"%s event %s waiting/skipping: %s",
| |||
config.name,
| |||
event_name,
| |||
detail,
| |||
)
| |||
finalize_risk_skip_if_due(
| |||
state, event_id, event_name, event_utc, "skipped_minimum_lot", detail
| |||
)
| |||
return False
| |||
| |||
current_portfolio_risk, risk_details = portfolio_initial_risk_snapshot()
| |||
cap_usd = balance * PORTFOLIO_RISK_CAP_PERCENT / 100.0
| |||
projected_risk = current_portfolio_risk + sizing.actual_risk_usd
| |||
if projected_risk > cap_usd + 1e-9:
| |||
detail = (
| |||
f"current={current_portfolio_risk:.2f}, proposed={sizing.actual_risk_usd:.2f}, "
| |||
f"projected={projected_risk:.2f}, cap={cap_usd:.2f} "
| |||
f"({PORTFOLIO_RISK_CAP_PERCENT:.2f}% of balance {balance:.2f})"
| |||
)
| |||
risk_block_log_once(
| |||
event_id,
| |||
"portfolio_cap",
| |||
"%s event %s blocked by portfolio risk cap: %s | live=%s",
| |||
config.name,
| |||
event_name,
| |||
detail,
| |||
"; ".join(risk_details) if risk_details else "none",
| |||
)
| |||
finalize_risk_skip_if_due(
| |||
state, event_id, event_name, event_utc, "skipped_portfolio_risk", detail
| |||
)
| |||
return False
| |||
| |||
buy_risk = estimated_sl_loss(mt5.ORDER_TYPE_BUY, lot, buy_price, buy_sl)
| |||
sell_risk = estimated_sl_loss(mt5.ORDER_TYPE_SELL, lot, sell_price, sell_sl)
| |||
broker_estimated_risk = (
| |||
max(value for value in (buy_risk, sell_risk) if value is not None)
| |||
if buy_risk is not None or sell_risk is not None
| |||
else None
| |||
)
| |||
LOGGER.info(
| |||
"%s risk sizing | target=%.2f%% | balance=%.2f | raw=%.4f | lot=%.2f | "
| |||
"nominal initial risk=%.2f (%.2f%%) | portfolio %.2f -> %.2f / %.2f",
| |||
config.name,
| |||
config.risk_percent,
| |||
balance,
| |||
sizing.raw_lot,
| |||
lot,
| |||
sizing.actual_risk_usd,
| |||
100.0 * sizing.actual_risk_usd / balance,
| |||
current_portfolio_risk,
| |||
projected_risk,
| |||
cap_usd,
| |||
)
| |||
if broker_estimated_risk is not None:
| |||
LOGGER.info(
| |||
"%s broker order_calc_profit initial-SL estimate=%.2f; sizing model with "
| |||
"$%.2f/oz cost buffer=%.2f",
| |||
config.name,
| |||
broker_estimated_risk,
| |||
RISK_COST_BUFFER_PER_OUNCE,
| |||
sizing.actual_risk_usd,
| |||
)
| |||
| |||
min_distance = max(
| |||
int(getattr(SYMBOL_INFO, "trade_stops_level", 0)),
| |||
int(getattr(SYMBOL_INFO, "trade_freeze_level", 0)),
| |||
) * float(SYMBOL_INFO.point)
| |||
if buy_price - float(tick.ask) < min_distance or float(tick.bid) - sell_price < min_distance:
| |||
LOGGER.error(
| |||
"%s pair invalid: broker minimum pending distance %.5f exceeds configured offset %.5f",
| |||
config.name,
| |||
min_distance,
| |||
config.offset,
| |||
)
| |||
return False
| |||
| |||
expiry_broker = utc_to_broker_clock(expiry_utc, tick)
| |||
base = {
| |||
"action": mt5.TRADE_ACTION_PENDING,
| |||
"symbol": SYMBOL,
| |||
"volume": lot,
| |||
"deviation": MAX_DEVIATION_POINTS,
| |||
"magic": config.magic,
| |||
"type_time": mt5.ORDER_TIME_SPECIFIED,
| |||
"expiration": int(expiry_broker.timestamp()),
| |||
"type_filling": mt5.ORDER_FILLING_RETURN,
| |||
}
| |||
buy_request = {
| |||
**base,
| |||
"type": mt5.ORDER_TYPE_BUY_STOP,
| |||
"price": buy_price,
| |||
"sl": buy_sl,
| |||
"tp": 0.0,
| |||
"comment": pair_comment(group_id, "B"),
| |||
}
| |||
sell_request = {
| |||
**base,
| |||
"type": mt5.ORDER_TYPE_SELL_STOP,
| |||
"price": sell_price,
| |||
"sl": sell_sl,
| |||
"tp": 0.0,
| |||
"comment": pair_comment(group_id, "S"),
| |||
}
| |||
| |||
buy_result = send_pending_request(buy_request)
| |||
if buy_result is None or not accepted_retcode(buy_result.retcode):
| |||
return False
| |||
| |||
sell_result = send_pending_request(sell_request)
| |||
if sell_result is None or not accepted_retcode(sell_result.retcode):
| |||
LOGGER.critical(
| |||
"%s OCO second leg failed after first leg accepted. group=%s first=%s second=%s",
| |||
config.name,
| |||
group_id,
| |||
order_result_text(buy_result),
| |||
order_result_text(sell_result),
| |||
)
| |||
# Cancel the first leg if it is still pending. If it filled during the tiny
| |||
# submission interval, it remains protected by its server-side initial SL.
| |||
for order in orders_for_group(config, group_id):
| |||
remove_pending_order(order, "PAIR_FAIL")
| |||
return bool(positions_for_group(config, group_id))
| |||
| |||
group_record = {
| |||
"strategy": config.name,
| |||
"event_id": event_id,
| |||
"event_name": event_name,
| |||
"event_utc": format_utc(event_utc),
| |||
"expiry_utc": format_utc(expiry_utc),
| |||
"lot": lot,
| |||
"risk_percent": config.risk_percent,
| |||
"sizing_balance": balance,
| |||
"raw_lot": sizing.raw_lot,
| |||
"initial_risk_usd": sizing.actual_risk_usd,
| |||
"portfolio_risk_before_usd": current_portfolio_risk,
| |||
"portfolio_risk_after_usd": projected_risk,
| |||
"portfolio_risk_cap_usd": cap_usd,
| |||
"reference_bid": float(tick.bid),
| |||
"reference_ask": float(tick.ask),
| |||
"buy_order": int(getattr(buy_result, "order", 0)),
| |||
"sell_order": int(getattr(sell_result, "order", 0)),
| |||
}
| |||
state["active_groups"][group_id] = group_record
| |||
save_state(state)
| |||
| |||
LOGGER.info(
| |||
"%s OCO placed | event=%s | group=%s | lot=%.2f | ref=%.2f/%.2f | "
| |||
"buy_stop=%.2f SL=%.2f | sell_stop=%.2f SL=%.2f | expiry=%s",
| |||
config.name,
| |||
event_name,
| |||
group_id,
| |||
lot,
| |||
tick.bid,
| |||
tick.ask,
| |||
buy_price,
| |||
buy_sl,
| |||
sell_price,
| |||
sell_sl,
| |||
format_utc(expiry_utc),
| |||
)
| |||
return True
| |||
| |||
| |||
def close_position(position: Any, comment: str) -> bool:
| |||
tick = current_tick(require_fresh=True)
| |||
position_type_buy = int(position.type) == int(mt5.POSITION_TYPE_BUY)
| |||
order_type = mt5.ORDER_TYPE_SELL if position_type_buy else mt5.ORDER_TYPE_BUY
| |||
price = float(tick.bid) if position_type_buy else float(tick.ask)
| |||
| |||
# RoboForex market execution usually accepts IOC; retry supported policies.
| |||
filling_candidates = [
| |||
getattr(mt5, "ORDER_FILLING_IOC", 1),
| |||
getattr(mt5, "ORDER_FILLING_FOK", 0),
| |||
getattr(mt5, "ORDER_FILLING_RETURN", 2),
| |||
]
| |||
tried: set[int] = set()
| |||
for filling in filling_candidates:
| |||
if filling in tried:
| |||
continue
| |||
tried.add(filling)
| |||
request = {
| |||
"action": mt5.TRADE_ACTION_DEAL,
| |||
"symbol": SYMBOL,
| |||
"position": int(position.ticket),
| |||
"volume": float(position.volume),
| |||
"type": order_type,
| |||
"price": normalize_price(price),
| |||
"deviation": MAX_DEVIATION_POINTS,
| |||
"magic": int(position.magic),
| |||
"comment": comment[:31],
| |||
"type_time": mt5.ORDER_TIME_GTC,
| |||
"type_filling": filling,
| |||
}
| |||
result = mt5.order_send(request)
| |||
if result is not None and accepted_retcode(result.retcode):
| |||
LOGGER.info(
| |||
"Closed position ticket=%s strategy=%s reason=%s result=%s",
| |||
position.ticket,
| |||
CONFIG_BY_MAGIC.get(int(position.magic), NEWS_CONFIG).name,
| |||
comment,
| |||
order_result_text(result),
| |||
)
| |||
return True
| |||
LOGGER.error(
| |||
"Close attempt failed ticket=%s filling=%s: %s",
| |||
position.ticket,
| |||
filling,
| |||
order_result_text(result),
| |||
)
| |||
time.sleep(0.1)
| |||
tick = current_tick(require_fresh=True)
| |||
price = float(tick.bid) if position_type_buy else float(tick.ask)
| |||
return False
| |||
| |||
| |||
def modify_position_sl(position: Any, new_sl: float) -> bool:
| |||
request = {
| |||
"action": mt5.TRADE_ACTION_SLTP,
| |||
"symbol": SYMBOL,
| |||
"position": int(position.ticket),
| |||
"sl": normalize_price(new_sl),
| |||
"tp": float(position.tp) if float(position.tp) > 0 else 0.0,
| |||
"magic": int(position.magic),
| |||
"comment": "TRAIL",
| |||
}
| |||
result = mt5.order_send(request)
| |||
ok = result is not None and accepted_retcode(result.retcode)
| |||
if ok:
| |||
LOGGER.info(
| |||
"Position SL modified ticket=%s old_sl=%.2f new_sl=%.2f",
| |||
position.ticket,
| |||
float(position.sl),
| |||
new_sl,
| |||
)
| |||
else:
| |||
LOGGER.error(
| |||
"Position SL modification failed ticket=%s new_sl=%.2f: %s",
| |||
position.ticket,
| |||
new_sl,
| |||
order_result_text(result),
| |||
)
| |||
return ok
| |||
| |||
| |||
def ensure_initial_protection(position: Any, config: StrategyConfig) -> bool:
| |||
"""Ensure a recovered/live position never remains without its initial SL.
| |||
| |||
Pending orders normally install the SL server-side. This guard repairs a
| |||
missing or manually widened SL after a restart. If the market is already at
| |||
or beyond the intended stop, the position is closed instead of widening risk.
| |||
"""
| |||
buy = int(position.type) == int(mt5.POSITION_TYPE_BUY)
| |||
target = normalize_price(
| |||
float(position.price_open) - config.initial_sl
| |||
if buy
| |||
else float(position.price_open) + config.initial_sl
| |||
)
| |||
old_sl = float(getattr(position, "sl", 0.0) or 0.0)
| |||
point = float(SYMBOL_INFO.point)
| |||
protected = (old_sl >= target - point / 2) if buy else (0 < old_sl <= target + point / 2)
| |||
if protected:
| |||
return True
| |||
| |||
tick = current_tick(require_fresh=True)
| |||
market = float(tick.bid) if buy else float(tick.ask)
| |||
if (buy and market <= target) or ((not buy) and market >= target):
| |||
LOGGER.critical(
| |||
"%s position ticket=%s lacks its required initial SL and market has "
| |||
"already crossed it; closing immediately.",
| |||
config.name,
| |||
position.ticket,
| |||
)
| |||
close_position(position, f"{config.name}_INITIAL_GAP")
| |||
return False
| |||
| |||
min_distance = max(
| |||
int(getattr(SYMBOL_INFO, "trade_stops_level", 0)),
| |||
int(getattr(SYMBOL_INFO, "trade_freeze_level", 0)),
| |||
) * point
| |||
if (buy and target > market - min_distance) or ((not buy) and target < market + min_distance):
| |||
LOGGER.critical(
| |||
"%s position ticket=%s cannot restore the intended initial SL without "
| |||
"widening risk; closing immediately.",
| |||
config.name,
| |||
position.ticket,
| |||
)
| |||
close_position(position, f"{config.name}_SL_REPAIR")
| |||
return False
| |||
| |||
LOGGER.warning(
| |||
"%s position ticket=%s has missing/wider SL %.2f; restoring %.2f.",
| |||
config.name,
| |||
position.ticket,
| |||
old_sl,
| |||
target,
| |||
)
| |||
return modify_position_sl(position, target)
| |||
| |||
| |||
# =============================================================================
| |||
# COMPLETED-M1 TICK CLOSES AND TRAILING
| |||
# =============================================================================
| |||
| |||
def minute_floor(dt: datetime) -> datetime:
| |||
return dt.astimezone(UTC).replace(second=0, microsecond=0)
| |||
| |||
| |||
def completed_minute_close(boundary_broker: datetime) -> Optional[tuple[datetime, float, float]]:
| |||
"""Return the final bid/ask of the completed broker-clock M1 candle."""
| |||
boundary = minute_floor(boundary_broker)
| |||
key = int(boundary.timestamp())
| |||
if key in COMPLETED_CLOSE_CACHE:
| |||
return COMPLETED_CLOSE_CACHE[key]
| |||
| |||
start = boundary - timedelta(seconds=90)
| |||
end = boundary - timedelta(microseconds=1)
| |||
ticks = mt5.copy_ticks_range(SYMBOL, start, end, mt5.COPY_TICKS_ALL)
| |||
if ticks is None or len(ticks) == 0:
| |||
LOGGER.warning(
| |||
"No ticks available for completed broker minute ending %s",
| |||
boundary.isoformat(),
| |||
)
| |||
COMPLETED_CLOSE_CACHE.clear()
| |||
COMPLETED_CLOSE_CACHE[key] = None
| |||
return None
| |||
| |||
last = ticks[-1]
| |||
last_msc = int(last["time_msc"])
| |||
completed_start_msc = int((boundary - timedelta(minutes=1)).timestamp() * 1000)
| |||
if last_msc < completed_start_msc:
| |||
LOGGER.warning(
| |||
"No tick belongs to completed broker minute %s; refusing to carry an older quote forward.",
| |||
(boundary - timedelta(minutes=1)).isoformat(),
| |||
)
| |||
COMPLETED_CLOSE_CACHE.clear()
| |||
COMPLETED_CLOSE_CACHE[key] = None
| |||
return None
| |||
bid = float(last["bid"])
| |||
ask = float(last["ask"])
| |||
if bid <= 0 or ask <= 0:
| |||
COMPLETED_CLOSE_CACHE.clear()
| |||
COMPLETED_CLOSE_CACHE[key] = None
| |||
return None
| |||
result = (boundary - timedelta(minutes=1), bid, ask)
| |||
COMPLETED_CLOSE_CACHE.clear() # only the current boundary is useful live
| |||
COMPLETED_CLOSE_CACHE[key] = result
| |||
return result
| |||
| |||
| |||
| |||
@dataclass(frozen=True)
| |||
class CompletedM1Bar:
| |||
minute_ts: int
| |||
open: float
| |||
high: float
| |||
low: float
| |||
close: float
| |||
| |||
| |||
def _clamp(value: float, lower: float, upper: float) -> float:
| |||
value = max(float(lower), float(value))
| |||
if float(upper) > 0:
| |||
value = min(float(upper), value)
| |||
return value
| |||
| |||
| |||
def completed_m1_rates(boundary_broker: datetime) -> Optional[list[CompletedM1Bar]]:
| |||
"""Return completed M1 bid bars ending before a broker-clock boundary.
| |||
| |||
This RoboForex terminal exposes rate epochs in the same broker-clock coordinate
| |||
as live ticks and positions. Querying in that coordinate prevents a UTC+2/+3
| |||
server offset from making the newest completed bars appear unavailable.
| |||
"""
| |||
boundary = minute_floor(boundary_broker)
| |||
key = int(boundary.timestamp())
| |||
if key in M1_RATE_CACHE:
| |||
return M1_RATE_CACHE[key]
| |||
if time.monotonic() < M1_RATE_RETRY_AFTER.get(key, 0.0):
| |||
return None
| |||
| |||
start = boundary - timedelta(days=1)
| |||
end = boundary - timedelta(microseconds=1)
| |||
rates = mt5.copy_rates_range(SYMBOL, mt5.TIMEFRAME_M1, start, end)
| |||
if rates is None or len(rates) == 0:
| |||
if key not in M1_RATE_SOURCE_WARNINGS:
| |||
M1_RATE_SOURCE_WARNINGS.add(key)
| |||
LOGGER.warning(
| |||
"No completed M1 rates available through broker boundary %s: %s; retrying.",
| |||
boundary.isoformat(),
| |||
mt5_error_text(),
| |||
)
| |||
M1_RATE_RETRY_AFTER[key] = time.monotonic() + 1.0
| |||
return None
| |||
| |||
by_minute: dict[int, CompletedM1Bar] = {}
| |||
for rate in rates:
| |||
minute_ts = int(rate["time"])
| |||
if minute_ts >= key:
| |||
continue
| |||
open_price = float(rate["open"])
| |||
high_price = float(rate["high"])
| |||
low_price = float(rate["low"])
| |||
close_price = float(rate["close"])
| |||
if min(open_price, high_price, low_price, close_price) <= 0:
| |||
continue
| |||
by_minute[minute_ts] = CompletedM1Bar(
| |||
minute_ts=minute_ts,
| |||
open=open_price,
| |||
high=high_price,
| |||
low=low_price,
| |||
close=close_price,
| |||
)
| |||
| |||
bars = [by_minute[minute] for minute in sorted(by_minute)]
| |||
expected_last = key - 60
| |||
if not bars or bars[-1].minute_ts < expected_last:
| |||
if key not in M1_RATE_SOURCE_WARNINGS:
| |||
M1_RATE_SOURCE_WARNINGS.add(key)
| |||
LOGGER.warning(
| |||
"Latest completed M1 rate is stale at broker boundary %s; latest=%s; retrying.",
| |||
boundary.isoformat(),
| |||
datetime.fromtimestamp(bars[-1].minute_ts, UTC).isoformat() if bars else None,
| |||
)
| |||
M1_RATE_RETRY_AFTER[key] = time.monotonic() + 1.0
| |||
return None
| |||
| |||
M1_RATE_CACHE.clear() # live calculations need only the current/frozen boundary
| |||
M1_RATE_RETRY_AFTER.clear()
| |||
M1_RATE_SOURCE_WARNINGS.discard(key)
| |||
M1_RATE_CACHE[key] = bars
| |||
return bars
| |||
| |||
| |||
def volatility_from_bars(
| |||
bars: list[CompletedM1Bar],
| |||
estimator: str,
| |||
window: int,
| |||
) -> Optional[float]:
| |||
"""Calculate the exact estimator definitions used in the brute-force study."""
| |||
if window <= 0 or len(bars) < window + 1:
| |||
return None
| |||
| |||
estimator = str(estimator).upper()
| |||
closes = [bar.close for bar in bars]
| |||
| |||
if estimator == "CLOSE_RMS":
| |||
changes = [
| |||
closes[index] - closes[index - 1]
| |||
for index in range(len(closes) - window, len(closes))
| |||
]
| |||
return math.sqrt(sum(change * change for change in changes) / window)
| |||
| |||
true_ranges: list[float] = []
| |||
for index in range(1, len(bars)):
| |||
bar = bars[index]
| |||
previous_close = bars[index - 1].close
| |||
true_ranges.append(
| |||
max(
| |||
bar.high - bar.low,
| |||
abs(bar.high - previous_close),
| |||
abs(bar.low - previous_close),
| |||
)
| |||
)
| |||
if len(true_ranges) < window:
| |||
return None
| |||
| |||
if estimator == "TR_SMA":
| |||
values = true_ranges[-window:]
| |||
return sum(values) / window
| |||
| |||
if estimator == "TR_MEDIAN":
| |||
values = sorted(true_ranges[-window:])
| |||
middle = window // 2
| |||
if window % 2:
| |||
return values[middle]
| |||
return (values[middle - 1] + values[middle]) / 2.0
| |||
| |||
if estimator == "ATR_WILDER":
| |||
# Conventional Wilder ATR seed followed by recursive smoothing. The
| |||
# four-day history above makes the result effectively independent of the
| |||
# seed for the selected 30-minute window.
| |||
atr = sum(true_ranges[:window]) / window
| |||
for true_range in true_ranges[window:]:
| |||
atr += (true_range - atr) / window
| |||
return atr
| |||
| |||
raise ValueError(f"Unsupported volatility estimator: {estimator!r}")
| |||
| |||
| |||
def volatility_at_boundary(
| |||
boundary_broker: datetime,
| |||
estimator: str,
| |||
window: int,
| |||
) -> Optional[float]:
| |||
bars = completed_m1_rates(boundary_broker)
| |||
if bars is None:
| |||
return None
| |||
return volatility_from_bars(bars, estimator, window)
| |||
| |||
| |||
def frozen_activation_for_event(
| |||
config: StrategyConfig,
| |||
event_utc: datetime,
| |||
clock_tick: Any = None,
| |||
) -> Optional[float]:
| |||
if not config.activation_estimator or config.activation_window <= 0:
| |||
return None
| |||
event_broker = utc_to_broker_clock(event_utc, clock_tick)
| |||
volatility = volatility_at_boundary(
| |||
event_broker,
| |||
config.activation_estimator,
| |||
config.activation_window,
| |||
)
| |||
if volatility is None or not math.isfinite(volatility):
| |||
return None
| |||
return _clamp(
| |||
config.activation_multiplier * volatility,
| |||
config.activation_floor,
| |||
config.activation_cap,
| |||
)
| |||
| |||
| |||
def _group_event_utc(group_id: str, group: Any) -> Optional[datetime]:
| |||
if isinstance(group, dict) and group.get("event_utc"):
| |||
try:
| |||
return parse_utc(str(group["event_utc"]))
| |||
except Exception:
| |||
pass
| |||
try:
| |||
return datetime.strptime(group_id[1:11], "%y%m%d%H%M").replace(tzinfo=UTC)
| |||
except Exception:
| |||
return None
| |||
| |||
| |||
def effective_trail_activation(
| |||
position: Any,
| |||
config: StrategyConfig,
| |||
state: dict[str, Any],
| |||
clock_tick: Any,
| |||
) -> float:
| |||
"""Return fixed activation, or the frozen pre-event NYSE activation."""
| |||
if not config.activation_estimator or config.activation_window <= 0:
| |||
return config.trail_activation
| |||
| |||
group_id = group_id_from_comment(getattr(position, "comment", ""), config)
| |||
groups = state.setdefault("active_groups", {})
| |||
group = groups.get(group_id) if group_id else None
| |||
if isinstance(group, dict):
| |||
stored = group.get("frozen_trail_activation")
| |||
if stored is not None:
| |||
try:
| |||
value = float(stored)
| |||
if math.isfinite(value) and value > 0:
| |||
return value
| |||
except (TypeError, ValueError):
| |||
pass
| |||
| |||
event_utc = _group_event_utc(group_id, group) if group_id else None
| |||
if event_utc is None:
| |||
return config.trail_activation
| |||
calculated = frozen_activation_for_event(config, event_utc, clock_tick)
| |||
if calculated is None:
| |||
warning_key = (f"{config.name}_ACTIVATION", int(event_utc.timestamp()))
| |||
if warning_key not in VOLATILITY_WARNING_KEYS:
| |||
VOLATILITY_WARNING_KEYS.add(warning_key)
| |||
LOGGER.warning(
| |||
"%s frozen activation unavailable; using fixed fallback %.2f",
| |||
config.name,
| |||
config.trail_activation,
| |||
)
| |||
return config.trail_activation
| |||
| |||
if isinstance(group, dict):
| |||
group["frozen_trail_activation"] = calculated
| |||
save_state(state)
| |||
return calculated
| |||
| |||
| |||
def effective_trail_distance(
| |||
config: StrategyConfig,
| |||
boundary_broker: datetime,
| |||
broker_min_distance: float,
| |||
) -> float:
| |||
"""Return deployment-safe adaptive distance, never below broker constraints."""
| |||
distance = config.trail_distance
| |||
if config.trail_estimator and config.trail_window > 0:
| |||
volatility = volatility_at_boundary(
| |||
boundary_broker,
| |||
config.trail_estimator,
| |||
config.trail_window,
| |||
)
| |||
if volatility is not None and math.isfinite(volatility):
| |||
distance = _clamp(
| |||
config.trail_multiplier * volatility,
| |||
config.trail_floor,
| |||
config.trail_cap,
| |||
)
| |||
else:
| |||
warning_key = (config.name, int(minute_floor(boundary_broker).timestamp()))
| |||
if warning_key not in VOLATILITY_WARNING_KEYS:
| |||
VOLATILITY_WARNING_KEYS.add(warning_key)
| |||
LOGGER.warning(
| |||
"%s volatility unavailable at %s; using fixed fallback trail %.2f",
| |||
config.name,
| |||
minute_floor(boundary_broker).isoformat(),
| |||
config.trail_distance,
| |||
)
| |||
return max(float(distance), float(broker_min_distance))
| |||
| |||
def rebuild_trail_state(
| |||
position: Any,
| |||
config: StrategyConfig,
| |||
clock_tick: Any = None,
| |||
) -> dict[str, Any]:
| |||
"""Reconstruct completed-M1 executable closes after the entry candle.
| |||
| |||
Position and tick timestamps remain in MT5 broker-clock space. Their common
| |||
offset cancels, so reconstruction is independent of Windows/VPS timezone.
| |||
A caller-supplied tick keeps one trailing pass on a single clock snapshot.
| |||
"""
| |||
entry_dt = _broker_clock_datetime(position)
| |||
entry_minute = minute_floor(entry_dt)
| |||
if clock_tick is None:
| |||
clock_tick = current_tick(require_fresh=False)
| |||
end_boundary = minute_floor(_broker_clock_datetime(clock_tick))
| |||
start = entry_minute
| |||
end = end_boundary - timedelta(microseconds=1)
| |||
| |||
best: Optional[float] = None
| |||
last_minute: Optional[datetime] = None
| |||
history_query_succeeded = end <= start
| |||
if end > start:
| |||
ticks = mt5.copy_ticks_range(SYMBOL, start, end, mt5.COPY_TICKS_ALL)
| |||
history_query_succeeded = ticks is not None
| |||
if ticks is None:
| |||
LOGGER.error(
| |||
"Could not rebuild trailing history ticket=%s: %s",
| |||
position.ticket,
| |||
mt5_error_text(),
| |||
)
| |||
elif len(ticks):
| |||
last_by_minute: dict[int, tuple[float, float]] = {}
| |||
for tick in ticks:
| |||
minute_ts = int(tick["time_msc"] // 60_000) * 60
| |||
last_by_minute[minute_ts] = (float(tick["bid"]), float(tick["ask"]))
| |||
| |||
buy = int(position.type) == int(mt5.POSITION_TYPE_BUY)
| |||
# The entry candle is excluded: only minute starts strictly greater
| |||
# than the entry candle start are eligible.
| |||
for minute_ts in sorted(last_by_minute):
| |||
minute_dt = datetime.fromtimestamp(minute_ts, UTC)
| |||
if minute_dt <= entry_minute:
| |||
continue
| |||
bid, ask = last_by_minute[minute_ts]
| |||
executable = bid if buy else ask
| |||
if executable <= 0:
| |||
continue
| |||
if best is None:
| |||
best = executable
| |||
elif buy:
| |||
best = max(best, executable)
| |||
else:
| |||
best = min(best, executable)
| |||
last_minute = minute_dt
| |||
| |||
LOGGER.info(
| |||
"Rebuilt trailing state ticket=%s best=%s last_broker_minute=%s",
| |||
position.ticket,
| |||
best,
| |||
last_minute.isoformat() if last_minute else None,
| |||
)
| |||
rebuilt_through = (
| |||
end_boundary - timedelta(minutes=1)
| |||
if history_query_succeeded
| |||
else None
| |||
)
| |||
return {
| |||
"strategy": config.name,
| |||
"clock_basis": BROKER_CLOCK_BASIS,
| |||
"best_executable_close": best,
| |||
"last_completed_minute": format_utc(last_minute) if last_minute else None,
| |||
"rebuilt_through": format_utc(rebuilt_through) if rebuilt_through else None,
| |||
}
| |||
| |||
| |||
def theoretical_trailing_sl(
| |||
config: StrategyConfig,
| |||
buy: bool,
| |||
entry_price: float,
| |||
best_close: float,
| |||
trail_activation: float,
| |||
trail_distance: float,
| |||
broker_min_distance: float,
| |||
) -> Optional[float]:
| |||
"""Return the completed-M1 stop under the adaptive deployment-safe rules.
| |||
| |||
Volatility is measured only from fully completed M1 bars. The current stop is
| |||
evaluated before this candidate is applied, and callers still enforce the
| |||
never-loosen rule. Existing profit floors and permanent late trails remain
| |||
fixed exactly as in the prior specification.
| |||
"""
| |||
favourable = best_close - entry_price if buy else entry_price - best_close
| |||
if favourable + 1e-9 < trail_activation:
| |||
return None
| |||
| |||
active_distance = max(float(trail_distance), float(broker_min_distance))
| |||
if (
| |||
config.late_trail_threshold is not None
| |||
and config.late_trail_distance is not None
| |||
and favourable + 1e-9 >= config.late_trail_threshold
| |||
):
| |||
active_distance = max(
| |||
float(config.late_trail_distance),
| |||
float(broker_min_distance),
| |||
)
| |||
| |||
candidate = best_close - active_distance if buy else best_close + active_distance
| |||
| |||
if (
| |||
config.profit_floor_threshold is not None
| |||
and config.profit_floor is not None
| |||
and favourable + 1e-9 >= config.profit_floor_threshold
| |||
):
| |||
floor_price = entry_price + config.profit_floor if buy else entry_price - config.profit_floor
| |||
candidate = max(candidate, floor_price) if buy else min(candidate, floor_price)
| |||
| |||
return normalize_price(candidate)
| |||
| |||
| |||
def manage_trailing(position: Any, config: StrategyConfig, state: dict[str, Any]) -> None:
| |||
ticket = int(position.ticket)
| |||
ticket_key = str(ticket)
| |||
| |||
# A short broker quote pause is expected occasionally. Defer only this
| |||
# position's trailing pass; do not abort the rest of the main reconciliation
| |||
# loop. Entry placement still uses the same strict freshness check and is not
| |||
# relaxed by this handling.
| |||
try:
| |||
clock_tick = current_tick(require_fresh=True)
| |||
except TransientTickError as exc:
| |||
now_mono = time.monotonic()
| |||
last_warning = TRAIL_TICK_WARNING_LAST_MONOTONIC.get(ticket, -math.inf)
| |||
if now_mono - last_warning >= TRAIL_TICK_WARNING_INTERVAL_SECONDS:
| |||
TRAIL_TICK_WARNING_LAST_MONOTONIC[ticket] = now_mono
| |||
LOGGER.warning(
| |||
"%s trailing deferred ticket=%s current_sl=%.2f: %s",
| |||
config.name,
| |||
ticket,
| |||
float(getattr(position, "sl", 0.0) or 0.0),
| |||
exc,
| |||
)
| |||
return
| |||
| |||
trail_map = state.setdefault("trail", {})
| |||
trail_state = trail_map.get(ticket_key)
| |||
if (
| |||
not isinstance(trail_state, dict)
| |||
or trail_state.get("clock_basis") != BROKER_CLOCK_BASIS
| |||
):
| |||
# Existing version-1 state files remain valid. Only an open position's old
| |||
# trail subrecord is rebuilt once under the corrected broker-clock basis.
| |||
trail_state = rebuild_trail_state(position, config, clock_tick)
| |||
trail_map[ticket_key] = trail_state
| |||
save_state(state)
| |||
| |||
entry_dt = _broker_clock_datetime(position)
| |||
now_broker = _broker_clock_datetime(clock_tick)
| |||
latest_completed_minute = minute_floor(now_broker) - timedelta(minutes=1)
| |||
| |||
last_text = trail_state.get("last_completed_minute")
| |||
rebuilt_text = trail_state.get("rebuilt_through")
| |||
last_minute = parse_utc(last_text) if last_text else None
| |||
rebuilt_through = parse_utc(rebuilt_text) if rebuilt_text else None
| |||
covered_candidates = [dt for dt in (last_minute, rebuilt_through) if dt is not None]
| |||
covered_minute = max(covered_candidates) if covered_candidates else None
| |||
if covered_minute is None or latest_completed_minute > covered_minute + timedelta(minutes=1):
| |||
trail_state = rebuild_trail_state(position, config, clock_tick)
| |||
trail_map[ticket_key] = trail_state
| |||
save_state(state)
| |||
| |||
completed = completed_minute_close(now_broker)
| |||
buy = int(position.type) == int(mt5.POSITION_TYPE_BUY)
| |||
| |||
if completed is not None:
| |||
candle_minute, bid_close, ask_close = completed
| |||
entry_minute = minute_floor(entry_dt)
| |||
last_text = trail_state.get("last_completed_minute")
| |||
last_minute = parse_utc(last_text) if last_text else None
| |||
| |||
# Entry candle is not eligible, and every minute is processed once.
| |||
if candle_minute > entry_minute and (last_minute is None or candle_minute > last_minute):
| |||
executable_close = bid_close if buy else ask_close
| |||
best = trail_state.get("best_executable_close")
| |||
best_value = float(best) if best is not None else None
| |||
if best_value is None:
| |||
best_value = executable_close
| |||
elif buy:
| |||
best_value = max(best_value, executable_close)
| |||
else:
| |||
best_value = min(best_value, executable_close)
| |||
trail_state["best_executable_close"] = best_value
| |||
trail_state["last_completed_minute"] = format_utc(candle_minute)
| |||
trail_state["rebuilt_through"] = format_utc(candle_minute)
| |||
trail_state["clock_basis"] = BROKER_CLOCK_BASIS
| |||
save_state(state)
| |||
| |||
best_raw = trail_state.get("best_executable_close")
| |||
if best_raw is None:
| |||
return
| |||
best_close = float(best_raw)
| |||
entry_price = float(position.price_open)
| |||
| |||
# Use the same fresh snapshot obtained at the beginning of this trailing pass.
| |||
# A second quote request here previously created another avoidable failure point.
| |||
tick = clock_tick
| |||
current_bid = float(tick.bid)
| |||
current_ask = float(tick.ask)
| |||
broker_min_distance = max(
| |||
int(getattr(SYMBOL_INFO, "trade_stops_level", 0)),
| |||
int(getattr(SYMBOL_INFO, "trade_freeze_level", 0)),
| |||
) * float(SYMBOL_INFO.point)
| |||
trail_activation = effective_trail_activation(
| |||
position,
| |||
config,
| |||
state,
| |||
clock_tick,
| |||
)
| |||
trail_distance = effective_trail_distance(
| |||
config,
| |||
minute_floor(now_broker),
| |||
broker_min_distance,
| |||
)
| |||
theoretical_sl = theoretical_trailing_sl(
| |||
config,
| |||
buy,
| |||
entry_price,
| |||
best_close,
| |||
trail_activation,
| |||
trail_distance,
| |||
broker_min_distance,
| |||
)
| |||
if theoretical_sl is None:
| |||
return
| |||
| |||
# Conservative restart/gap handling: if the market is already through the
| |||
# recovered stop, close at the current market price instead of pretending the
| |||
# old stop could be installed retroactively.
| |||
if (buy and current_bid <= theoretical_sl) or (not buy and current_ask >= theoretical_sl):
| |||
LOGGER.warning(
| |||
"Price is through theoretical trailing stop; closing at market. "
| |||
"ticket=%s theoretical_sl=%.2f market=%.2f/%.2f",
| |||
position.ticket,
| |||
theoretical_sl,
| |||
current_bid,
| |||
current_ask,
| |||
)
| |||
close_position(position, f"{config.name}_TRAIL_GAP")
| |||
return
| |||
| |||
min_distance = broker_min_distance
| |||
| |||
if buy:
| |||
candidate = min(theoretical_sl, current_bid - min_distance)
| |||
candidate = normalize_price(candidate)
| |||
old_sl = float(position.sl)
| |||
if old_sl > 0 and candidate <= old_sl + float(SYMBOL_INFO.point) / 2:
| |||
return
| |||
if candidate >= current_bid:
| |||
return
| |||
else:
| |||
candidate = max(theoretical_sl, current_ask + min_distance)
| |||
candidate = normalize_price(candidate)
| |||
old_sl = float(position.sl)
| |||
if old_sl > 0 and candidate >= old_sl - float(SYMBOL_INFO.point) / 2:
| |||
return
| |||
if candidate <= current_ask:
| |||
return
| |||
| |||
modify_position_sl(position, candidate)
| |||
| |||
| |||
| |||
# =============================================================================
| |||
# OCO, EXPIRY, POSITION MANAGEMENT
| |||
# =============================================================================
| |||
| |||
def order_expiry_utc(order: Any, state: dict[str, Any]) -> Optional[datetime]:
| |||
comment = str(getattr(order, "comment", ""))
| |||
group_id = comment[:-1] if comment.endswith(("B", "S")) else comment
| |||
group = state.get("active_groups", {}).get(group_id)
| |||
if isinstance(group, dict) and group.get("expiry_utc"):
| |||
try:
| |||
return parse_utc(group["expiry_utc"])
| |||
except Exception:
| |||
pass
| |||
| |||
raw = int(getattr(order, "time_expiration", 0) or 0)
| |||
if raw > 0:
| |||
try:
| |||
clock_tick = current_tick(require_fresh=False)
| |||
return broker_clock_to_utc(datetime.fromtimestamp(raw, UTC), clock_tick)
| |||
except Exception:
| |||
LOGGER.exception(
| |||
"Could not convert broker expiration for order ticket=%s",
| |||
getattr(order, "ticket", 0),
| |||
)
| |||
return None
| |||
| |||
| |||
def pending_oco_pair_is_complete(orders: list[Any]) -> bool:
| |||
"""Return True only for exactly one BUY leg and one SELL leg of one group."""
| |||
if len(orders) != 2:
| |||
return False
| |||
comments = [str(getattr(order, "comment", "")).strip() for order in orders]
| |||
if not all(comment.endswith(("B", "S")) for comment in comments):
| |||
return False
| |||
group_ids = {comment[:-1] for comment in comments}
| |||
sides = {comment[-1] for comment in comments}
| |||
if len(group_ids) != 1 or sides != {"B", "S"}:
| |||
return False
| |||
| |||
order_types = {int(getattr(order, "type", -1)) for order in orders}
| |||
required_types = {
| |||
int(getattr(mt5, "ORDER_TYPE_BUY_STOP", 4)),
| |||
int(getattr(mt5, "ORDER_TYPE_SELL_STOP", 5)),
| |||
}
| |||
return order_types == required_types
| |||
| |||
| |||
def manage_open_position(
| |||
position: Any,
| |||
config: StrategyConfig,
| |||
state: dict[str, Any],
| |||
) -> None:
| |||
if not ensure_initial_protection(position, config):
| |||
return
| |||
| |||
# Maximum hold is elapsed time, not a wall-clock comparison. The live MT5
| |||
# tick and the position opening timestamp share the same broker clock, so the
| |||
# timezone offset cancels exactly.
| |||
clock_tick = current_tick(require_fresh=False)
| |||
elapsed_seconds = _broker_elapsed_seconds(position, clock_tick)
| |||
if elapsed_seconds < -5.0:
| |||
LOGGER.error(
| |||
"%s position ticket=%s has a future broker timestamp; deferring time exit. "
| |||
"position_msc=%s tick_msc=%s",
| |||
config.name,
| |||
position.ticket,
| |||
_mt5_object_time_msc(position),
| |||
_mt5_object_time_msc(clock_tick),
| |||
)
| |||
elif elapsed_seconds >= config.max_hold_minutes * 60.0:
| |||
LOGGER.info(
| |||
"%s maximum hold reached ticket=%s elapsed=%.1fs limit=%ss",
| |||
config.name,
| |||
position.ticket,
| |||
elapsed_seconds,
| |||
config.max_hold_minutes * 60,
| |||
)
| |||
close_position(position, f"{config.name}_TIME")
| |||
return
| |||
| |||
manage_trailing(position, config, state)
| |||
| |||
| |||
def reconcile_news_events(config: StrategyConfig, state: dict[str, Any]) -> None:
| |||
"""Reconcile each NEWS event minute independently.
| |||
| |||
Distinct event minutes may have simultaneous positions. OCO sibling removal,
| |||
duplicate-fill repair, expiry, time exit and trailing are all scoped to one
| |||
group_id, never to every position sharing NEWS_MAGIC.
| |||
"""
| |||
all_orders = sorted(
| |||
orders_for_magic(config.magic),
| |||
key=lambda item: (int(getattr(item, "time_setup_msc", 0)), int(item.ticket)),
| |||
)
| |||
all_positions = sorted(
| |||
positions_for_magic(config.magic),
| |||
key=lambda item: (int(getattr(item, "time_msc", 0)), int(item.ticket)),
| |||
)
| |||
| |||
grouped_orders: dict[str, list[Any]] = {}
| |||
grouped_positions: dict[str, list[Any]] = {}
| |||
malformed_orders: list[Any] = []
| |||
malformed_positions: list[Any] = []
| |||
| |||
for order in all_orders:
| |||
group_id = group_id_from_comment(getattr(order, "comment", ""), config)
| |||
if group_id is None:
| |||
malformed_orders.append(order)
| |||
else:
| |||
grouped_orders.setdefault(group_id, []).append(order)
| |||
| |||
for position in all_positions:
| |||
group_id = group_id_from_comment(getattr(position, "comment", ""), config)
| |||
if group_id is None:
| |||
malformed_positions.append(position)
| |||
else:
| |||
grouped_positions.setdefault(group_id, []).append(position)
| |||
| |||
for order in malformed_orders:
| |||
LOGGER.critical(
| |||
"NEWS pending order ticket=%s has an unrecognized event comment %r; removing it.",
| |||
order.ticket,
| |||
str(getattr(order, "comment", "")),
| |||
)
| |||
remove_pending_order(order, "NEWS_COMMENT_REPAIR")
| |||
| |||
now = utc_now()
| |||
group_ids = set(grouped_orders) | set(grouped_positions)
| |||
for group_id in sorted(group_ids):
| |||
orders = grouped_orders.get(group_id, [])
| |||
positions = grouped_positions.get(group_id, [])
| |||
| |||
if not positions and orders and not pending_oco_pair_is_complete(orders):
| |||
LOGGER.critical(
| |||
"NEWS group %s has an incomplete/duplicate OCO set (%d order(s)); "
| |||
"cancelling only this event's legs.",
| |||
group_id,
| |||
len(orders),
| |||
)
| |||
for order in orders:
| |||
remove_pending_order(order, "NEWS_PAIR_REPAIR")
| |||
continue
| |||
| |||
# Local OCO: a fill cancels only the sibling leg from the same event minute.
| |||
if positions and orders:
| |||
for order in orders:
| |||
remove_pending_order(order, "NEWS_OCO")
| |||
orders = []
| |||
| |||
# Both directions may fill during a gap, but only within one event group.
| |||
if len(positions) > 1:
| |||
keeper = positions[0]
| |||
LOGGER.critical(
| |||
"NEWS OCO breach for group %s: %d positions. Keeping earliest ticket=%s.",
| |||
group_id,
| |||
len(positions),
| |||
keeper.ticket,
| |||
)
| |||
for extra in positions[1:]:
| |||
close_position(extra, "NEWS_OCO_BREACH")
| |||
positions = [keeper]
| |||
| |||
if not positions:
| |||
for order in orders:
| |||
expiry = order_expiry_utc(order, state)
| |||
if expiry and now >= expiry + timedelta(seconds=1):
| |||
remove_pending_order(order, "NEWS_EXPIRED")
| |||
continue
| |||
| |||
manage_open_position(positions[0], config, state)
| |||
| |||
# A broker may alter a position comment. Do not close such a protected position
| |||
# merely because its group cannot be reconstructed; manage it independently.
| |||
for position in malformed_positions:
| |||
LOGGER.warning(
| |||
"NEWS position ticket=%s has unrecognized comment %r; managing it as a "
| |||
"standalone NEWS position.",
| |||
position.ticket,
| |||
str(getattr(position, "comment", "")),
| |||
)
| |||
manage_open_position(position, config, state)
| |||
| |||
| |||
def reconcile_subsystem(config: StrategyConfig, state: dict[str, Any]) -> None:
| |||
if config.name == "NEWS":
| |||
reconcile_news_events(config, state)
| |||
return
| |||
| |||
positions = sorted(
| |||
positions_for_magic(config.magic),
| |||
key=lambda p: (int(getattr(p, "time_msc", 0)), int(p.ticket)),
| |||
)
| |||
orders = orders_for_magic(config.magic)
| |||
| |||
# Session subsystems retain the original one-OCO/one-position rule.
| |||
if not positions and orders and not pending_oco_pair_is_complete(orders):
| |||
LOGGER.critical(
| |||
"%s has an incomplete/duplicate pending OCO set (%d order(s)); "
| |||
"cancelling all legs before any new placement.",
| |||
config.name,
| |||
len(orders),
| |||
)
| |||
for order in orders:
| |||
remove_pending_order(order, f"{config.name}_PAIR_REPAIR")
| |||
orders = orders_for_magic(config.magic)
| |||
| |||
if positions and orders:
| |||
for order in orders:
| |||
remove_pending_order(order, f"{config.name}_OCO")
| |||
orders = []
| |||
| |||
if len(positions) > 1:
| |||
keeper = positions[0]
| |||
LOGGER.critical(
| |||
"%s OCO breach: %d simultaneous positions. Keeping earliest ticket=%s.",
| |||
config.name,
| |||
len(positions),
| |||
keeper.ticket,
| |||
)
| |||
for extra in positions[1:]:
| |||
close_position(extra, f"{config.name}_OCO_BREACH")
| |||
positions = [keeper]
| |||
| |||
now = utc_now()
| |||
if not positions:
| |||
for order in orders:
| |||
expiry = order_expiry_utc(order, state)
| |||
if expiry and now >= expiry + timedelta(seconds=1):
| |||
remove_pending_order(order, f"{config.name}_EXPIRED")
| |||
return
| |||
| |||
manage_open_position(positions[0], config, state)
| |||
| |||
def cleanup_trail_state(state: dict[str, Any]) -> None:
| |||
live_tickets = {
| |||
str(int(position.ticket))
| |||
for magic in OUR_MAGICS
| |||
for position in positions_for_magic(magic)
| |||
}
| |||
trail = state.get("trail", {})
| |||
changed = False
| |||
for ticket in list(trail):
| |||
if ticket not in live_tickets:
| |||
del trail[ticket]
| |||
TRAIL_TICK_WARNING_LAST_MONOTONIC.pop(int(ticket), None)
| |||
changed = True
| |||
if changed:
| |||
save_state(state)
| |||
| |||
| |||
def cleanup_active_groups(state: dict[str, Any]) -> None:
| |||
live_comments = {
| |||
str(order.comment)
| |||
for magic in OUR_MAGICS
| |||
for order in orders_for_magic(magic)
| |||
} | {
| |||
str(position.comment)
| |||
for magic in OUR_MAGICS
| |||
for position in positions_for_magic(magic)
| |||
}
| |||
now = utc_now()
| |||
groups = state.get("active_groups", {})
| |||
changed = False
| |||
for group_id, group in list(groups.items()):
| |||
still_live = any(comment.startswith(group_id) for comment in live_comments)
| |||
if still_live:
| |||
continue
| |||
try:
| |||
expiry = parse_utc(group.get("expiry_utc", ""))
| |||
except Exception:
| |||
expiry = now - timedelta(days=1)
| |||
if now > expiry + timedelta(minutes=10):
| |||
del groups[group_id]
| |||
changed = True
| |||
if changed:
| |||
save_state(state)
| |||
| |||
| |||
# =============================================================================
| |||
# EVENT PROCESSING
| |||
# =============================================================================
| |||
| |||
def mark_event(
| |||
state: dict[str, Any],
| |||
event_key: str,
| |||
status: str,
| |||
event_utc: datetime,
| |||
detail: str = "",
| |||
) -> None:
| |||
state.setdefault("processed_events", {})[event_key] = {
| |||
"status": status,
| |||
"event_utc": format_utc(event_utc),
| |||
"updated_utc": format_utc(utc_now()),
| |||
"detail": detail,
| |||
}
| |||
save_state(state)
| |||
| |||
| |||
def process_scheduled_event(
| |||
config: StrategyConfig,
| |||
event_key: str,
| |||
event_name: str,
| |||
event_utc: datetime,
| |||
state: dict[str, Any],
| |||
) -> None:
| |||
if event_key in state.get("processed_events", {}):
| |||
return
| |||
| |||
now = utc_now()
| |||
placement_start = event_utc - timedelta(seconds=PLACEMENT_LEAD_SECONDS)
| |||
| |||
if now < placement_start:
| |||
return
| |||
| |||
# Never create a new pair after the scheduled event time. A late bot start is
| |||
# recorded as missed rather than silently changing the tested entry timing.
| |||
if now >= event_utc:
| |||
status = "skipped_busy" if event_setup_busy(config, event_utc) else "missed_late"
| |||
mark_event(state, event_key, status, event_utc, event_name)
| |||
LOGGER.warning("%s event %s: %s", config.name, event_name, status)
| |||
return
| |||
| |||
if event_setup_busy(config, event_utc):
| |||
# For NEWS this checks only the same UTC event minute. Earlier NEWS event
| |||
# positions do not block this event; session subsystems remain single-setup.
| |||
return
| |||
| |||
try:
| |||
placed = place_oco_pair(config, event_key, event_name, event_utc, state)
| |||
except Exception:
| |||
LOGGER.exception("Failed to place %s pair for %s", config.name, event_name)
| |||
return
| |||
| |||
if placed:
| |||
RISK_BLOCK_WARNING_KEYS.discard((event_key, "minimum_lot"))
| |||
RISK_BLOCK_WARNING_KEYS.discard((event_key, "portfolio_cap"))
| |||
mark_event(state, event_key, "placed", event_utc, event_name)
| |||
| |||
def process_events(state: dict[str, Any]) -> None:
| |||
now = utc_now()
| |||
| |||
# Macro-news timetable. These rows are explicitly UTC and independent of the
| |||
# machine, VPS, terminal, broker-server, Moldova or European local timezone.
| |||
for event in NEWS_EVENTS:
| |||
if not event.tradable:
| |||
continue
| |||
if event.when_utc < now - timedelta(minutes=1):
| |||
if event.event_id not in state.get("processed_events", {}):
| |||
mark_event(
| |||
state,
| |||
event.event_id,
| |||
"expired_before_start",
| |||
event.when_utc,
| |||
event.event_name,
| |||
)
| |||
continue
| |||
if event.when_utc > now + timedelta(minutes=2):
| |||
break
| |||
process_scheduled_event(
| |||
NEWS_CONFIG,
| |||
event.event_id,
| |||
event.event_name,
| |||
event.when_utc,
| |||
state,
| |||
)
| |||
| |||
# Four daily market-session events. Each event is constructed in its own
| |||
# exchange timezone and converted to UTC before scheduling.
| |||
for spec, event_id, event_utc, event_name in all_daily_session_events(now):
| |||
process_scheduled_event(
| |||
spec.config,
| |||
event_id,
| |||
event_name,
| |||
event_utc,
| |||
state,
| |||
)
| |||
| |||
| |||
def timetable_horizon_warning(now: datetime) -> None:
| |||
global LAST_HORIZON_WARNING_DATE
| |||
today_key = now.date().isoformat()
| |||
if LAST_HORIZON_WARNING_DATE == today_key:
| |||
return
| |||
LAST_HORIZON_WARNING_DATE = today_key
| |||
| |||
future = [event for event in NEWS_EVENTS if event.tradable and event.when_utc > now]
| |||
if not future:
| |||
LOGGER.critical("No future traded-code NEWS events are available; check traded_codes.csv and timetable.csv.")
| |||
return
| |||
latest = max(event.when_utc for event in future)
| |||
if latest < now + timedelta(days=10):
| |||
LOGGER.warning(
| |||
"Strong-news timetable horizon is short: last tradable event is %s; check the calendar EA.",
| |||
format_utc(latest),
| |||
)
| |||
| |||
| |||
# =============================================================================
| |||
# BALANCE AND TRADE LOGS
| |||
# =============================================================================
| |||
| |||
def read_last_logged_balance_text(path: Path) -> Optional[str]:
| |||
"""Return the last valid balance recorded in *path*, normalized to 2 decimals."""
| |||
if not path.exists():
| |||
return None
| |||
| |||
last_balance_text: Optional[str] = None
| |||
try:
| |||
with path.open("r", encoding="utf-8") as handle:
| |||
for raw_line in handle:
| |||
line = raw_line.strip()
| |||
if not line or "\t" not in line:
| |||
continue
| |||
_, raw_balance = line.rsplit("\t", 1)
| |||
try:
| |||
balance = float(raw_balance.strip())
| |||
except ValueError:
| |||
continue
| |||
if math.isfinite(balance):
| |||
last_balance_text = f"{balance:.2f}"
| |||
except OSError:
| |||
LOGGER.exception("Cannot read existing balance log %s", path)
| |||
return None
| |||
| |||
return last_balance_text
| |||
| |||
| |||
def append_balance(force: bool = False) -> None:
| |||
"""Append the current balance only when its logged 2-decimal value changes."""
| |||
global LAST_LOGGED_BALANCE_TEXT, LAST_BALANCE_LOG_YEAR
| |||
| |||
now = utc_now()
| |||
path = LOG_DIR / f"{now.year}.txt"
| |||
| |||
# On startup/reconnect calls and at a UTC year rollover, recover the last
| |||
# persisted value so an unchanged balance is not duplicated after restart.
| |||
if force or LAST_BALANCE_LOG_YEAR != now.year:
| |||
LAST_LOGGED_BALANCE_TEXT = read_last_logged_balance_text(path)
| |||
LAST_BALANCE_LOG_YEAR = now.year
| |||
| |||
account = mt5.account_info()
| |||
if account is None:
| |||
LOGGER.error("Cannot log balance: %s", mt5_error_text())
| |||
return
| |||
| |||
balance_text = f"{float(account.balance):.2f}"
| |||
if LAST_LOGGED_BALANCE_TEXT == balance_text:
| |||
return
| |||
| |||
with path.open("a", encoding="utf-8") as handle:
| |||
# User requested only date/time and balance, nothing else.
| |||
handle.write(f"{now:%Y-%m-%d %H:%M:%S} UTC\t{balance_text}\n")
| |||
| |||
LAST_LOGGED_BALANCE_TEXT = balance_text
| |||
LAST_BALANCE_LOG_YEAR = now.year
| |||
| |||
| |||
def deal_reason_name(value: int) -> str:
| |||
mapping = {}
| |||
for name in (
| |||
"DEAL_REASON_CLIENT",
| |||
"DEAL_REASON_MOBILE",
| |||
"DEAL_REASON_WEB",
| |||
"DEAL_REASON_EXPERT",
| |||
"DEAL_REASON_SL",
| |||
"DEAL_REASON_TP",
| |||
"DEAL_REASON_SO",
| |||
"DEAL_REASON_ROLLOVER",
| |||
"DEAL_REASON_VMARGIN",
| |||
"DEAL_REASON_SPLIT",
| |||
"DEAL_REASON_CORPORATE_ACTION",
| |||
):
| |||
if hasattr(mt5, name):
| |||
mapping[int(getattr(mt5, name))] = name.replace("DEAL_REASON_", "")
| |||
return mapping.get(int(value), str(value))
| |||
| |||
| |||
def weighted_price(deals: Iterable[Any]) -> float:
| |||
deals = list(deals)
| |||
total_volume = sum(float(deal.volume) for deal in deals)
| |||
if total_volume <= 0:
| |||
return 0.0
| |||
return sum(float(deal.price) * float(deal.volume) for deal in deals) / total_volume
| |||
| |||
| |||
def append_trade_row(
| |||
exit_dt: datetime,
| |||
entry_dt: datetime,
| |||
strategy: str,
| |||
direction: str,
| |||
volume: float,
| |||
entry_price: float,
| |||
exit_price: float,
| |||
net: float,
| |||
reason: str,
| |||
position_id: int,
| |||
comments: str,
| |||
) -> None:
| |||
# Closed trades are grouped by their UTC exit year. Entry and exit fields
| |||
# below retain the full UTC date and time for every position.
| |||
path = LOG_DIR / f"{exit_dt:%Y}_trades.log"
| |||
header = (
| |||
"ENTRY DATE/TIME UTC | EXIT DATE/TIME UTC | STRATEGY | DIR | LOT | "
| |||
"ENTRY | EXIT | NET USD | OUTCOME | REASON | POSITION | COMMENTS\n"
| |||
"--------------------+---------------------+-------------+------+--------+"
| |||
"------------+------------+------------+---------+--------------+----------+---------\n"
| |||
)
| |||
if not path.exists() or path.stat().st_size == 0:
| |||
path.write_text(header, encoding="utf-8")
| |||
| |||
outcome = "PROFIT" if net > 1e-9 else "LOSS" if net < -1e-9 else "FLAT"
| |||
line = (
| |||
f"{entry_dt:%Y-%m-%d %H:%M:%S} | {exit_dt:%Y-%m-%d %H:%M:%S} | "
| |||
f"{strategy:<11} | {direction:<4} | {volume:>6.2f} | "
| |||
f"{entry_price:>10.2f} | {exit_price:>10.2f} | {net:>10.2f} | "
| |||
f"{outcome:<7} | {reason:<12} | {position_id:<8} | {comments}\n"
| |||
)
| |||
with path.open("a", encoding="utf-8") as handle:
| |||
handle.write(line)
| |||
| |||
| |||
def reconcile_trade_logs(state: dict[str, Any], force: bool = False) -> None:
| |||
global LAST_DEAL_SCAN_MONOTONIC
| |||
now_mono = time.monotonic()
| |||
if not force and now_mono - LAST_DEAL_SCAN_MONOTONIC < 30:
| |||
return
| |||
LAST_DEAL_SCAN_MONOTONIC = now_mono
| |||
| |||
end_utc = utc_now() + timedelta(minutes=1)
| |||
start_utc = end_utc - timedelta(days=TRADE_LOG_LOOKBACK_DAYS)
| |||
try:
| |||
clock_tick = current_tick(require_fresh=False)
| |||
start_broker = utc_to_broker_clock(start_utc, clock_tick)
| |||
end_broker = utc_to_broker_clock(end_utc, clock_tick)
| |||
except Exception:
| |||
LOGGER.exception("Cannot establish broker-clock range for deal-history reconciliation.")
| |||
return
| |||
| |||
deals = mt5.history_deals_get(start_broker, end_broker)
| |||
if deals is None:
| |||
LOGGER.error("history_deals_get failed: %s", mt5_error_text())
| |||
return
| |||
| |||
grouped: dict[int, list[Any]] = {}
| |||
for deal in deals:
| |||
position_id = int(getattr(deal, "position_id", 0) or 0)
| |||
if position_id <= 0:
| |||
continue
| |||
grouped.setdefault(position_id, []).append(deal)
| |||
| |||
logged = {int(value) for value in state.get("logged_position_ids", [])}
| |||
changed = False
| |||
entry_in = int(getattr(mt5, "DEAL_ENTRY_IN", 0))
| |||
entry_out = int(getattr(mt5, "DEAL_ENTRY_OUT", 1))
| |||
entry_out_by = int(getattr(mt5, "DEAL_ENTRY_OUT_BY", 3))
| |||
entry_inout = int(getattr(mt5, "DEAL_ENTRY_INOUT", 2))
| |||
| |||
for position_id, group in sorted(grouped.items()):
| |||
if position_id in logged:
| |||
continue
| |||
if not any(int(getattr(deal, "magic", 0)) in OUR_MAGICS for deal in group):
| |||
continue
| |||
group.sort(key=lambda deal: (int(getattr(deal, "time_msc", 0)), int(deal.ticket)))
| |||
entries = [deal for deal in group if int(deal.entry) in {entry_in, entry_inout}]
| |||
exits = [deal for deal in group if int(deal.entry) in {entry_out, entry_out_by, entry_inout}]
| |||
if not entries or not exits:
| |||
continue
| |||
| |||
entry_volume = sum(float(deal.volume) for deal in entries)
| |||
exit_volume = sum(float(deal.volume) for deal in exits)
| |||
if exit_volume + 1e-9 < entry_volume:
| |||
continue # still partially open
| |||
| |||
entry = entries[0]
| |||
magic = next(
| |||
(int(deal.magic) for deal in entries if int(deal.magic) in OUR_MAGICS),
| |||
int(entry.magic),
| |||
)
| |||
config = CONFIG_BY_MAGIC.get(magic)
| |||
if config is None:
| |||
continue
| |||
| |||
direction = (
| |||
"BUY"
| |||
if int(entry.type) == int(getattr(mt5, "DEAL_TYPE_BUY", 0))
| |||
else "SELL"
| |||
)
| |||
entry_dt = _mt5_object_utc_datetime(entry, clock_tick)
| |||
exit_dt = _mt5_object_utc_datetime(exits[-1], clock_tick)
| |||
entry_price = weighted_price(entries)
| |||
exit_price = weighted_price(exits)
| |||
net = sum(
| |||
float(getattr(deal, "profit", 0.0))
| |||
+ float(getattr(deal, "commission", 0.0))
| |||
+ float(getattr(deal, "swap", 0.0))
| |||
+ float(getattr(deal, "fee", 0.0))
| |||
for deal in group
| |||
)
| |||
reasons = sorted({deal_reason_name(int(deal.reason)) for deal in exits})
| |||
comments = " / ".join(
| |||
dict.fromkeys(str(deal.comment).strip() for deal in group if str(deal.comment).strip())
| |||
)
| |||
append_trade_row(
| |||
exit_dt=exit_dt,
| |||
entry_dt=entry_dt,
| |||
strategy=config.name,
| |||
direction=direction,
| |||
volume=entry_volume,
| |||
entry_price=entry_price,
| |||
exit_price=exit_price,
| |||
net=net,
| |||
reason=",".join(reasons),
| |||
position_id=position_id,
| |||
comments=comments,
| |||
)
| |||
LOGGER.info(
| |||
"Trade logged position_id=%s strategy=%s direction=%s net=%.2f",
| |||
position_id,
| |||
config.name,
| |||
direction,
| |||
net,
| |||
)
| |||
logged.add(position_id)
| |||
changed = True
| |||
| |||
if changed:
| |||
state["logged_position_ids"] = sorted(logged)
| |||
save_state(state)
| |||
| |||
| |||
# =============================================================================
| |||
# STARTUP VALIDATION AND MAIN LOOP
| |||
# =============================================================================
| |||
| |||
def describe_configuration() -> None:
| |||
LOGGER.info(
| |||
"Scheduling is UTC-aware and host-timezone independent | "
| |||
"news=timetable UTC | New York=%s | Tokyo=%s | placement lead=%.1fs",
| |||
getattr(NY_TZ, "key", "America/New_York"),
| |||
getattr(TOKYO_TZ, "key", "Asia/Tokyo"),
| |||
PLACEMENT_LEAD_SECONDS,
| |||
)
| |||
LOGGER.info(
| |||
"Risk sizing: portfolio cap=%.2f%% | cost buffer=$%.2f/oz | minimum-lot policy=%s",
| |||
PORTFOLIO_RISK_CAP_PERCENT,
| |||
RISK_COST_BUFFER_PER_OUNCE,
| |||
MINIMUM_LOT_POLICY,
| |||
)
| |||
for config in ALL_CONFIGS:
| |||
trail_rule = (
| |||
f"clip({config.trail_multiplier:g}*{config.trail_estimator}{config.trail_window},"
| |||
f"{config.trail_floor:g},{config.trail_cap:g})"
| |||
if config.trail_estimator and config.trail_window > 0
| |||
else f"fixed {config.trail_distance:g}"
| |||
)
| |||
activation_rule = (
| |||
f"frozen clip({config.activation_multiplier:g}*{config.activation_estimator}"
| |||
f"{config.activation_window},{config.activation_floor:g},{config.activation_cap:g})"
| |||
if config.activation_estimator and config.activation_window > 0
| |||
else f"fixed {config.trail_activation:g}"
| |||
)
| |||
LOGGER.info(
| |||
"%s parameters: offset=%.2f SL=%.2f activation=%s trail=%s "
| |||
"floor_at=%s floor=%s late_at=%s late_trail=%s expiry=%ss hold=%sm "
| |||
"risk=%.2f%% min_lot=%.2f",
| |||
config.name,
| |||
config.offset,
| |||
config.initial_sl,
| |||
activation_rule,
| |||
trail_rule,
| |||
config.profit_floor_threshold,
| |||
config.profit_floor,
| |||
config.late_trail_threshold,
| |||
config.late_trail_distance,
| |||
config.expiry_seconds,
| |||
config.max_hold_minutes,
| |||
config.risk_percent,
| |||
config.min_lot,
| |||
)
| |||
| |||
| |||
def signal_handler(signum: int, _frame: Any) -> None:
| |||
global STOP_REQUESTED
| |||
STOP_REQUESTED = True
| |||
LOGGER.warning(
| |||
"Shutdown requested by signal %s. Existing pending orders/positions for all strategies are left "
| |||
"server-side with their initial/current SL and pending expiration.",
| |||
signum,
| |||
)
| |||
| |||
| |||
def main() -> int:
| |||
setup_logging()
| |||
acquire_single_instance_lock()
| |||
signal.signal(signal.SIGINT, signal_handler)
| |||
if hasattr(signal, "SIGTERM"):
| |||
signal.signal(signal.SIGTERM, signal_handler)
| |||
| |||
state = load_state()
| |||
try:
| |||
try:
| |||
load_traded_codes(force=True)
| |||
except FileNotFoundError:
| |||
LOGGER.error(
| |||
"traded_codes.csv is not present; NEWS remains disabled until a "
| |||
"valid file appears. Session strategies continue."
| |||
)
| |||
except Exception:
| |||
LOGGER.exception(
| |||
"Initial traded-code load failed; NEWS remains disabled until a "
| |||
"valid replacement is loaded. Session strategies continue."
| |||
)
| |||
| |||
try:
| |||
load_timetable(force=True)
| |||
except FileNotFoundError:
| |||
LOGGER.error(
| |||
"timetable.csv is not present yet; NEWS remains disabled until the "
| |||
"EA publishes a valid file. Session strategies continue."
| |||
)
| |||
except Exception:
| |||
LOGGER.exception(
| |||
"Initial timetable load failed; NEWS remains disabled until a valid "
| |||
"replacement is loaded. Session strategies continue."
| |||
)
| |||
connect_mt5()
| |||
describe_configuration()
| |||
append_balance(force=True)
| |||
reconcile_trade_logs(state, force=True)
| |||
prune_state(state, utc_now())
| |||
save_state(state)
| |||
| |||
while not STOP_REQUESTED:
| |||
loop_started = time.monotonic()
| |||
try:
| |||
if not ensure_connection():
| |||
time.sleep(RECONNECT_RETRY_SECONDS)
| |||
continue
| |||
| |||
try:
| |||
load_traded_codes(force=False)
| |||
except FileNotFoundError:
| |||
LOGGER.error(
| |||
"traded_codes.csv is temporarily missing; retaining the last "
| |||
"valid in-memory code set. Set enabled=0 in a valid file to "
| |||
"intentionally disable NEWS trading."
| |||
)
| |||
except Exception:
| |||
LOGGER.exception(
| |||
"Traded-code reload failed; retaining the last valid in-memory "
| |||
"code set."
| |||
)
| |||
| |||
try:
| |||
load_timetable(force=False)
| |||
except FileNotFoundError:
| |||
LOGGER.error(
| |||
"timetable.csv is temporarily missing; retaining the last "
| |||
"valid in-memory NEWS calendar while session strategies continue."
| |||
)
| |||
except Exception:
| |||
LOGGER.exception(
| |||
"Timetable reload failed; retaining the last valid in-memory "
| |||
"NEWS calendar while session strategies continue."
| |||
)
| |||
| |||
now = utc_now()
| |||
timetable_horizon_warning(now)
| |||
| |||
# OCO cancellation and position protection run before new entries.
| |||
for config in ALL_CONFIGS:
| |||
reconcile_subsystem(config, state)
| |||
cleanup_trail_state(state)
| |||
cleanup_active_groups(state)
| |||
| |||
process_events(state)
| |||
append_balance(force=False)
| |||
reconcile_trade_logs(state, force=False)
| |||
| |||
except Exception:
| |||
LOGGER.exception("Unhandled loop error; bot will continue.")
| |||
| |||
near_window = max(15.0, PLACEMENT_LEAD_SECONDS + 2.0)
| |||
near = seconds_to_nearest_event(utc_now()) <= near_window
| |||
target_sleep = NEAR_EVENT_POLL_SECONDS if near else NORMAL_POLL_SECONDS
| |||
elapsed = time.monotonic() - loop_started
| |||
time.sleep(max(0.01, target_sleep - elapsed))
| |||
| |||
finally:
| |||
try:
| |||
reconcile_trade_logs(state, force=True)
| |||
except Exception:
| |||
LOGGER.exception("Final trade-log reconciliation failed.")
| |||
try:
| |||
mt5.shutdown()
| |||
except Exception:
| |||
pass
| |||
LOGGER.info("Bot stopped.")
| |||
return 0
| |||
| |||
| |||
if __name__ == "__main__":
| |||
raise SystemExit(main())
|