Initial commit: Quantum Terminal — Free & Open Source Trading Platform
This commit is contained in:
@@ -0,0 +1,805 @@
|
||||
"""
|
||||
================================================================================
|
||||
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
|
||||
Reference in New Issue
Block a user