Files

805 lines
35 KiB
Python

"""
================================================================================
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