""" ================================================================================ ACCOUNT MANAGER — Real Account State & Persistent P&L Tracking ================================================================================ Quantum Terminal | Layer 3 — Account & Risk Management Bridges the signal engine to real account data. Reads MT5 account balance/equity, tracks daily and weekly P&L with persistence across restarts, and provides configurable trading rules for prop firm compliance. Architecture: MT5 Account ──► AccountManager ──► PortfolioState (signal engine) │ ▼ account_state.json (persistent) │ ▼ RiskGovernor (pre-trade checks) Settings are stored in account_settings.json (user-editable from terminal UI). Account state (daily P&L, peak equity, etc.) is stored in account_state.json. Usage: from account_manager import AccountManager mgr = AccountManager() mgr.sync_from_mt5() portfolio = mgr.get_portfolio_state() # feeds into signal engine mgr.record_trade_result(pnl_usd=150.0) Dependencies: MetaTrader5 (optional), json, pathlib ================================================================================ """ import json import logging import os from dataclasses import dataclass, field, asdict from typing import Dict, List, Optional from datetime import datetime, date, timedelta from pathlib import Path # version: v4 (broker-TZ-aware period-pnl bucketing) # v4 — get_period_pnl now corrects for the broker TZ before bucketing # deals into today/yesterday/this_week/etc. The MetaTrader5 library # returns deal.time as broker server time treated as a Unix epoch # (i.e. broker_local seconds, not real UTC). Our boundaries # (datetime.now() + datetime(year, month, day)) are in real UTC # via .timestamp(). Without correction, the comparison was off by # the broker's TZ offset — for an operator + broker both at GMT+3, # ~3h of deals spilled across day boundaries, e.g. yesterday-late # trades counted as today and vice versa. v4 detects the broker # offset from a recent tick (round to whole hours) and converts # d.time → real-UTC before comparison. log = logging.getLogger("mk.account_manager") PROJECT_ROOT = Path(__file__).resolve().parent # v3: AppData dir name from env var so v1 / v2 builds get separate dirs. APP_DIR = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal") def _get_user_data_dir() -> Path: """Per-user writable dir for account_settings.json + account_state.json. Fixes Errno 13 Permission denied under Program Files on installed consumer. v3: AppData dir name comes from MK_APP_DIR_NAME (default "QuantumTerminal").""" import platform as _platform if _platform.system() == "Windows": appdata = os.environ.get("APPDATA", "") base = Path(appdata) / APP_DIR if appdata else \ Path.home() / "AppData" / "Roaming" / APP_DIR else: base = Path.home() / f".{APP_DIR.lower()}" base.mkdir(parents=True, exist_ok=True) return base def _resolve_default_settings_path() -> Path: """v2: Prefer the AppData path. If a legacy file exists next to the module (dev-mode / old install), migrate it on first use.""" user_dir = _get_user_data_dir() user_path = user_dir / "account_settings.json" legacy = PROJECT_ROOT / "account_settings.json" if legacy.exists() and not user_path.exists(): try: user_path.write_bytes(legacy.read_bytes()) log.info(f"Migrated {legacy} → {user_path}") except Exception as e: log.warning(f"Settings migration failed: {e}") return user_path def _resolve_default_state_path() -> Path: user_dir = _get_user_data_dir() user_path = user_dir / "account_state.json" legacy = PROJECT_ROOT / "account_state.json" if legacy.exists() and not user_path.exists(): try: user_path.write_bytes(legacy.read_bytes()) log.info(f"Migrated {legacy} → {user_path}") except Exception as e: log.warning(f"State migration failed: {e}") return user_path # ============================================================ # 1. TRADING SETTINGS (user-configurable) # ============================================================ @dataclass class TradingSettings: """ User-configurable trading rules. Saved to account_settings.json. Editable from the terminal settings panel. """ # -- Account -- account_size: float = 100_000.0 # Initial account balance (or prop firm start) currency: str = "USD" # -- Prop Firm Rules -- max_daily_loss_pct: float = 2.0 # Max daily loss as % of initial balance max_daily_profit_pct: float = 0.0 # Max daily profit lock (0 = disabled) max_total_drawdown_pct: float = 10.0 # Max drawdown from initial balance max_trailing_drawdown_pct: float = 0.0 # Max drawdown from peak equity (0 = disabled) # -- Position Limits -- max_open_positions: int = 4 max_correlated_positions: int = 2 # Max same-direction in correlated group risk_per_trade_pct: float = 0.5 # Fixed fractional risk per trade # -- Kelly -- use_kelly: bool = True kelly_fraction: float = 0.25 # Quarter-Kelly # -- Calibration Gating -- min_calibration_grade: str = "C" # Filter signals below this grade require_calibration: bool = False # If True, only trade calibrated setups # -- Session Rules -- trading_enabled: bool = True # Master switch auto_sync_mt5: bool = True # Auto-sync equity from MT5 on startup # -- Auto Trading (Phase 3G) -- auto_trading_enabled: bool = False # Auto-execute signals (separate from live_trading) auto_min_confidence: float = 0.60 # Min signal confidence to auto-trade auto_min_grade: str = "C" # Min calibration grade to auto-trade auto_max_daily_trades: int = 5 # Max auto-trades per day auto_allowed_types: list = field(default_factory=lambda: [ "cone_boundary_fade", "drift_momentum", "institutional_anchor", "cone_convergence", "regime_transition", "cone_breakout", ]) auto_allowed_tickers: list = field(default_factory=list) # Empty = all universe auto_scan_interval: float = 30.0 # Seconds between scans auto_log_only: bool = True # True = dry run (log but don't execute) # -- Scheduler (Phase 3G) -- scheduler_enabled: bool = True scheduler_weekly_enabled: bool = True scheduler_weekly_day: int = 6 # 0=Mon, 6=Sun scheduler_weekly_time: str = "21:30" # HH:MM UTC scheduler_daily_enabled: bool = True scheduler_daily_time: str = "21:30" # HH:MM UTC scheduler_daily_skip_weekends: bool = True scheduler_calc_before_weekly: bool = True # Run cones before weekly orch # Calibration calibration_workers: int = 1 # CPU threads for ATR/management sweep scheduler_calc_modules: str = "anchors,cones" def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, d: dict) -> 'TradingSettings': # Only take keys that exist in the dataclass valid_keys = {f.name for f in cls.__dataclass_fields__.values()} filtered = {k: v for k, v in d.items() if k in valid_keys} return cls(**filtered) # ============================================================ # 2. ACCOUNT STATE (persistent) # ============================================================ @dataclass class AccountState: """ Persistent account state — survives server restarts. Saved to account_state.json. """ # -- Equity tracking -- initial_balance: float = 100_000.0 # Set once at start (prop firm funded amount) current_equity: float = 100_000.0 # Last known equity peak_equity: float = 100_000.0 # Highest equity ever (for trailing DD) # -- Daily tracking -- daily_start_equity: float = 100_000.0 # Equity at start of current day daily_pnl: float = 0.0 # P&L since daily reset daily_trades: int = 0 # Trade count today current_date: str = "" # ISO date of current tracking day # -- Weekly tracking -- weekly_start_equity: float = 100_000.0 weekly_pnl: float = 0.0 weekly_trades: int = 0 current_week: str = "" # ISO week string (e.g., "2026-W12") # -- Circuit breaker states -- daily_loss_halt: bool = False # True = daily loss limit hit daily_profit_lock: bool = False # True = daily profit target hit total_drawdown_halt: bool = False # True = max drawdown breached trailing_drawdown_halt: bool = False # True = trailing DD breached # -- Trade log (lightweight) -- recent_trades: List[Dict] = field(default_factory=list) # Last 50 trades # -- Timestamps -- last_mt5_sync: str = "" last_updated: str = "" def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, d: dict) -> 'AccountState': valid_keys = {f.name for f in cls.__dataclass_fields__.values()} filtered = {k: v for k, v in d.items() if k in valid_keys} # Handle list fields that may come as None if 'recent_trades' not in filtered or filtered['recent_trades'] is None: filtered['recent_trades'] = [] return cls(**filtered) # ============================================================ # 3. ACCOUNT MANAGER # ============================================================ class AccountManager: """ Manages account state, MT5 sync, and trading settings. """ def __init__(self, settings_path: Optional[Path] = None, state_path: Optional[Path] = None): # v2: default to %APPDATA%\QuantumTerminal\ (writable) instead of the module # directory, which lives under Program Files and is read-only for # non-admin users. Legacy files are migrated on first use. self._settings_path = settings_path or _resolve_default_settings_path() self._state_path = state_path or _resolve_default_state_path() self.settings = self._load_settings() self.state = self._load_state() # Check for day/week rollover self._check_daily_reset() self._check_weekly_reset() # ──────────────────────────────────────────────────────── # PERSISTENCE # ──────────────────────────────────────────────────────── def _load_settings(self) -> TradingSettings: """Load or create settings file.""" if self._settings_path.exists(): try: with open(self._settings_path) as f: data = json.load(f) log.info(f"Loaded trading settings from {self._settings_path}") return TradingSettings.from_dict(data) except Exception as e: log.warning(f"Failed to load settings: {e} — using defaults") settings = TradingSettings() self._save_settings(settings) return settings def _save_settings(self, settings: TradingSettings = None): """Save settings to JSON.""" if settings is None: settings = self.settings try: with open(self._settings_path, "w") as f: json.dump(settings.to_dict(), f, indent=2) except Exception as e: log.warning(f"Failed to save settings: {e}") def _load_state(self) -> AccountState: """Load or create state file.""" if self._state_path.exists(): try: with open(self._state_path) as f: data = json.load(f) log.info(f"Loaded account state from {self._state_path}") return AccountState.from_dict(data) except Exception as e: log.warning(f"Failed to load state: {e} — using defaults") state = AccountState( initial_balance=self.settings.account_size, current_equity=self.settings.account_size, peak_equity=self.settings.account_size, daily_start_equity=self.settings.account_size, weekly_start_equity=self.settings.account_size, ) self._save_state(state) return state def _save_state(self, state: AccountState = None): """Save state to JSON.""" if state is None: state = self.state state.last_updated = datetime.now().isoformat() try: with open(self._state_path, "w") as f: json.dump(state.to_dict(), f, indent=2, default=str) except Exception as e: log.warning(f"Failed to save state: {e}") def update_settings(self, new_settings: dict): """Update settings from terminal UI (partial update).""" old_account_size = self.settings.account_size current = self.settings.to_dict() current.update(new_settings) self.settings = TradingSettings.from_dict(current) self._save_settings() log.info(f"Trading settings updated: {list(new_settings.keys())}") # Auto-reset baseline when account_size changes if "account_size" in new_settings and new_settings["account_size"] != old_account_size: self.reset_baseline(self.settings.account_size) def reset_baseline(self, new_balance: float = None): """ Reset account state baseline. Call when: - Setting up a new funded account - Account size changes in settings - Starting fresh after a reset Sets initial_balance, peak_equity, daily/weekly start to the new value. Clears all circuit breaker halts. Preserves current_equity if MT5-synced. """ bal = new_balance or self.settings.account_size equity = self.state.current_equity if self.state.current_equity > 0 else bal self.state.initial_balance = bal self.state.peak_equity = max(bal, equity) self.state.daily_start_equity = equity self.state.weekly_start_equity = equity self.state.daily_pnl = 0.0 self.state.weekly_pnl = 0.0 self.state.daily_trades = 0 self.state.weekly_trades = 0 # Clear all circuit breaker halts self.state.daily_loss_halt = False self.state.daily_profit_lock = False self.state.total_drawdown_halt = False self.state.trailing_drawdown_halt = False self._save_state() log.info(f"Account baseline reset: initial_balance=${bal:,.2f}, " f"equity=${equity:,.2f}, all circuit breakers cleared") # ──────────────────────────────────────────────────────── # MT5 SYNC # ──────────────────────────────────────────────────────── def sync_from_mt5(self) -> bool: """ Read real account equity from MT5. Returns True if sync successful. """ try: import MetaTrader5 as mt5 if not mt5.terminal_info(): log.warning("MT5 not connected — cannot sync account") return False account = mt5.account_info() if account is None: log.warning("MT5 account_info() returned None") return False equity = account.equity balance = account.balance self.state.current_equity = equity self.state.peak_equity = max(self.state.peak_equity, equity) self.state.last_mt5_sync = datetime.now().isoformat() # Update daily P&L self.state.daily_pnl = equity - self.state.daily_start_equity self.state.weekly_pnl = equity - self.state.weekly_start_equity # Check circuit breakers self._check_circuit_breakers() self._save_state() log.info(f"MT5 sync: equity=${equity:,.2f}, balance=${balance:,.2f}, " f"daily P&L=${self.state.daily_pnl:+,.2f}") return True except ImportError: log.info("MetaTrader5 package not available — manual equity mode") return False except Exception as e: log.warning(f"MT5 sync failed: {e}") return False def sync_positions_from_mt5(self) -> List[Dict]: """Read open positions from MT5.""" try: import MetaTrader5 as mt5 if not mt5.terminal_info(): return [] positions = mt5.positions_get() if positions is None: return [] result = [] for pos in positions: result.append({ 'ticket': pos.ticket, 'ticker': pos.symbol, 'direction': 'long' if pos.type == 0 else 'short', 'size': pos.volume, 'entry': pos.price_open, 'current_price': pos.price_current, 'current_pnl': pos.profit, 'sl': pos.sl, 'tp': pos.tp, 'magic': pos.magic, 'comment': pos.comment, }) return result except (ImportError, Exception): return [] # ──────────────────────────────────────────────────────── # DAILY/WEEKLY RESETS # ──────────────────────────────────────────────────────── def _check_daily_reset(self): """Reset daily tracking if it's a new day.""" today = date.today().isoformat() if self.state.current_date != today: if self.state.current_date: log.info(f"Daily reset: {self.state.current_date} → {today} " f"(yesterday P&L: ${self.state.daily_pnl:+,.2f})") self.state.daily_start_equity = self.state.current_equity self.state.daily_pnl = 0.0 self.state.daily_trades = 0 self.state.daily_loss_halt = False self.state.daily_profit_lock = False self.state.current_date = today self._save_state() def _check_weekly_reset(self): """Reset weekly tracking if it's a new week.""" current_week = date.today().isocalendar() week_str = f"{current_week[0]}-W{current_week[1]:02d}" if self.state.current_week != week_str: if self.state.current_week: log.info(f"Weekly reset: {self.state.current_week} → {week_str} " f"(last week P&L: ${self.state.weekly_pnl:+,.2f})") self.state.weekly_start_equity = self.state.current_equity self.state.weekly_pnl = 0.0 self.state.weekly_trades = 0 self.state.current_week = week_str self._save_state() # ──────────────────────────────────────────────────────── # CIRCUIT BREAKERS # ──────────────────────────────────────────────────────── def _check_circuit_breakers(self): """Check all prop firm circuit breakers.""" s = self.settings st = self.state # Daily loss limit if s.max_daily_loss_pct > 0: daily_loss_limit = st.initial_balance * (s.max_daily_loss_pct / 100) if st.daily_pnl <= -daily_loss_limit: if not st.daily_loss_halt: log.warning(f"🛑 DAILY LOSS LIMIT HIT: ${st.daily_pnl:+,.2f} " f"exceeds -{s.max_daily_loss_pct}% of ${st.initial_balance:,.0f}") st.daily_loss_halt = True # Daily profit lock if s.max_daily_profit_pct > 0: daily_profit_limit = st.initial_balance * (s.max_daily_profit_pct / 100) if st.daily_pnl >= daily_profit_limit: if not st.daily_profit_lock: log.info(f"🔒 DAILY PROFIT LOCK: ${st.daily_pnl:+,.2f} " f"exceeds +{s.max_daily_profit_pct}% target") st.daily_profit_lock = True # Total drawdown from initial if s.max_total_drawdown_pct > 0: total_dd = (st.initial_balance - st.current_equity) / st.initial_balance * 100 if total_dd >= s.max_total_drawdown_pct: if not st.total_drawdown_halt: log.warning(f"🛑 MAX DRAWDOWN BREACHED: {total_dd:.1f}% from initial " f"(limit: {s.max_total_drawdown_pct}%)") st.total_drawdown_halt = True # Trailing drawdown from peak if s.max_trailing_drawdown_pct > 0 and st.peak_equity > 0: trailing_dd = (st.peak_equity - st.current_equity) / st.peak_equity * 100 if trailing_dd >= s.max_trailing_drawdown_pct: if not st.trailing_drawdown_halt: log.warning(f"🛑 TRAILING DRAWDOWN BREACHED: {trailing_dd:.1f}% from peak " f"${st.peak_equity:,.2f} (limit: {s.max_trailing_drawdown_pct}%)") st.trailing_drawdown_halt = True @property def is_trading_allowed(self) -> bool: """Check if trading is allowed based on all circuit breakers.""" if not self.settings.trading_enabled: return False st = self.state return not (st.daily_loss_halt or st.daily_profit_lock or st.total_drawdown_halt or st.trailing_drawdown_halt) @property def halt_reason(self) -> str: """Get the reason trading is halted, or empty string.""" if not self.settings.trading_enabled: return "Trading disabled in settings" st = self.state reasons = [] if st.daily_loss_halt: reasons.append(f"Daily loss limit ({self.settings.max_daily_loss_pct}%)") if st.daily_profit_lock: reasons.append(f"Daily profit locked ({self.settings.max_daily_profit_pct}%)") if st.total_drawdown_halt: reasons.append(f"Max drawdown ({self.settings.max_total_drawdown_pct}%)") if st.trailing_drawdown_halt: reasons.append(f"Trailing drawdown ({self.settings.max_trailing_drawdown_pct}%)") return " | ".join(reasons) # ──────────────────────────────────────────────────────── # TRADE RECORDING # ──────────────────────────────────────────────────────── def record_trade_result(self, pnl_usd: float, ticker: str = "", direction: str = "", signal_type: str = "", lots: float = 0.0, magic: int = 0): """ Record a trade result and update P&L tracking. Called when a trade closes (from lifecycle manager or MT5 bridge). """ self.state.daily_pnl += pnl_usd self.state.weekly_pnl += pnl_usd self.state.daily_trades += 1 self.state.weekly_trades += 1 self.state.current_equity += pnl_usd self.state.peak_equity = max(self.state.peak_equity, self.state.current_equity) # Append to recent trades (keep last 50) self.state.recent_trades.append({ 'timestamp': datetime.now().isoformat(), 'ticker': ticker, 'direction': direction, 'signal_type': signal_type, 'lots': lots, 'pnl_usd': round(pnl_usd, 2), 'magic': magic, 'equity_after': round(self.state.current_equity, 2), }) if len(self.state.recent_trades) > 50: self.state.recent_trades = self.state.recent_trades[-50:] self._check_circuit_breakers() self._save_state() log.info(f"Trade recorded: {ticker} {direction} {signal_type} → " f"PnL=${pnl_usd:+,.2f} | Daily=${self.state.daily_pnl:+,.2f} | " f"Equity=${self.state.current_equity:,.2f}") # ──────────────────────────────────────────────────────── # BRIDGE TO SIGNAL ENGINE # ──────────────────────────────────────────────────────── def get_portfolio_state(self): """ Build a PortfolioState for the signal engine from current account data. """ # Import here to avoid circular dependency from signal_engine_v2 import PortfolioState positions = self.sync_positions_from_mt5() return PortfolioState( equity=self.state.current_equity, open_positions=[ { 'ticker': p['ticker'], 'direction': p['direction'], 'size': p['size'], 'entry': p['entry'], 'current_pnl': p['current_pnl'], 'risk_usd': abs(p['entry'] - p.get('sl', p['entry'])) * p['size'], } for p in positions ], daily_pnl=self.state.daily_pnl, weekly_pnl=self.state.weekly_pnl, peak_equity=self.state.peak_equity, ) # ──────────────────────────────────────────────────────── # REST API HELPERS (for data_server.py endpoints) # ──────────────────────────────────────────────────────── def get_status_dict(self) -> dict: """Get full account status for the terminal dashboard.""" s = self.settings st = self.state daily_loss_limit = st.initial_balance * (s.max_daily_loss_pct / 100) if s.max_daily_loss_pct > 0 else 0 daily_profit_limit = st.initial_balance * (s.max_daily_profit_pct / 100) if s.max_daily_profit_pct > 0 else 0 total_dd = (st.initial_balance - st.current_equity) / st.initial_balance * 100 if st.initial_balance > 0 else 0 trailing_dd = (st.peak_equity - st.current_equity) / st.peak_equity * 100 if st.peak_equity > 0 else 0 return { "trading_allowed": self.is_trading_allowed, "halt_reason": self.halt_reason, "equity": round(st.current_equity, 2), "initial_balance": round(st.initial_balance, 2), "peak_equity": round(st.peak_equity, 2), "daily_pnl": round(st.daily_pnl, 2), "daily_pnl_pct": round(st.daily_pnl / st.initial_balance * 100, 2) if st.initial_balance > 0 else 0, "daily_loss_limit": round(daily_loss_limit, 2), "daily_profit_limit": round(daily_profit_limit, 2), "daily_loss_halt": st.daily_loss_halt, "daily_profit_lock": st.daily_profit_lock, "weekly_pnl": round(st.weekly_pnl, 2), "total_drawdown_pct": round(total_dd, 2), "trailing_drawdown_pct": round(trailing_dd, 2), "total_drawdown_halt": st.total_drawdown_halt, "trailing_drawdown_halt": st.trailing_drawdown_halt, "daily_trades": st.daily_trades, "weekly_trades": st.weekly_trades, "open_positions": len(self.sync_positions_from_mt5()), "max_positions": s.max_open_positions, "last_mt5_sync": st.last_mt5_sync, "last_updated": st.last_updated, } def get_settings_dict(self) -> dict: """Get current settings for the terminal UI.""" return self.settings.to_dict() # ──────────────────────────────────────────────────────── # MT5 DEAL HISTORY — REAL PERIOD P&L # ──────────────────────────────────────────────────────── def get_period_pnl(self) -> dict: """ Query MT5 closed deal history for accurate period P&L. Returns real closed-trade P&L (profit + swap + commission) for: - today, yesterday - this week, last week - this month, last month This is the source of truth — NOT the equity-delta tracking which drifts when weekly_start_equity is stale. """ try: import MetaTrader5 as mt5 import time as _time if not mt5.terminal_info(): return self._empty_period_pnl("MT5 not connected") # v4: detect broker TZ offset (broker_time treated as UTC - # real_UTC). Round to whole hours since broker TZs are integer # offsets. Falls back to 0 (treat as UTC broker) on failure. broker_offset_sec = 0 try: for sym in ("EURUSD", "XAUUSD", "USDJPY", "BTCUSD"): tick = mt5.symbol_info_tick(sym) if tick and getattr(tick, "time", 0): raw = int(tick.time) - int(_time.time()) broker_offset_sec = round(raw / 3600) * 3600 # Sanity: clamp to ±14h (no real broker outside that) if abs(broker_offset_sec) > 14 * 3600: broker_offset_sec = 0 break except Exception as e: log.debug(f"broker offset detection failed: {e}") now = datetime.now() today_start = datetime(now.year, now.month, now.day) yesterday_start = today_start - timedelta(days=1) # Week boundaries (Monday 00:00) weekday = now.weekday() # 0=Mon this_week_start = today_start - timedelta(days=weekday) last_week_start = this_week_start - timedelta(days=7) last_week_end = this_week_start # Month boundaries this_month_start = datetime(now.year, now.month, 1) if now.month == 1: last_month_start = datetime(now.year - 1, 12, 1) else: last_month_start = datetime(now.year, now.month - 1, 1) last_month_end = this_month_start # v4: fetch a wider window than strictly needed so deals near the # boundaries don't get clipped by MT5's own pseudo-UTC interpretation # of the input range. We filter precisely in Python below. query_from = last_month_start - timedelta(days=2) query_to = now + timedelta(days=2) all_deals = mt5.history_deals_get(query_from, query_to) if all_deals is None: all_deals = () # Filter to closing deals only (entry: 1=out, 2=inout, 3=out_by) # Deal type 6 = balance operations — skip those close_deals = [ d for d in all_deals if d.entry in (1, 2, 3) and d.type not in (6,) ] def _sum_pnl(deals, dt_from, dt_to): """Sum profit + swap + commission for deals in [dt_from, dt_to). v4: convert d.time (broker pseudo-UTC) to real UTC before comparing against the boundaries (which are real UTC via .timestamp()).""" ts_from = dt_from.timestamp() ts_to = dt_to.timestamp() total = 0.0 count = 0 for d in deals: d_real_utc = d.time - broker_offset_sec if ts_from <= d_real_utc < ts_to: total += d.profit + d.swap + d.commission count += 1 return round(total, 2), count today_pnl, today_trades = _sum_pnl(close_deals, today_start, now + timedelta(hours=1)) yesterday_pnl, yesterday_trades = _sum_pnl(close_deals, yesterday_start, today_start) this_week_pnl, this_week_trades = _sum_pnl(close_deals, this_week_start, now + timedelta(hours=1)) last_week_pnl, last_week_trades = _sum_pnl(close_deals, last_week_start, last_week_end) this_month_pnl, this_month_trades = _sum_pnl(close_deals, this_month_start, now + timedelta(hours=1)) last_month_pnl, last_month_trades = _sum_pnl(close_deals, last_month_start, last_month_end) equity = self.state.current_equity or self.state.initial_balance return { "today": {"pnl": today_pnl, "trades": today_trades, "pct": round(today_pnl / equity * 100, 2) if equity > 0 else 0}, "yesterday": {"pnl": yesterday_pnl, "trades": yesterday_trades, "pct": round(yesterday_pnl / equity * 100, 2) if equity > 0 else 0}, "this_week": {"pnl": this_week_pnl, "trades": this_week_trades, "pct": round(this_week_pnl / equity * 100, 2) if equity > 0 else 0}, "last_week": {"pnl": last_week_pnl, "trades": last_week_trades, "pct": round(last_week_pnl / equity * 100, 2) if equity > 0 else 0}, "this_month": {"pnl": this_month_pnl, "trades": this_month_trades, "pct": round(this_month_pnl / equity * 100, 2) if equity > 0 else 0}, "last_month": {"pnl": last_month_pnl, "trades": last_month_trades, "pct": round(last_month_pnl / equity * 100, 2) if equity > 0 else 0}, "source": "mt5_deals", "deals_scanned": len(close_deals), } except ImportError: return self._empty_period_pnl("MetaTrader5 not available") except Exception as e: log.warning(f"Period P&L fetch failed: {e}") return self._empty_period_pnl(str(e)) @staticmethod def _empty_period_pnl(reason: str = "") -> dict: """Return empty period P&L structure.""" empty = {"pnl": 0, "trades": 0, "pct": 0} return { "today": empty.copy(), "yesterday": empty.copy(), "this_week": empty.copy(), "last_week": empty.copy(), "this_month": empty.copy(), "last_month": empty.copy(), "source": "unavailable", "reason": reason, "deals_scanned": 0, } # ============================================================ # 4. SINGLETON # ============================================================ _manager_instance = None def get_account_manager() -> AccountManager: """Get the singleton AccountManager instance.""" global _manager_instance if _manager_instance is None: _manager_instance = AccountManager() return _manager_instance