From e28f2db97c4bf8d93c8ff0541f9ca8107b89808b Mon Sep 17 00:00:00 2001 From: saber Date: Wed, 17 Jun 2026 11:26:57 +0100 Subject: [PATCH] layer one v3 --- .env.example | 83 ++- .gitignore | 1 - apex.db-shm | Bin 0 -> 32768 bytes apex.db-wal | Bin 0 -> 127752 bytes config.py | 130 +++-- confluence_filter.py | 333 ++++++++++++ create_excel_template.py | 3 +- currency_strength_matrix.py | 205 ++++++++ data_feeder.py | 569 +++++++++++++++++++++ database.py | 432 +++++++++------- fred_client.py | 4 +- layer2_technical.py | 227 +++++++++ main.py | 14 +- main_window.py | 77 ++- project_structure_and_resume.md | 878 ++++++++++++++++++++++++++++++++ requirements.txt | 1 + risk_management.py | 466 +++++++++++++++++ scorer.py | 38 +- ui/confluence_tab.py | 362 +++++++++++++ ui/dashboard_tab.py | 15 + ui/layer2_monitor_tab.py | 477 +++++++++++++++++ ui/settings_tab.py | 80 ++- 22 files changed, 4124 insertions(+), 271 deletions(-) create mode 100644 apex.db-shm create mode 100644 apex.db-wal create mode 100644 confluence_filter.py create mode 100644 currency_strength_matrix.py create mode 100644 data_feeder.py create mode 100644 layer2_technical.py create mode 100644 project_structure_and_resume.md create mode 100644 risk_management.py create mode 100644 ui/confluence_tab.py create mode 100644 ui/layer2_monitor_tab.py diff --git a/.env.example b/.env.example index 9b005b5..4639769 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ -# APEX Layer 1 — Environment Configuration Example -# +# APEX — Environment Configuration Example +# # Copy this file to .env and fill in your values # cp .env.example .env # ============================================================================ -# FRED API Configuration (Required) +# FRED API Configuration (Required for Layer 1) # ============================================================================ # Get a free API key from: https://fred.stlouisfed.org # 1. Register for an account @@ -12,58 +12,85 @@ # 3. Copy your key and paste below FRED_API_KEY=paste_your_fred_api_key_here +# ============================================================================ +# MetaTrader 5 Configuration (for Layer 2 Technical Analysis) +# ============================================================================ +# Real-time forex data from local MT5 terminal (no API key needed). +# Symbol suffix varies by broker (e.g., .m for OANDA MT5). +# Leave empty if your symbols are plain (EURUSD, USDJPY, etc.) +MT5_SYMBOL_SUFFIX= + # ============================================================================ # Database Configuration # ============================================================================ -# Path to SQLite database file DB_PATH=apex.db -# Database connection timeout (seconds) -DB_TIMEOUT=10 - -# Auto-create schema on first run -DB_AUTO_CREATE=true - # ============================================================================ # Debugging & Logging # ============================================================================ -# Enable debug output to console (true/false) DEBUG=true # ============================================================================ # Scoring Weights (must sum to 1.0) # ============================================================================ -# Interest rate differential weight (50% default) WEIGHT_RATE=0.50 - -# CPI deviation weight (30% default) WEIGHT_CPI=0.30 - -# PMI composite weight (20% default) WEIGHT_PMI=0.20 # ============================================================================ # Trading Rules # ============================================================================ -# Minimum gap in points to generate trade signal (default 20) -# Gap < 20: NO TRADE -# Gap 20-40: Weak signal -# Gap 40-60: Standard signal -# Gap > 60: Strong signal +# Gap < 20: NO TRADE | 20-40: Weak | 40-60: Standard | > 60: Strong MIN_GAP=20.0 # ============================================================================ # Auto-Fetch Configuration # ============================================================================ -# Automatically fetch rates from FRED on app startup (true/false) AUTO_FETCH_RATES_ON_STARTUP=true # ============================================================================ -# Application UI Settings +# Technical Analysis Settings (Layer 2) # ============================================================================ -# Window title -APP_TITLE=APEX Layer 1 — Currency Strength Engine +# Z-score threshold for overbought/oversold (±2.0σ) +Z_SCORE_THRESHOLD=2.0 -# Default window size (width x height) -WINDOW_WIDTH=1200 -WINDOW_HEIGHT=800 +# Statistical lookback window (Task 1.1): 288 M5 bars = 24 hours of data +Z_SCORE_LOOKBACK=288 + +# Bar timeframe for anchored statistics: M1 or M5 +BAR_TIMEFRAME=M5 + +# Hours of historical data for μ/σ anchoring (default 48h) +BAR_LOOKBACK_HOURS=48 + +# Total bar count (288 M5 bars = 24h, 576 = 48h) +BAR_LOOKBACK_BARS=288 + +# Historical poll interval in seconds (300 = 5 min) +HISTORICAL_POLL_INTERVAL=300 + +# ============================================================================ +# Confluence Settings +# ============================================================================ +CONFLUENCE_ENABLED=true +MIN_CONFLUENCE_STRENGTH=60.0 + +# ============================================================================ +# Risk Management +# ============================================================================ +ACCOUNT_BALANCE=10000.0 +RISK_PER_TRADE=0.01 +MAX_PORTFOLIO_LEVERAGE=2.0 +USE_GRID_HEDGING=true +GRID_LEVELS=3 + +# ============================================================================ +# Mock Data Feeder Configuration (for testing without MT5) +# ============================================================================ +MOCK_DRIFT=0.0001 +MOCK_THETA=0.02 +MOCK_NOISE_STD=0.0008 +MOCK_SEASONAL_AMP=0.0003 +MOCK_TICK_NOISE=0.0002 +MOCK_BID_ASK_SPREAD=0.0001 +MOCK_HISTORICAL_DAILY_NOISE=0.01 diff --git a/.gitignore b/.gitignore index 8ef2d6d..90cdae3 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,5 @@ example_*.csv ~$* # OS specific -Thumbs.db .DS_Store .Thumbs.db diff --git a/apex.db-shm b/apex.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..4bd9bd624a403b1e0293f2293b5e1d8558874a50 GIT binary patch literal 32768 zcmeI)sZj(`5C-6Xm-}As;f?@B3@gAu3@8iQnY@P-<(g+s8j z;_Iree)DSTb=Ma_SMP_ZW2|H+=^d21Q1bfa^y9ey`gwNsaB_40^!#{nbbojGc3b`N z|MJvN)bD-`M01_3=Fly5yyRf5XZn5jAK%Rg3IPHH2oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 zh9=M{TVEzJo!QK1F}MDv>`CrMpHcrjD$e9Y}T5}Oy@A<*KlY|8n{E-Z0Wi<+snTvo4x@je4 z%l`1!){og-Yjfykt!1{E2`#2A|FBK1wQfJgX3_jX+6s)))z)({u;YN!PPd)g@qIVA z?4I-RobT&-{<_cSJjdwH+~5g)H^bw}^CVw&>vq*{T-JN|)=f`0?+6{(Dn9c4{qyVI z`uL5O_Y%Xo$pg}Ek91u8;)MVL2q1s}0tg_000IagfB*u{2^9Gf~tmkUqp`R zAz2S8`VvKpD{5R;!m6gLQLVbZsS;M$sEREq!t2q1s}0tg_000Iag@P8Mul^2*Jsy5{XcD|W)q_OdZ z70K@f{8HE0{sQ8S7Xk<%fB*srAbz^+5I_I{ z1Q0*~0R#|000CzNvi$j8@kK;1`JUXrFw>u(Ep{gq7h~FYaOLK$>V^IHEaW}{XNHMh z5kLR|1Q0*~0R#|0009ILuqR;aK7yd=%XA+>BD!GdAK&ho&3y#+MuAocAbtC#;vyqC8$6gvl$=LX6;dJ+NimWEe15U!@Y!0*vd-(UaXjz01NZkln_f&c;t zAbz5xN8(B(98+{fEuHLn1kt{_g~x`z86YoUHHsWZ009ILKmY**5I_I{1Q0+Vodj&< z1?GtgOnHI!><6~px8<}%ULc*umTN%(0R#|0009ILKmY**5O9+~%JKpe%m12T=&|JT zzs3Fn6Ah-!3+!zEI=W_l_2=XT+%)5;1px#QKmY**5I_I{1Q0;L{Q|b~0`rB7DKGHV zv5`H#*ADC^FW~+eLxl(+fB*srAbjQ$b!JoyL}HK>z^+5I_I{ z1Q0*~0R#|mlR(Py0+*NnwXkeNqot95@H_(Ryg<#R;p-QdJa`j%0XNM!YC!-21Q0*~ z0R#|0009ILaKC`9yg*2}nDPR5H7#HA@Y}ubk{59QjG;mV5I_I{1Q0*~0R#|000CzN zEb; zk3d#5RaTQx1aYky;wQIm-LBe=%X$wt1U|Xg_Q8v%$O~AFBF7Ox009ILKmY**5I_I{ z1Q19k0b6;25>bIEFK}+KzkF8t7WNlNr=jIq5I_I{1Q0*~0R#|0009JCC6JoDfNA+( zSEGj2^9W20rpgO^|Iw*?x9@$jn7n|irX0l}fB*srAb= config.MIN_GAP_TO_TRADE + + if config.DEBUG: + directions = {c: v["direction"] for c, v in self.bias_matrix.items()} + print(f"[Confluence] Monthly bias matrix set: {directions}") + + def _check_boundary(self, short_ccy: str, long_ccy: str) -> Tuple[bool, str]: + """Check if a proposed trade crosses the Layer 1 macro boundary. + + A trade proposes SHORT short_ccy + LONG long_ccy. + Boundary rules: + - STRONG currencies cannot be shorted + - WEAK currencies cannot be longed + - NEUTRAL currencies have no restriction + + Returns: + (allowed: bool, reason: str) + """ + if not self.bias_matrix: + return True, "No macro bias set" + + short_dir = self.bias_matrix.get(short_ccy, {}).get("direction", "NEUTRAL") + long_dir = self.bias_matrix.get(long_ccy, {}).get("direction", "NEUTRAL") + + if short_dir == "STRONG": + return ( + False, + f"Cannot short {short_ccy}: classified STRONG by Layer 1 macro bias" + ) + if long_dir == "WEAK": + return ( + False, + f"Cannot long {long_ccy}: classified WEAK by Layer 1 macro bias" + ) + return True, "Within macro boundary" + + def check_entry_confluence(self, current_prices: Dict[str, float] = None + ) -> Tuple[bool, str, float]: + """Check entry conditions. + + Layer 2 operates freely. The ONLY constraint is the Layer 1 + macro directional boundary: STRONG currencies can't be shorted, + WEAK currencies can't be longed. + + Priority: + 1. Matrix divergence (currency-level) — checked against boundary + 2. Pair extreme Z-score (pair-level) — checked against boundary + + Returns: + (should_enter, reason, confluence_strength) + """ + self._build_matrix(current_prices) + + # === PRIMARY: Matrix divergence === + # If one currency is overbought across ALL pairs and another is + # oversold across ALL pairs, we have a genuine S.A.T.O.R.I. signal. + if self.matrix and self.matrix.has_divergence(): + mc = self.matrix.get_matrix_cross() + gap = self.matrix.get_divergence_gap() + + if mc and "_" in mc: + short_ccy, long_ccy = mc.split("_", 1) + allowed, reason = self._check_boundary(short_ccy, long_ccy) + if allowed: + confidence = min(abs(gap) / 4.0, 1.0) * 100 + self.confluence_strength = confidence + self.last_confluence_check = datetime.now() + return ( + True, + f"MATRIX DIVERGENCE: {mc} " + f"(Gap: {gap:.1f}σ, Strength: {confidence:.0f}%)", + confidence, + ) + else: + self.confluence_strength = 0.0 + self.last_confluence_check = datetime.now() + return False, f"MATRIX DIVERGENCE BLOCKED — {reason}", 0.0 + + # === SECONDARY: Any extreme pair Z-score, checked against boundary === + all_z = self.tech_analyzer.get_all_z_scores() + sorted_pairs = sorted(all_z.items(), key=lambda x: abs(x[1]), reverse=True) + + for pair, z_score in sorted_pairs: + if abs(z_score) < config.Z_SCORE_THRESHOLD: + continue + + base, quote = pair.split("_") + if z_score > 0: + short_ccy, long_ccy = base, quote + else: + short_ccy, long_ccy = quote, base + + allowed, reason = self._check_boundary(short_ccy, long_ccy) + if allowed: + confidence = min(abs(z_score) / 3.0, 1.0) * 100 + self.confluence_strength = confidence + self.last_confluence_check = datetime.now() + return ( + True, + f"PAIR EXTREME: {pair} Z={z_score:.2f} " + f"(Strength: {confidence:.0f}%)", + confidence, + ) + + return False, "No valid signals within macro boundary", 0.0 + + def check_exit_confluence(self) -> Tuple[bool, str]: + """Check if position should exit (mean reversion / boundary shift).""" + self._build_matrix() + + if self.matrix and not self.matrix.has_divergence(): + gap = self.matrix.get_divergence_gap() + return True, f"EXIT: Matrix divergence collapsed (gap: {gap:.2f}σ)" + + if self.layer1_strongest and self.layer1_weakest: + pair = f"{self.layer1_strongest}_{self.layer1_weakest}" + if self.tech_signal.should_exit_on_mean_reversion(pair): + z = self.tech_analyzer.get_z_score(pair) + return True, f"EXIT: {pair} mean reversion (Z-score: {z:.2f})" + + return False, "Position still valid" + + def is_conflicting(self) -> bool: + """Check if any extreme Layer 2 signal crosses the macro boundary.""" + if not self.bias_matrix: + return False + + all_z = self.tech_analyzer.get_all_z_scores() + for pair, z_score in all_z.items(): + if abs(z_score) < config.Z_SCORE_THRESHOLD: + continue + base, quote = pair.split("_") + if z_score > 0: + short_dir = self.bias_matrix.get(base, {}).get("direction", "NEUTRAL") + long_dir = self.bias_matrix.get(quote, {}).get("direction", "NEUTRAL") + else: + short_dir = self.bias_matrix.get(quote, {}).get("direction", "NEUTRAL") + long_dir = self.bias_matrix.get(base, {}).get("direction", "NEUTRAL") + if short_dir == "STRONG" or long_dir == "WEAK": + return True + return False + + def get_confluence_report(self, current_prices: Dict[str, float] = None) -> Dict: + """Get detailed confluence analysis report including matrix status.""" + self._build_matrix(current_prices) + matrix_report = self.matrix.get_report() if self.matrix else {} + pair = f"{self.layer1_strongest}_{self.layer1_weakest}" if self.layer1_strongest else "N/A" + + mc = matrix_report.get("matrix_cross", "N/A") + mc_z = self.tech_analyzer.get_z_score(mc) if mc and mc != "N/A" else 0.0 + + if pair != "N/A": + z_score = self.tech_analyzer.get_z_score(pair) + volatility = self.tech_analyzer.get_volatility(pair) + mean_price = self.tech_analyzer.get_mean_price(pair) + else: + z_score = 0.0 + volatility = 0.0 + mean_price = 0.0 + + return { + 'pair': pair, + 'layer1_gap': self.layer1_gap, + 'layer1_status': 'ACTIVE' if self.layer1_is_active else 'NO_TRADE', + 'layer2_z_score': z_score, + 'layer2_is_extreme': self.tech_analyzer.is_extreme(pair) if pair != "N/A" else False, + 'layer2_volatility': volatility, + 'layer2_mean_price': mean_price, + 'confluence_strength': self.confluence_strength, + 'last_check': self.last_confluence_check, + 'is_conflicting': self.is_conflicting(), + 'matrix_cross': mc, + 'matrix_cross_z': mc_z, + 'divergence_gap': matrix_report.get("divergence_gap", 0), + 'has_matrix_divergence': matrix_report.get("has_divergence", False), + 'matrix_ranked': matrix_report.get("ranked", []), + 'active_session': matrix_report.get("active_session", "N/A"), + 'bias_matrix': { + c: v["direction"] for c, v in self.bias_matrix.items() + } if self.bias_matrix else {}, + } + + def get_all_signals(self, current_prices: Dict[str, float] = None) -> Dict[str, Dict]: + """Get all available signals ranked by strength.""" + signals = {} + self._build_matrix(current_prices) + + if self.matrix and self.matrix.has_divergence(): + mc = self.matrix.get_matrix_cross() + if mc: + gap = self.matrix.get_divergence_gap() + strength = min(abs(gap) / 4.0, 1.0) * 100 + mc_z = self.tech_analyzer.get_z_score(mc) + + short_ccy, long_ccy = mc.split("_", 1) + allowed, _ = self._check_boundary(short_ccy, long_ccy) + if allowed: + signals[mc] = { + 'pair': mc, + 'type': 'MATRIX_DIVERGENCE', + 'strength': strength, + 'reason': f"Matrix cross {mc} (spread: {gap:.2f}σ)", + 'direction': 'SHORT' if strength > 50 else 'LONG', + } + + # Scan all extreme pairs + for pair, z_score in sorted( + self.tech_analyzer.get_all_z_scores().items(), + key=lambda x: abs(x[1]), reverse=True + ): + if abs(z_score) < config.Z_SCORE_THRESHOLD: + continue + if pair in signals: + continue + + base, quote = pair.split("_") + if z_score > 0: + short_ccy, long_ccy = base, quote + else: + short_ccy, long_ccy = quote, base + + allowed, _ = self._check_boundary(short_ccy, long_ccy) + if allowed: + strength = min(abs(z_score) / 3.0, 1.0) * 100 + signals[pair] = { + 'pair': pair, + 'type': 'PAIR_EXTREME', + 'strength': strength, + 'reason': f"{pair} Z={z_score:.2f} within macro boundary", + 'direction': 'SHORT' if z_score > 0 else 'LONG', + } + + return signals + + +class SignalHistory: + """Track historical confluence signals for analysis.""" + + def __init__(self): + self.signals = [] + self.max_history = 1000 + + def add_signal(self, signal: Dict): + signal['timestamp'] = datetime.now() + self.signals.append(signal) + if len(self.signals) > self.max_history: + self.signals = self.signals[-self.max_history:] + + def get_signals_for_pair(self, pair: str) -> list: + return [s for s in self.signals if s.get('pair') == pair] + + def get_recent_signals(self, hours: int = 24) -> list: + cutoff = datetime.now().timestamp() - (hours * 3600) + return [s for s in self.signals if s['timestamp'].timestamp() > cutoff] + + def get_win_rate(self, pair: str = None) -> float: + if pair: + sigs = self.get_signals_for_pair(pair) + else: + sigs = self.signals + if not sigs: + return 0.0 + wins = sum(1 for s in sigs if s.get('result') == 'WIN') + return (wins / len(sigs)) * 100 + + def clear(self): + self.signals = [] diff --git a/create_excel_template.py b/create_excel_template.py index 1b73d5e..4d4343f 100644 --- a/create_excel_template.py +++ b/create_excel_template.py @@ -12,8 +12,7 @@ This will generate: """ import pandas as pd -from openpyxl import Workbook -from openpyxl.styles import Font, PatternFill, Alignment + import config from datetime import datetime diff --git a/currency_strength_matrix.py b/currency_strength_matrix.py new file mode 100644 index 0000000..4aff531 --- /dev/null +++ b/currency_strength_matrix.py @@ -0,0 +1,205 @@ +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from datetime import datetime, timezone +import statistics +import config + + +@dataclass +class CurrencyStrength: + name: str + avg_z_score: float + rank: int + is_overbought: bool + is_oversold: bool + direction: str + session_srv: float = 0.0 # Session Relative Velocity (Task 1.2) + + +class SessionTracker: + """Detects the active trading session and tracks session-start snapshots. + + Sessions (UTC): + Tokyo: 00:00–08:00 + London: 07:00–16:00 + New York: 13:00–22:00 + Overlapping hours are resolved as London (the dominant session). + """ + + TOKYO = "Tokyo" + LONDON = "London" + NEWYORK = "New York" + + def __init__(self): + self.current_session: Optional[str] = None + self.session_start_prices: Dict[str, float] = {} # pair -> price at session open + self.session_open_time: Optional[datetime] = None + + def get_active_session(self, utc_hour: int = None) -> str: + if utc_hour is None: + utc_hour = datetime.now(timezone.utc).hour + if config.SESSION_LONDON_OPEN <= utc_hour < config.SESSION_LONDON_CLOSE: + return self.LONDON + if config.SESSION_TOKYO_OPEN <= utc_hour < config.SESSION_TOKYO_CLOSE: + return self.TOKYO + if config.SESSION_NEWYORK_OPEN <= utc_hour < config.SESSION_NEWYORK_CLOSE: + return self.NEWYORK + return "Off-Hours" + + def check_new_session(self, current_prices: Dict[str, float]) -> Optional[str]: + """Detect if a new session has started and snapshot prices.""" + now = datetime.now(timezone.utc) + session = self.get_active_session(now.hour) + if session != self.current_session and session != "Off-Hours": + self.current_session = session + self.session_start_prices = dict(current_prices) + self.session_open_time = now + return session + if self.current_session is None: + self.current_session = session + if session != "Off-Hours": + self.session_start_prices = dict(current_prices) + self.session_open_time = now + return None + + def compute_srv(self, pair: str, current_price: float) -> float: + """Session Relative Velocity: % change from session open to now.""" + start = self.session_start_prices.get(pair) + if start is None or start == 0: + return 0.0 + return ((current_price - start) / start) * 100.0 + + +class CurrencyStrengthMatrix: + """Computes individual currency strength indices from pair Z-scores. + + Includes Session-Based Indexing (Task 1.2): + - Tracks performance since Tokyo/London/NY session opens + - Session Relative Velocity (SRV) per currency + """ + + def __init__(self, z_scores: Dict[str, float] = None): + self.currencies = config.CURRENCIES + self.threshold = config.Z_SCORE_THRESHOLD + self._raw_scores: Dict[str, List[float]] = {} + self._strengths: Dict[str, CurrencyStrength] = {} + self.session_tracker = SessionTracker() + self._srv_map: Dict[str, float] = {} # currency -> avg SRV + if z_scores: + self.update(z_scores) + + def update(self, z_scores: Dict[str, float], current_prices: Dict[str, float] = None): + """Recompute all currency strengths from 28 pair Z-scores. + + If current_prices is provided, also updates session tracking + and computes Session Relative Velocity. + """ + self._raw_scores = {} + for ccy in self.currencies: + scores = [] + for other in self.currencies: + if other == ccy: + continue + pair = f"{ccy}_{other}" + z = z_scores.get(pair) + if z is not None: + scores.append(z) + self._raw_scores[ccy] = scores + + strengths = {} + for ccy, scores in self._raw_scores.items(): + avg_z = statistics.mean(scores) if scores else 0.0 + strengths[ccy] = CurrencyStrength( + name=ccy, + avg_z_score=avg_z, + rank=0, + is_overbought=avg_z >= self.threshold, + is_oversold=avg_z <= -self.threshold, + direction="OVERBOUGHT" if avg_z >= self.threshold else ("OVERSOLD" if avg_z <= -self.threshold else "NEUTRAL"), + ) + + sorted_ccys = sorted(strengths.keys(), key=lambda c: strengths[c].avg_z_score, reverse=True) + for rank, ccy in enumerate(sorted_ccys, 1): + strengths[ccy].rank = rank + + # Session tracking (Task 1.2) + if current_prices: + new_session = self.session_tracker.check_new_session(current_prices) + self._compute_srv(current_prices, strengths) + + self._strengths = strengths + + def _compute_srv(self, current_prices: Dict[str, float], + strengths: Dict[str, CurrencyStrength]): + """Compute average Session Relative Velocity per currency.""" + srv_scores: Dict[str, List[float]] = {c: [] for c in self.currencies} + for ccy in self.currencies: + for other in self.currencies: + if other == ccy: + continue + pair = f"{ccy}_{other}" + price = current_prices.get(pair) + if price is not None: + srv = self.session_tracker.compute_srv(pair, price) + srv_scores[ccy].append(srv) + for ccy in self.currencies: + scores = srv_scores[ccy] + self._srv_map[ccy] = statistics.mean(scores) if scores else 0.0 + if ccy in strengths: + strengths[ccy].session_srv = self._srv_map[ccy] + + def get_strongest(self) -> Optional[CurrencyStrength]: + return max(self._strengths.values(), key=lambda s: s.avg_z_score) if self._strengths else None + + def get_weakest(self) -> Optional[CurrencyStrength]: + return min(self._strengths.values(), key=lambda s: s.avg_z_score) if self._strengths else None + + def get_matrix_cross(self) -> Optional[str]: + s = self.get_strongest() + w = self.get_weakest() + if s and w and s.name != w.name: + return f"{s.name}_{w.name}" + return None + + def get_divergence_gap(self) -> float: + s = self.get_strongest() + w = self.get_weakest() + return (s.avg_z_score - w.avg_z_score) if s and w else 0.0 + + def has_divergence(self) -> bool: + s = self.get_strongest() + w = self.get_weakest() + return bool(s and w and s.is_overbought and w.is_oversold) + + def get_strong_currencies(self) -> List[str]: + return [c.name for c in self._strengths.values() if c.is_overbought] + + def get_weak_currencies(self) -> List[str]: + return [c.name for c in self._strengths.values() if c.is_oversold] + + def get_ranked_list(self) -> List[CurrencyStrength]: + return sorted(self._strengths.values(), key=lambda s: s.rank) + + def get_active_session(self) -> str: + return self.session_tracker.get_active_session() + + def get_srv_map(self) -> Dict[str, float]: + return dict(self._srv_map) + + def get_report(self) -> Dict: + ranked = self.get_ranked_list() + s = self.get_strongest() + w = self.get_weakest() + return { + "ranked": [(c.name, round(c.avg_z_score, 2), c.direction, round(c.session_srv, 4)) for c in ranked], + "strongest": s.name if s else None, + "strongest_z": round(s.avg_z_score, 2) if s else 0, + "weakest": w.name if w else None, + "weakest_z": round(w.avg_z_score, 2) if w else 0, + "matrix_cross": self.get_matrix_cross(), + "divergence_gap": round(self.get_divergence_gap(), 2), + "has_divergence": self.has_divergence(), + "overbought": self.get_strong_currencies(), + "oversold": self.get_weak_currencies(), + "active_session": self.get_active_session(), + } diff --git a/data_feeder.py b/data_feeder.py new file mode 100644 index 0000000..48e3f36 --- /dev/null +++ b/data_feeder.py @@ -0,0 +1,569 @@ +""" +APEX Layer 2 — MetaTrader 5 Data Feeder + +Fetches forex data from a local MetaTrader 5 terminal. +- Connects via the MetaTrader5 Python package +- Gets real-time bid/ask prices from symbol_info_tick() +- Gets historical candles from copy_rates_from_pos() +- Configurable symbol suffix (e.g., .m for OANDA MT5) + +Requirements: +- MetaTrader 5 terminal installed and running with a demo/live account +- pip install MetaTrader5 + +Fallback: +- MockDataFeeder for testing without MT5 +""" + +import time +import threading +from typing import Dict, List, Optional, Callable +from datetime import datetime, timedelta +import config + + +class Mt5DataFeeder: + """Fetches forex data from MetaTrader 5 terminal. + + Strategy: fetch 7 major USD pairs (every broker has them), + then derive all 28 cross rates. No need for exotic symbol lookups. + """ + + # Every MT5 broker has these 7 pairs covering all 8 currencies vs USD + USD_PAIRS = ["EUR_USD", "GBP_USD", "AUD_USD", "NZD_USD", + "USD_JPY", "USD_CAD", "USD_CHF"] + + def __init__(self, symbol_suffix: str = None): + self.symbol_suffix = symbol_suffix if symbol_suffix is not None else config.MT5_SYMBOL_SUFFIX + self.connected = False + self.last_error = None + self._mt5 = None + self.cached_rates: Dict[str, float] = {} + self.price_callbacks = [] + self.error_callbacks = [] + self._running = False + self._symbols_enabled = set() + + def initialize(self) -> bool: + try: + import MetaTrader5 as mt5 + self._mt5 = mt5 + if not mt5.initialize(): + self.last_error = "MT5 terminal not running. Start MetaTrader 5 first." + self.connected = False + return False + self.connected = True + self.last_error = None + if config.DEBUG: + print("[MT5] Initialized successfully") + return True + except ImportError: + self.last_error = ( + "MetaTrader5 package not installed.\n" + "Run: pip install MetaTrader5" + ) + self.connected = False + return False + except Exception as e: + self.last_error = f"MT5 init error: {e}" + self.connected = False + return False + + def test_connection(self) -> bool: + return self.initialize() + + def shutdown(self): + if self._mt5: + self._mt5.shutdown() + self.connected = False + + def get_connection_status(self) -> str: + if self.connected: + return "Connected" + return f"Disconnected: {self.last_error or 'Unknown'}" + + def _mt5_pair(self, pair: str) -> str: + return pair.replace("_", "") + self.symbol_suffix + + def _ensure_symbol(self, symbol: str) -> bool: + if symbol in self._symbols_enabled: + return True + if not self._mt5.symbol_select(symbol, True): + self.last_error = f"Cannot enable symbol: {symbol}" + if config.DEBUG: + print(f"[MT5] Cannot enable symbol: {symbol}") + return False + self._symbols_enabled.add(symbol) + if config.DEBUG: + print(f"[MT5] Enabled symbol: {symbol}") + return True + + def get_current_price(self, currency_pair: str) -> Optional[Dict]: + if not self.connected: + return None + mt5_pair = self._mt5_pair(currency_pair) + if not self._ensure_symbol(mt5_pair): + return None + tick = self._mt5.symbol_info_tick(mt5_pair) + if tick is None: + self.last_error = f"No tick data for: {mt5_pair}" + return None + return { + "pair": currency_pair, + "time": datetime.fromtimestamp(tick.time).isoformat(), + "mid": (tick.bid + tick.ask) / 2, + "bid": tick.bid, + "ask": tick.ask, + } + + def get_all_major_pairs(self) -> List[str]: + pairs = [] + for base in config.CURRENCIES: + for quote in config.CURRENCIES: + if base != quote: + pairs.append(f"{base}_{quote}") + return pairs + + def fetch_all_rates(self) -> Dict[str, float]: + """Fetch 7 USD pairs and derive all 28 cross rates.""" + if not self.connected: + return {} + + usd_rates: Dict[str, Optional[float]] = {} + for pair in self.USD_PAIRS: + price = self.get_current_price(pair) + if price is None: + continue + base, quote = pair.split("_") + mid = price["mid"] + if base == "USD": + usd_rates[quote] = 1.0 / mid if mid != 0 else None + else: + usd_rates[base] = mid + + usd_rates["USD"] = 1.0 + + rates = {} + for base in config.CURRENCIES: + for quote in config.CURRENCIES: + if base == quote: + continue + base_val = usd_rates.get(base) + quote_val = usd_rates.get(quote) + if base_val is not None and quote_val is not None: + rates[f"{base}_{quote}"] = base_val / quote_val + + self.cached_rates.update(rates) + return rates + + def get_exchange_rate(self, from_currency: str, to_currency: str) -> Optional[float]: + pair = f"{from_currency}_{to_currency}" + if pair in self.cached_rates: + return self.cached_rates[pair] + if not self.connected: + return None + rates = self.fetch_all_rates() + return rates.get(pair) + + def get_order_book(self, currency_pair: str) -> Optional[Dict]: + """Fetch live bid/ask/spread for the exact trade symbol (Task 2.1). + + Called by ConfluenceFilter when a pair reaches actionable divergence, + rather than relying on synthetic mid-price for execution decisions. + """ + if not self.connected: + return None + mt5_pair = self._mt5_pair(currency_pair) + if not self._ensure_symbol(mt5_pair): + return None + tick = self._mt5.symbol_info_tick(mt5_pair) + if tick is None: + return None + symbol_info = self._mt5.symbol_info(mt5_pair) + spread = (symbol_info.spread if symbol_info else 0) * ( + symbol_info.point if symbol_info else 0.0001 + ) + return { + "pair": currency_pair, + "bid": tick.bid, + "ask": tick.ask, + "spread": spread, + "mid": (tick.bid + tick.ask) / 2, + "time": datetime.fromtimestamp(tick.time).isoformat(), + } + + def fetch_historical_closes_all_pairs( + self, days: int = 30, interval: str = "1d" + ) -> Dict[str, List[float]]: + """Fetch past N days of Close prices for all 28 pairs (Task 3.2). + + Used by BasketHedging to compute a rolling Pearson correlation matrix. + Returns dict mapping pair -> list of close prices (oldest first). + """ + if not self.connected: + return {} + timeframe_map = { + "1min": self._mt5.TIMEFRAME_M1, + "5min": self._mt5.TIMEFRAME_M5, + "1d": self._mt5.TIMEFRAME_D1, + } + tf = timeframe_map.get(interval, self._mt5.TIMEFRAME_D1) + count_map = {"1min": 1440 * days, "5min": 288 * days, "1d": days} + count = count_map.get(interval, days) + + all_closes: Dict[str, List[float]] = {} + pairs = self.get_all_major_pairs() + for pair in pairs: + mt5_pair = self._mt5_pair(pair) + if not self._ensure_symbol(mt5_pair): + continue + rates = self._mt5.copy_rates_from_pos(mt5_pair, tf, 0, count) + if rates is not None: + closes = [r.close for r in rates] + all_closes[pair] = closes + return all_closes + + def get_historical_candles( + self, + from_currency: str = "USD", + to_currency: str = "JPY", + interval: str = "1h", + outputsize: str = "compact", + ) -> Optional[List[Dict]]: + if not self.connected: + return None + + timeframe_map = { + "1min": self._mt5.TIMEFRAME_M1, + "5min": self._mt5.TIMEFRAME_M5, + "15min": self._mt5.TIMEFRAME_M15, + "30min": self._mt5.TIMEFRAME_M30, + "1h": self._mt5.TIMEFRAME_H1, + "60min": self._mt5.TIMEFRAME_H1, + "4h": self._mt5.TIMEFRAME_H4, + "1d": self._mt5.TIMEFRAME_D1, + "1w": self._mt5.TIMEFRAME_W1, + } + + tf = timeframe_map.get(interval) + if tf is None: + self.last_error = f"Unknown interval: {interval}" + return None + + mt5_pair = self._mt5_pair(f"{from_currency}_{to_currency}") + if not self._ensure_symbol(mt5_pair): + return None + count = 100 if outputsize == "full" else 20 + + rates = self._mt5.copy_rates_from_pos(mt5_pair, tf, 0, count) + if rates is None: + self.last_error = f"No historical data for {mt5_pair} ({interval})" + return None + + candles = [] + for r in rates: + candles.append({ + "time": datetime.fromtimestamp(r.time).isoformat(), + "open": r.open, + "high": r.high, + "low": r.low, + "close": r.close, + }) + return candles + + def stream_prices( + self, + instruments: List[str], + callback: Callable = None, + poll_interval: int = 1, + ): + """Poll 7 USD pairs, derive all 28 rates, feed callback for each instrument.""" + if not callback: + return + + self.price_callbacks.append(callback) + self._running = True + + def poll_loop(): + while self._running: + all_rates = self.fetch_all_rates() + for pair in instruments: + rate = all_rates.get(pair) + if rate: + callback({ + "pair": pair, + "time": datetime.now().isoformat(), + "mid": rate, + "bid": rate, + "ask": rate, + }) + time.sleep(poll_interval) + + thread = threading.Thread(target=poll_loop, daemon=True) + thread.start() + + def on_price_update(self, callback: Callable): + self.price_callbacks.append(callback) + + def on_error(self, callback: Callable): + self.error_callbacks.append(callback) + + def stop_streaming(self): + self._running = False + if config.DEBUG: + print("[MT5] Streaming stopped") + + +class MockDataFeeder: + """Mock data feeder for testing — simulates intraday prices for all 28 pairs.""" + + USD_PAIRS = ["EUR_USD", "GBP_USD", "AUD_USD", "NZD_USD", + "USD_JPY", "USD_CAD", "USD_CHF"] + + def __init__(self): + self.base_prices = { + "EUR_USD": 1.0850, + "GBP_USD": 1.2650, + "AUD_USD": 0.6650, + "NZD_USD": 0.6050, + "USD_JPY": 149.50, + "USD_CAD": 1.3750, + "USD_CHF": 0.8920, + } + self.price_callbacks = [] + self.connected = True + self._running = False + self._cached_bars: Dict[str, List[float]] = {} + self._tick_index = 0 + + def test_connection(self) -> bool: + return True + + def get_all_major_pairs(self) -> List[str]: + pairs = [] + for base in config.CURRENCIES: + for quote in config.CURRENCIES: + if base != quote: + pairs.append(f"{base}_{quote}") + return pairs + + def generate_mock_bars(self, n_bars: int = 288) -> Dict[str, List[float]]: + """Generate n_bars simulated M5 close prices with realistic behavior. + + Uses an Ornstein-Uhlenbeck process (mean-reverting random walk with + drift) for each of the 7 USD pairs, then derives all 28 cross rates. + This gives 24h (288 M5 bars) of realistic forex data where Z-scores + reflect genuine multi-hour deviations. + + Caches the generated bars so subsequent tick prices are anchored + to the last bar close — not the initial base price. + """ + import random + bars: Dict[str, List[float]] = {} + usd_pair_bars: Dict[str, List[float]] = {} + + for pair in self.USD_PAIRS: + base = self.base_prices.get(pair, 1.0) + series = [] + price = base + drift = random.uniform(-config.MOCK_DRIFT, config.MOCK_DRIFT) + theta = config.MOCK_THETA + long_term_mean = base + + for i in range(n_bars): + noise = random.gauss(0, config.MOCK_NOISE_STD) + reversion = theta * (long_term_mean - price) + seasonal = config.MOCK_SEASONAL_AMP * random.uniform(-1, 1) + price = price + reversion + drift + seasonal + noise + series.append(price) + + usd_pair_bars[pair] = series + + pairs_list = self.get_all_major_pairs() + for pair in pairs_list: + base_c, quote_c = pair.split("_") + derived = [] + for i in range(n_bars): + usd_rates = {"USD": 1.0} + for up in self.USD_PAIRS: + b, q = up.split("_") + mid = usd_pair_bars[up][i] + if b == "USD": + usd_rates[q] = 1.0 / mid if mid else 0 + else: + usd_rates[b] = mid + bv = usd_rates.get(base_c, 0) + qv = usd_rates.get(quote_c, 1) + derived.append(bv / qv if qv else 0) + bars[pair] = derived + + self._cached_bars = bars + self._tick_index = 0 + return bars + + def _current_bar_prices(self) -> Dict[str, float]: + """Get the latest bar close prices for all pairs.""" + if not self._cached_bars: + return {} + prices = {} + for pair in self.get_all_major_pairs(): + bars = self._cached_bars.get(pair) + if bars: + prices[pair] = bars[-1] + return prices + + def _tick_price(self, pair: str) -> float: + """Return price anchored to last bar close + small noise. + + Uses the last bar close from _cached_bars as the anchor, so the + tick price is always near the most recent bar and Z-scores reflect + the bar position relative to the 24-hour history, not random noise. + """ + import random + last_bars = self._cached_bars.get(pair) if self._cached_bars else None + if last_bars and len(last_bars) > 0: + base = last_bars[-1] + else: + base = self.base_prices.get(pair, 1.0) + return base + random.uniform(-config.MOCK_TICK_NOISE, config.MOCK_TICK_NOISE) + + def get_current_price(self, currency_pair: str) -> Optional[Dict]: + """Get price for any pair, deriving cross rates from USD pairs.""" + import random + usd_rates = {} + for p in self.USD_PAIRS: + base, quote = p.split("_") + mid = self._tick_price(p) + if base == "USD": + usd_rates[quote] = 1.0 / mid if mid != 0 else None + else: + usd_rates[base] = mid + usd_rates["USD"] = 1.0 + + base_c, quote_c = currency_pair.split("_") + base_val = usd_rates.get(base_c) + quote_val = usd_rates.get(quote_c) + if base_val is None or quote_val is None: + return None + price = base_val / quote_val + return { + "pair": currency_pair, + "time": datetime.now().isoformat(), + "mid": price, + "bid": price - config.MOCK_BID_ASK_SPREAD, + "ask": price + config.MOCK_BID_ASK_SPREAD, + } + + def fetch_all_rates(self) -> Dict[str, float]: + """Derive all 28 cross rates from 7 USD pairs (same as Mt5DataFeeder).""" + usd_rates: Dict[str, Optional[float]] = {} + for p in self.USD_PAIRS: + base, quote = p.split("_") + mid = self._tick_price(p) + if base == "USD": + usd_rates[quote] = 1.0 / mid if mid != 0 else None + else: + usd_rates[base] = mid + usd_rates["USD"] = 1.0 + + rates = {} + for base in config.CURRENCIES: + for quote in config.CURRENCIES: + if base == quote: + continue + bv = usd_rates.get(base) + qv = usd_rates.get(quote) + if bv is not None and qv is not None: + rates[f"{base}_{quote}"] = bv / qv + return rates + + def get_order_book(self, currency_pair: str) -> Optional[Dict]: + """Mock order book — simulated bid/ask/spread.""" + price = self.get_current_price(currency_pair) + if not price: + return None + return { + "pair": currency_pair, + "bid": price["mid"] - config.MOCK_BID_ASK_SPREAD, + "ask": price["mid"] + config.MOCK_BID_ASK_SPREAD, + "spread": config.MOCK_BID_ASK_SPREAD * 2, + "mid": price["mid"], + "time": datetime.now().isoformat(), + } + + def fetch_historical_closes_all_pairs( + self, days: int = 30, interval: str = "1d" + ) -> Dict[str, List[float]]: + """Mock historical close prices — random walk for all 28 pairs.""" + import random + closes: Dict[str, List[float]] = {} + pairs = self.get_all_major_pairs() + all_rates = self.fetch_all_rates() + for pair in pairs: + base = all_rates.get(pair, 1.0) + series = [] + price = base + for _ in range(days): + price += random.uniform(-config.MOCK_HISTORICAL_DAILY_NOISE, config.MOCK_HISTORICAL_DAILY_NOISE) + series.append(price) + closes[pair] = series + return closes + + def get_historical_candles( + self, + from_currency: str = "USD", + to_currency: str = "JPY", + interval: str = "1min", + outputsize: str = "compact", + ) -> Optional[List[Dict]]: + import random + candles = [] + count = 100 if outputsize == "full" else 20 + pair = f"{from_currency}_{to_currency}" + base = self._cached_bars.get(pair, [None])[-1] if self._cached_bars.get(pair) else 1.0 + for i in range(count): + noise = random.uniform(-0.005, 0.005) + price = base + noise + candles.append({ + "time": (datetime.now() - timedelta(minutes=count - i)).isoformat(), + "open": price, + "high": price + 0.01, + "low": price - 0.01, + "close": price + random.uniform(-0.005, 0.005), + }) + return candles + + def stream_prices(self, instruments: List[str], callback: Callable, poll_interval: int = 1): + """Derive all 28 rates and feed callback for each instrument (same as MT5).""" + self.price_callbacks.append(callback) + self._running = True + + def mock_stream(): + while self._running: + all_rates = self.fetch_all_rates() + for pair in instruments: + rate = all_rates.get(pair) + if rate: + callback({ + "pair": pair, + "time": datetime.now().isoformat(), + "mid": rate, + "bid": rate, + "ask": rate, + }) + time.sleep(poll_interval) + + thread = threading.Thread(target=mock_stream, daemon=True) + thread.start() + + def stop_streaming(self): + """Stop the mock data stream.""" + self._running = False + if config.DEBUG: + print("[Mock] Streaming stopped") + + def on_price_update(self, callback: Callable): + self.price_callbacks.append(callback) + + def on_error(self, callback: Callable): + pass diff --git a/database.py b/database.py index c9c3870..e540600 100644 --- a/database.py +++ b/database.py @@ -13,7 +13,8 @@ All operations use parameterized queries to prevent SQL injection. """ import sqlite3 -from datetime import datetime +import threading +from datetime import datetime, timezone from pathlib import Path from typing import Optional, Dict, List, Tuple import config @@ -38,14 +39,17 @@ class Database: Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) try: + self._lock = threading.Lock() self.conn = sqlite3.connect( self.db_path, timeout=config.DB_TIMEOUT, check_same_thread=False # Allow access from multiple threads ) self.conn.row_factory = sqlite3.Row # Return rows as dicts - - # Enable foreign keys + + # Performance PRAGMAs (Task 2.2 — WAL + synchronous=NORMAL) + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA synchronous=NORMAL") self.conn.execute("PRAGMA foreign_keys = ON") if config.DB_AUTO_CREATE: @@ -56,75 +60,96 @@ class Database: def _create_schema(self): """Create database schema if it doesn't exist.""" - cursor = self.conn.cursor() - try: - # Table 1: Interest rates (updated via FRED API) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS rates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - currency TEXT NOT NULL UNIQUE, - rate REAL NOT NULL, - updated_at TEXT NOT NULL, - source TEXT DEFAULT 'FRED', - CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')) - ); - """) + with self._lock: + cursor = self.conn.cursor() - # Table 2: Monthly manual entries (CPI + PMI) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS monthly_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - month TEXT NOT NULL, - currency TEXT NOT NULL, - cpi_actual REAL, - pmi_actual REAL, - entered_at TEXT NOT NULL, - UNIQUE(month, currency), - CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')), - CONSTRAINT valid_month CHECK (month LIKE '____-__') - ); - """) - - # Table 3: Calculated scores (generated after each data entry) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS scores ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - month TEXT NOT NULL, - currency TEXT NOT NULL, - score_rate REAL, - score_cpi REAL, - score_pmi REAL, - total_score REAL NOT NULL, - rank INTEGER NOT NULL, - calculated_at TEXT NOT NULL, - UNIQUE(month, currency), - CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')), - CONSTRAINT valid_month CHECK (month LIKE '____-__') - ); - """) - - # Table 4: Signal log (one per month) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - generated_at TEXT NOT NULL, - month TEXT NOT NULL UNIQUE, - strongest TEXT NOT NULL, - weakest TEXT NOT NULL, - gap REAL NOT NULL, - signal TEXT NOT NULL, - status TEXT NOT NULL, - CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED')), - CONSTRAINT valid_month CHECK (month LIKE '____-__') - ); - """) - - self.conn.commit() - - if config.DEBUG: - print("[DB] Schema created successfully") + # Table 1: Interest rates (updated via FRED API) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS rates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + currency TEXT NOT NULL UNIQUE, + rate REAL NOT NULL, + updated_at TEXT NOT NULL, + source TEXT DEFAULT 'FRED', + CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')) + ); + """) + # Table 2: Monthly manual entries (CPI + PMI) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS monthly_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + currency TEXT NOT NULL, + cpi_actual REAL, + pmi_actual REAL, + entered_at TEXT NOT NULL, + UNIQUE(month, currency), + CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')), + CONSTRAINT valid_month CHECK (month LIKE '____-__') + ); + """) + + # Table 3: Calculated scores (generated after each data entry) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + currency TEXT NOT NULL, + score_rate REAL, + score_cpi REAL, + score_pmi REAL, + total_score REAL NOT NULL, + rank INTEGER NOT NULL, + calculated_at TEXT NOT NULL, + UNIQUE(month, currency), + CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')), + CONSTRAINT valid_month CHECK (month LIKE '____-__') + ); + """) + + # Table 4: M1/M5 Interval Bar Cache (Task 2.2 — optimized for bar storage) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS bar_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pair TEXT NOT NULL, + timeframe TEXT NOT NULL CHECK (timeframe IN ('M1', 'M5')), + bar_time TEXT NOT NULL, + open REAL, + high REAL, + low REAL, + close REAL NOT NULL, + volume INTEGER DEFAULT 0, + UNIQUE(pair, timeframe, bar_time) + ); + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_bar_cache_lookup + ON bar_cache(pair, timeframe, bar_time); + """) + + # Table 5: Signal log (one per month) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generated_at TEXT NOT NULL, + month TEXT NOT NULL UNIQUE, + strongest TEXT NOT NULL, + weakest TEXT NOT NULL, + gap REAL NOT NULL, + signal TEXT NOT NULL, + status TEXT NOT NULL, + CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED')), + CONSTRAINT valid_month CHECK (month LIKE '____-__') + ); + """) + + self.conn.commit() + + if config.DEBUG: + print("[DB] Schema created successfully") + except sqlite3.Error as e: self.conn.rollback() raise RuntimeError(f"Failed to create schema: {e}") @@ -145,21 +170,22 @@ class Database: if currency not in config.CURRENCIES: raise ValueError(f"Invalid currency: {currency}") - cursor = self.conn.cursor() try: - cursor.execute(""" - INSERT INTO rates (currency, rate, updated_at, source) - VALUES (?, ?, ?, ?) - ON CONFLICT(currency) DO UPDATE SET - rate = excluded.rate, - updated_at = excluded.updated_at, - source = excluded.source - """, (currency, rate, datetime.utcnow().isoformat(), source)) - self.conn.commit() - - if config.DEBUG: - print(f"[DB] Rate updated: {currency} = {rate}% (from {source})") + with self._lock: + cursor = self.conn.cursor() + cursor.execute(""" + INSERT INTO rates (currency, rate, updated_at, source) + VALUES (?, ?, ?, ?) + ON CONFLICT(currency) DO UPDATE SET + rate = excluded.rate, + updated_at = excluded.updated_at, + source = excluded.source + """, (currency, rate, datetime.now(timezone.utc).isoformat(), source)) + self.conn.commit() + if config.DEBUG: + print(f"[DB] Rate updated: {currency} = {rate}% (from {source})") + except sqlite3.Error as e: self.conn.rollback() raise RuntimeError(f"Failed to upsert rate for {currency}: {e}") @@ -220,30 +246,31 @@ class Database: if currency not in config.CURRENCIES: raise ValueError(f"Invalid currency: {currency}") - cursor = self.conn.cursor() try: - # First, get existing PMI if any - cursor.execute( - "SELECT pmi_actual FROM monthly_data WHERE month = ? AND currency = ?", - (month, currency) - ) - row = cursor.fetchone() - pmi = row["pmi_actual"] if row else None - - # Upsert with CPI - cursor.execute(""" - INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(month, currency) DO UPDATE SET - cpi_actual = excluded.cpi_actual, - entered_at = excluded.entered_at - """, (month, currency, cpi, pmi, datetime.utcnow().isoformat())) - - self.conn.commit() - - if config.DEBUG: - print(f"[DB] CPI saved: {month} {currency} = {cpi}%") + with self._lock: + cursor = self.conn.cursor() + # First, get existing PMI if any + cursor.execute( + "SELECT pmi_actual FROM monthly_data WHERE month = ? AND currency = ?", + (month, currency) + ) + row = cursor.fetchone() + pmi = row["pmi_actual"] if row else None + # Upsert with CPI + cursor.execute(""" + INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(month, currency) DO UPDATE SET + cpi_actual = excluded.cpi_actual, + entered_at = excluded.entered_at + """, (month, currency, cpi, pmi, datetime.now(timezone.utc).isoformat())) + + self.conn.commit() + + if config.DEBUG: + print(f"[DB] CPI saved: {month} {currency} = {cpi}%") + except sqlite3.Error as e: self.conn.rollback() raise RuntimeError(f"Failed to update CPI for {currency} in {month}: {e}") @@ -260,30 +287,31 @@ class Database: if currency not in config.CURRENCIES: raise ValueError(f"Invalid currency: {currency}") - cursor = self.conn.cursor() try: - # First, get existing CPI if any - cursor.execute( - "SELECT cpi_actual FROM monthly_data WHERE month = ? AND currency = ?", - (month, currency) - ) - row = cursor.fetchone() - cpi = row["cpi_actual"] if row else None - - # Upsert with PMI - cursor.execute(""" - INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(month, currency) DO UPDATE SET - pmi_actual = excluded.pmi_actual, - entered_at = excluded.entered_at - """, (month, currency, cpi, pmi, datetime.utcnow().isoformat())) - - self.conn.commit() - - if config.DEBUG: - print(f"[DB] PMI saved: {month} {currency} = {pmi}") + with self._lock: + cursor = self.conn.cursor() + # First, get existing CPI if any + cursor.execute( + "SELECT cpi_actual FROM monthly_data WHERE month = ? AND currency = ?", + (month, currency) + ) + row = cursor.fetchone() + cpi = row["cpi_actual"] if row else None + # Upsert with PMI + cursor.execute(""" + INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(month, currency) DO UPDATE SET + pmi_actual = excluded.pmi_actual, + entered_at = excluded.entered_at + """, (month, currency, cpi, pmi, datetime.now(timezone.utc).isoformat())) + + self.conn.commit() + + if config.DEBUG: + print(f"[DB] PMI saved: {month} {currency} = {pmi}") + except sqlite3.Error as e: self.conn.rollback() raise RuntimeError(f"Failed to update PMI for {currency} in {month}: {e}") @@ -344,6 +372,68 @@ class Database: except sqlite3.Error as e: raise RuntimeError(f"Failed to check month completeness for {month}: {e}") + # ======================================================================== + # BAR_CACHE Table Operations (Task 2.2) + # ======================================================================== + + def upsert_bar( + self, pair: str, timeframe: str, bar_time: str, + open_p: float, high: float, low: float, close: float, volume: int = 0 + ) -> None: + with self._lock: + cursor = self.conn.cursor() + cursor.execute(""" + INSERT INTO bar_cache (pair, timeframe, bar_time, open, high, low, close, volume) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pair, timeframe, bar_time) DO UPDATE SET + open = excluded.open, + high = excluded.high, + low = excluded.low, + close = excluded.close, + volume = excluded.volume + """, (pair, timeframe, bar_time, open_p, high, low, close, volume)) + self.conn.commit() + + def get_bars( + self, pair: str, timeframe: str, limit: int = 288 + ) -> List[Dict]: + cursor = self.conn.cursor() + try: + cursor.execute(""" + SELECT bar_time, open, high, low, close, volume + FROM bar_cache + WHERE pair = ? AND timeframe = ? + ORDER BY bar_time DESC + LIMIT ? + """, (pair, timeframe, limit)) + rows = cursor.fetchall() + bars = [] + for r in reversed(rows): + bars.append({ + "time": r["bar_time"], + "open": r["open"], + "high": r["high"], + "low": r["low"], + "close": r["close"], + "volume": r["volume"], + }) + return bars + except sqlite3.Error as e: + return [] + + def get_latest_bar_time(self, pair: str, timeframe: str) -> Optional[str]: + cursor = self.conn.cursor() + try: + cursor.execute(""" + SELECT bar_time FROM bar_cache + WHERE pair = ? AND timeframe = ? + ORDER BY bar_time DESC LIMIT 1 + """, (pair, timeframe)) + row = cursor.fetchone() + return row["bar_time"] if row else None + except sqlite3.Error: + return None + # ======================================================================== # SCORES Table Operations # ======================================================================== @@ -356,35 +446,36 @@ class Database: month: Month in format "YYYY-MM" scores: Dict mapping currency to {score_rate, score_cpi, score_pmi, total_score, rank} """ - cursor = self.conn.cursor() try: - for currency, score_data in scores.items(): - cursor.execute(""" - INSERT INTO scores (month, currency, score_rate, score_cpi, score_pmi, total_score, rank, calculated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(month, currency) DO UPDATE SET - score_rate = excluded.score_rate, - score_cpi = excluded.score_cpi, - score_pmi = excluded.score_pmi, - total_score = excluded.total_score, - rank = excluded.rank, - calculated_at = excluded.calculated_at - """, ( - month, - currency, - score_data.get("score_rate"), - score_data.get("score_cpi"), - score_data.get("score_pmi"), - score_data["total_score"], - score_data["rank"], - datetime.utcnow().isoformat() - )) - - self.conn.commit() - - if config.DEBUG: - print(f"[DB] {len(scores)} scores saved for {month}") + with self._lock: + cursor = self.conn.cursor() + for currency, score_data in scores.items(): + cursor.execute(""" + INSERT INTO scores (month, currency, score_rate, score_cpi, score_pmi, total_score, rank, calculated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(month, currency) DO UPDATE SET + score_rate = excluded.score_rate, + score_cpi = excluded.score_cpi, + score_pmi = excluded.score_pmi, + total_score = excluded.total_score, + rank = excluded.rank, + calculated_at = excluded.calculated_at + """, ( + month, + currency, + score_data.get("score_rate"), + score_data.get("score_cpi"), + score_data.get("score_pmi"), + score_data["total_score"], + score_data["rank"], + datetime.now(timezone.utc).isoformat() + )) + self.conn.commit() + + if config.DEBUG: + print(f"[DB] {len(scores)} scores saved for {month}") + except sqlite3.Error as e: self.conn.rollback() raise RuntimeError(f"Failed to save scores for {month}: {e}") @@ -443,25 +534,26 @@ class Database: if status not in ("ACTIVE", "NO_TRADE", "CLOSED"): raise ValueError(f"Invalid status: {status}") - cursor = self.conn.cursor() try: - cursor.execute(""" - INSERT INTO signals (generated_at, month, strongest, weakest, gap, signal, status) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(month) DO UPDATE SET - generated_at = excluded.generated_at, - strongest = excluded.strongest, - weakest = excluded.weakest, - gap = excluded.gap, - signal = excluded.signal, - status = excluded.status - """, (datetime.utcnow().isoformat(), month, strongest, weakest, gap, signal, status)) - - self.conn.commit() - - if config.DEBUG: - print(f"[DB] Signal saved for {month}: {signal} (status={status})") + with self._lock: + cursor = self.conn.cursor() + cursor.execute(""" + INSERT INTO signals (generated_at, month, strongest, weakest, gap, signal, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(month) DO UPDATE SET + generated_at = excluded.generated_at, + strongest = excluded.strongest, + weakest = excluded.weakest, + gap = excluded.gap, + signal = excluded.signal, + status = excluded.status + """, (datetime.now(timezone.utc).isoformat(), month, strongest, weakest, gap, signal, status)) + self.conn.commit() + + if config.DEBUG: + print(f"[DB] Signal saved for {month}: {signal} (status={status})") + except sqlite3.Error as e: self.conn.rollback() raise RuntimeError(f"Failed to save signal for {month}: {e}") diff --git a/fred_client.py b/fred_client.py index 2be16d1..07484d7 100644 --- a/fred_client.py +++ b/fred_client.py @@ -85,13 +85,13 @@ class FredClient: self.last_error = f"{currency}: API timeout (attempt {attempt + 1}/{max_retries})" if config.DEBUG: print(f"[FRED] {self.last_error}") - time.sleep(0.5 ** attempt) # Exponential backoff + time.sleep(0.5 * (2 ** attempt)) # Exponential backoff except requests.ConnectionError: self.last_error = f"{currency}: Connection error (attempt {attempt + 1}/{max_retries})" if config.DEBUG: print(f"[FRED] {self.last_error}") - time.sleep(0.5 ** attempt) + time.sleep(0.5 * (2 ** attempt)) except ValueError as e: self.last_error = f"{currency}: {str(e)}" diff --git a/layer2_technical.py b/layer2_technical.py new file mode 100644 index 0000000..8e32e30 --- /dev/null +++ b/layer2_technical.py @@ -0,0 +1,227 @@ +from typing import Dict, List, Optional, Tuple +from collections import deque +import statistics +import config + + +class TechnicalAnalyzer: + """Real-time technical analysis with bar-anchored statistics (Task 1.1). + + Maintains two data streams: + 1. Bar history (M1/M5 candles) — the multi-hour statistical anchor + for μ and σ (config.BAR_LOOKBACK_BARS, default 288 M5 bars = 24h). + 2. Live tick/poll deques — fast recent movement for display. + + Z-score formula (priority): + If bar history has >= 2 bars: Z = (tick - μ_bars) / σ_bars + Otherwise (fallback): Z = (tick - μ_ticks) / σ_ticks + + μ and σ prefer the multi-hour bar frame, but fall back to tick-based + statistics when bars haven't been seeded yet. + """ + + def __init__(self, lookback: int = None): + if lookback is None: + lookback = config.BAR_LOOKBACK_BARS + self.bar_lookback = lookback + self.tick_lookback = 20 + + self.bar_history: Dict[str, deque] = {} + self.price_history: Dict[str, deque] = {} + self.volume_history: Dict[str, deque] = {} + self.z_scores: Dict[str, float] = {} + self.extremes: Dict[str, bool] = {} + + for base in config.CURRENCIES: + for quote in config.CURRENCIES: + if base != quote: + pair = f"{base}_{quote}" + self.bar_history[pair] = deque(maxlen=self.bar_lookback) + self.price_history[pair] = deque(maxlen=self.tick_lookback) + self.volume_history[pair] = deque(maxlen=self.tick_lookback) + self.z_scores[pair] = 0.0 + self.extremes[pair] = False + + def add_bar(self, currency_pair: str, close: float, high: float = None, + low: float = None, volume: int = 0): + """Add a completed M1/M5 bar to the multi-hour historical frame.""" + if currency_pair not in self.bar_history: + return + self.bar_history[currency_pair].append(close) + + def add_price_data(self, currency_pair: str, close_price: float, + volume: float = 0): + """Add tick/poll price.""" + if currency_pair not in self.price_history: + return + + self.price_history[currency_pair].append(close_price) + if volume > 0: + self.volume_history[currency_pair].append(volume) + + self._update_z_score(currency_pair) + + def _get_mean_std(self, currency_pair: str) -> Tuple[float, float]: + """Compute μ and σ, preferring bar history over tick history. + + Falls back to tick data when bars haven't been seeded yet, + so the system works immediately from the first price update. + """ + bars = list(self.bar_history[currency_pair]) + if len(bars) >= 2: + try: + return (statistics.mean(bars), statistics.stdev(bars)) + except (ValueError, statistics.StatisticsError): + pass + + ticks = list(self.price_history[currency_pair]) + if len(ticks) >= 2: + try: + return (statistics.mean(ticks), statistics.stdev(ticks)) + except (ValueError, statistics.StatisticsError): + pass + + return (0.0, 0.0) + + def _update_z_score(self, currency_pair: str): + prices = list(self.price_history[currency_pair]) + if len(prices) < 1: + self.z_scores[currency_pair] = 0.0 + self.extremes[currency_pair] = False + return + + mu, sigma = self._get_mean_std(currency_pair) + if sigma == 0.0: + self.z_scores[currency_pair] = 0.0 + self.extremes[currency_pair] = False + return + + current_price = prices[-1] + z_score = (current_price - mu) / sigma + self.z_scores[currency_pair] = z_score + self.extremes[currency_pair] = abs(z_score) >= config.Z_SCORE_THRESHOLD + + def get_z_score(self, currency_pair: str) -> float: + return self.z_scores.get(currency_pair, 0.0) + + def is_extreme(self, currency_pair: str) -> bool: + return self.extremes.get(currency_pair, False) + + def get_overbought_pairs(self) -> List[str]: + return [pair for pair, z in self.z_scores.items() if z >= config.Z_SCORE_THRESHOLD] + + def get_oversold_pairs(self) -> List[str]: + return [pair for pair, z in self.z_scores.items() if z <= -config.Z_SCORE_THRESHOLD] + + def get_volatility(self, currency_pair: str) -> float: + """Volatility from bar history, falling back to ticks.""" + bars = list(self.bar_history[currency_pair]) + if len(bars) >= 2: + try: + return statistics.stdev(bars) + except (ValueError, statistics.StatisticsError): + pass + ticks = list(self.price_history[currency_pair]) + if len(ticks) >= 2: + try: + return statistics.stdev(ticks) + except (ValueError, statistics.StatisticsError): + pass + return 0.0 + + def get_mean_price(self, currency_pair: str) -> float: + """Mean from bar history, falling back to ticks.""" + bars = list(self.bar_history[currency_pair]) + if len(bars) >= 1: + return statistics.mean(bars) + ticks = list(self.price_history[currency_pair]) + if len(ticks) >= 1: + return statistics.mean(ticks) + return 0.0 + + def is_mean_reverting(self, currency_pair: str, threshold: float = 0.5) -> bool: + z = self.get_z_score(currency_pair) + return abs(z) < threshold + + def get_last_price(self, currency_pair: str) -> Optional[float]: + prices = self.price_history.get(currency_pair) + if prices and len(prices) > 0: + return prices[-1] + return None + + def get_all_z_scores(self) -> Dict[str, float]: + return self.z_scores.copy() + + def get_status_for_pair(self, currency_pair: str) -> Dict: + z_score = self.get_z_score(currency_pair) + volatility = self.get_volatility(currency_pair) + mean_price = self.get_mean_price(currency_pair) + is_extreme = self.is_extreme(currency_pair) + + if z_score > 2.5: + status = "SEVERELY OVERBOUGHT" + elif z_score > 2.0: + status = "OVERBOUGHT" + elif z_score > 0.5: + status = "Moderately Overbought" + elif z_score < -2.5: + status = "SEVERELY OVERSOLD" + elif z_score < -2.0: + status = "OVERSOLD" + elif z_score < -0.5: + status = "Moderately Oversold" + else: + status = "Neutral" + + return { + 'pair': currency_pair, + 'z_score': z_score, + 'volatility': volatility, + 'mean_price': mean_price, + 'is_extreme': is_extreme, + 'status': status + } + + def seed_bars(self, historical_bars: Dict[str, List[float]]): + """Seed bar_history with 288 M5 bars (24h) of historical close prices. + + Args: + historical_bars: dict mapping pair -> list of close prices (oldest first) + """ + for pair, closes in historical_bars.items(): + if pair in self.bar_history: + self.bar_history[pair].clear() + for c in closes[-self.bar_lookback:]: + self.bar_history[pair].append(c) + if len(self.bar_history[pair]) >= 2: + mu, sigma = self._get_mean_std(pair) + ticks = list(self.price_history[pair]) + if ticks and sigma > 0: + z = (ticks[-1] - mu) / sigma + self.z_scores[pair] = z + self.extremes[pair] = abs(z) >= config.Z_SCORE_THRESHOLD + + def clear_history(self): + for pair in self.bar_history: + self.bar_history[pair].clear() + self.price_history[pair].clear() + self.volume_history[pair].clear() + self.z_scores[pair] = 0.0 + self.extremes[pair] = False + + +class TechnicalSignal: + """Generates technical entry/exit signals based on Z-scores.""" + + def __init__(self, analyzer: TechnicalAnalyzer): + self.analyzer = analyzer + + def should_enter_on_extreme(self, currency_pair: str) -> bool: + return self.analyzer.is_extreme(currency_pair) + + def should_exit_on_mean_reversion(self, currency_pair: str) -> bool: + return self.analyzer.is_mean_reverting(currency_pair, threshold=0.5) + + def get_signal_strength(self, currency_pair: str) -> float: + z = self.analyzer.get_z_score(currency_pair) + return min(abs(z) / 3.0 * 100, 100.0) diff --git a/main.py b/main.py index 4c77d24..1a24417 100644 --- a/main.py +++ b/main.py @@ -10,18 +10,20 @@ Requirements: - Python 3.10+ - PyQt5 5.15+ - requests 2.31+ - - pandas 2.0+ (optional, for data) + - pandas 2.0+ - python-dotenv 1.0+ + - openpyxl 3.1+ Installation: - pip install PyQt5 requests python-dotenv + pip install -r requirements.txt First run: 1. Ensure .env file exists with FRED_API_KEY set - 2. Run: python main.py - 3. App initializes database with schema - 4. Auto-fetches rates from FRED if AUTO_FETCH_RATES_ON_STARTUP=true - 5. Ready for manual CPI/PMI entry + 2. (Optional for Layer 2) MetaTrader 5 terminal running + 3. Run: python main.py + 4. App initializes database with schema + 5. Auto-fetches rates from FRED if AUTO_FETCH_RATES_ON_STARTUP=true + 6. Ready for manual CPI/PMI entry """ import sys diff --git a/main_window.py b/main_window.py index 7b13380..2b68491 100644 --- a/main_window.py +++ b/main_window.py @@ -1,30 +1,35 @@ """ -APEX Layer 1 — Main Application Window +APEX Professional Trading System — Main Application Window -Assembles all 4 tabs: -- Tab 1: Dashboard (main signal + ranking table) -- Tab 2: Monthly Entry (CPI + PMI input form) -- Tab 3: History (past signals) -- Tab 4: Settings (configuration) +Assembles all 6 tabs: +- Tab 1: Dashboard (Layer 1 fundamental signals) +- Tab 2: Monthly Entry (CPI + PMI data input) +- Tab 3: Layer 2 Monitor (real-time technical analysis) +- Tab 4: Confluence Signals (merged Layer 1 + Layer 2) +- Tab 5: History (past signals) +- Tab 6: Settings (configuration) Responsibilities: - Create QMainWindow with QTabWidget - Instantiate all UI tabs - Manage database connection -- Run FRED API fetch in background thread (QThread) -- Connect inter-tab signals (e.g., entry tab saves → dashboard tab refreshes) +- Run FRED API fetch in background thread +- Run Alpha Vantage real-time data fetching +- Connect inter-tab signals - Handle window events and cleanup """ -from PyQt5.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout, QWidget, QMessageBox -from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox +from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtGui import QFont from typing import Dict, Optional import config from database import Database -from fred_client import FredClient +from layer2_technical import TechnicalAnalyzer from ui.dashboard_tab import DashboardTab from ui.entry_tab import MonthlyEntryTab +from ui.layer2_monitor_tab import Layer2MonitorTab +from ui.confluence_tab import ConfluenceSignalsTab from ui.history_tab import HistoryTab from ui.settings_tab import SettingsTab @@ -75,7 +80,7 @@ class FredFetchWorker(QThread): class MainWindow(QMainWindow): - """Main application window.""" + """Main application window — Professional hybrid trading system.""" def __init__(self): """Initialize main window.""" @@ -92,13 +97,18 @@ class MainWindow(QMainWindow): ) raise + # Initialize Layer 2 components + self.tech_analyzer = TechnicalAnalyzer(lookback=config.Z_SCORE_LOOKBACK) + # UI components self.dashboard_tab = None self.entry_tab = None + self.layer2_tab = None + self.confluence_tab = None self.history_tab = None self.settings_tab = None - # Worker thread + # Worker threads self.fred_worker = None self._init_ui() @@ -113,19 +123,27 @@ class MainWindow(QMainWindow): # Tab widget tabs = QTabWidget() - # Tab 1: Dashboard + # Tab 1: Dashboard (Layer 1) self.dashboard_tab = DashboardTab(self.db) tabs.addTab(self.dashboard_tab, config.TAB_NAMES["dashboard"]) - # Tab 2: Monthly Entry + # Tab 2: Monthly Entry (Data input) self.entry_tab = MonthlyEntryTab(self.db) tabs.addTab(self.entry_tab, config.TAB_NAMES["entry"]) - # Tab 3: History + # Tab 3: Layer 2 Monitor (Real-time technical) + self.layer2_tab = Layer2MonitorTab(self.tech_analyzer) + tabs.addTab(self.layer2_tab, config.TAB_NAMES["layer2"]) + + # Tab 4: Confluence Signals (Layer 1 + Layer 2) + self.confluence_tab = ConfluenceSignalsTab(self.db, self.tech_analyzer) + tabs.addTab(self.confluence_tab, config.TAB_NAMES["confluence"]) + + # Tab 5: History self.history_tab = HistoryTab(self.db) tabs.addTab(self.history_tab, config.TAB_NAMES["history"]) - # Tab 4: Settings + # Tab 6: Settings self.settings_tab = SettingsTab() tabs.addTab(self.settings_tab, config.TAB_NAMES["settings"]) @@ -144,6 +162,9 @@ class MainWindow(QMainWindow): # Entry tab saves data → History tab refreshes self.entry_tab.data_saved.connect(self.history_tab.refresh_history) + # Dashboard generates signal → Confluence tab receives signal + self.dashboard_tab.signal_generated.connect(self._on_dashboard_signal) + # Dashboard requests FRED fetch → Start worker thread self.dashboard_tab.fetch_rates_requested.connect(self._fetch_rates) @@ -191,6 +212,24 @@ class MainWindow(QMainWindow): print(f"[ERROR] {error_msg}") # Don't show error message to user; display gracefully in dashboard + def _on_dashboard_signal(self, strongest: str, weakest: str, gap: float, + bias_matrix: dict = None): + """ + Handle dashboard signal generation. + Pass to confluence tab for Layer 2 analysis. + + Args: + strongest: Strongest currency + weakest: Weakest currency + gap: Gap score + bias_matrix: Monthly directional bias matrix from Layer 1 + """ + if config.DEBUG: + print(f"[Main] Signal generated: {strongest}/{weakest} gap={gap:.1f}") + + # Update confluence tab with new Layer 1 signal + bias matrix + self.confluence_tab.set_layer1_signal(strongest, weakest, gap, bias_matrix) + def closeEvent(self, event): """Handle window close event.""" try: @@ -199,6 +238,10 @@ class MainWindow(QMainWindow): self.fred_worker.quit() self.fred_worker.wait() + # Stop Layer 2 monitoring + if self.layer2_tab: + self.layer2_tab.closeEvent(event) + # Close database self.db.close() diff --git a/project_structure_and_resume.md b/project_structure_and_resume.md new file mode 100644 index 0000000..193f893 --- /dev/null +++ b/project_structure_and_resume.md @@ -0,0 +1,878 @@ +# APEX — Currency Strength Engine + +> **Version:** 1.1.0 +> **Timeframe:** Swing / Position Trading (1–5 day holds) +> **Architecture:** S.A.T.O.R.I. (Statistical Arbitrage Trading & Orchestrated Reversion Index) +> **Layer:** Layer 1 (Fundamental) + Layer 2 (Technical/Statistical) +> **Methodology:** Dr. Giavon's Deconstructed Currency Strength Indexing + +--- + +## 1. Project Overview + +APEX is a desktop-based **Currency Strength Engine** that implements institutional-quality **statistical arbitrage (StatArb)** for the forex market. It deconstructs all 28 major cross-pairs to isolate the true strength/weakness of individual currencies, then generates mean-reversion signals when statistical divergences reach extreme thresholds. + +### Core Principle + +Instead of analyzing EUR/USD as a single entity, APEX decomposes every pair to isolate individual currency strength indices: + +``` +Individual Currency Strength = Average Z-Score Across ALL 7 Pairs Involving That Currency + +EUR_Strength = avg(Z(EUR_USD), Z(EUR_GBP), Z(EUR_JPY), Z(EUR_AUD), Z(EUR_CAD), Z(EUR_CHF), Z(EUR_NZD)) +USD_Strength = avg(Z(USD_EUR), Z(USD_GBP), Z(USD_JPY), Z(USD_AUD), Z(USD_CAD), Z(USD_CHF), Z(USD_NZD)) +... and so on for all 8 currencies +``` + +### Trading Philosophy + +| Component | Strategy | +|-----------|----------| +| **Timeframe** | Swing / Position — 1 to 5 day holds | +| **Entry Trigger** | Matrix Cross divergence: one currency overbought (Z > +2.0) across ALL pairs, another oversold (Z < -2.0) simultaneously | +| **Execution** | Short the strongest, buy the weakest — bet on mathematical mean reversion | +| **Risk Management** | No single-pair stop losses. Basket hedging across correlated pairs + grid hedging | +| **Exit** | Aggregate portfolio P&L goes net positive (portfolio-based exit, not per-pair) | +| **Z-Score Anchor** | 288 M5 bars = 24 hours of historical data (not tick noise) | +| **Session Tracking** | Tracks Tokyo / London / New York opens with Session Relative Velocity | + +--- + +## 2. Architecture + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ APEX APPLICATION │ +├────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ UI LAYER (6 Tabs) │ │ +│ │ ┌──────────┐ ┌────────┐ ┌──────────┐ ┌────────┐ ┌──────┐ │ │ +│ │ │Dashboard │ │Data │ │Layer 2 │ │Confluence│ │Hist.│ │ │ +│ │ │(Fundamen)│ │Entry │ │Monitor │ │Signals │ │ │ │ │ +│ │ └────┬─────┘ └────────┘ └────┬─────┘ └────┬────┘ └──────┘ │ │ +│ └───────┼───────────────────────┼─────────────┼────────────────┘ │ +│ │ │ │ │ +│ ┌───────▼───────────────────────▼─────────────▼────────────────┐ │ +│ │ BUSINESS LOGIC LAYER │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ +│ │ │ Scoring (L1) │ │Technical (L2)│ │ Matrix Engine │ │ │ +│ │ │ scorer.py │ │layer2_tech │ │ currency_strength│ │ │ +│ │ │ │ │ .py │ │ _matrix.py │ │ │ +│ │ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ │ +│ │ │ │ │ │ │ +│ │ ┌──────▼────────────────▼──────────────────▼──────────┐ │ │ +│ │ │ CONFLUENCE FILTER │ │ │ +│ │ │ confluence_filter.py │ │ │ +│ │ │ Layer 1 + Layer 2 + Matrix = Entry Signal │ │ │ +│ │ └──────────────────────┬──────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌──────────────────────▼──────────────────────────────┐ │ │ +│ │ │ RISK MANAGEMENT SYSTEM │ │ │ +│ │ │ risk_management.py │ │ │ +│ │ │ Position Sizing + Grid Hedge + Basket Hedge + │ │ │ +│ │ │ Portfolio P&L Tracking + Aggregate Exit │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ DATA LAYER │ │ +│ │ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ +│ │ │ FRED API │ │ MT5 Data │ │ SQLite │ │ Excel Import │ │ │ +│ │ │(interest │ │(forex │ │ Database │ │ (CPI/PMI) │ │ │ +│ │ │ rates) │ │ prices) │ │ apex.db │ │ │ │ │ +│ │ └───────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### The Two Layers + +| Layer | Input | Frequency | Output | +|-------|-------|-----------|--------| +| **Layer 1 (Fundamental)** | Interest rates (FRED), CPI, PMI (manual/Excel) | Monthly | Currency scores (0-100), Strongest/Weakest ranking | +| **Layer 2 (Technical)** | 288 M5 bars (24h) + live poll prices | Bar-anchored, tick-displayed | 28 pair Z-scores (anchored to 24h μ/σ), 8 currency strength indices, Matrix Cross | + +**Critical design:** Z-scores are NOT calculated over raw tick polls. On connect, the analyzer is seeded with 288 M5 bars of historical close prices. μ and σ are computed from this 24-hour window. Live tick prices are compared against this stable anchor, producing meaningful multi-hour deviation readings that don't flip on every tick. + +**Fallback:** If bar data hasn't been seeded yet, the system falls back to a 20-tick deque for immediate display. Once `seed_bars()` is called, the bar anchor takes over permanently. + +--- + +## 3. Directory Structure + +``` +apex_layer1/ +│ +├── __init__.py # Package marker (v1.0.0) +├── main.py # Application entry point +├── main_window.py # QMainWindow + tab assembly +├── config.py # All configuration & constants from .env +│ +├── data_feeder.py # MT5 + Mock data feeders +├── fred_client.py # FRED interest rate API client +├── database.py # SQLite database manager +│ +├── scorer.py # Layer 1 scoring engine +├── layer2_technical.py # Layer 2 Z-score engine (28 pairs) +├── currency_strength_matrix.py # S.A.T.O.R.I. currency strength index +├── confluence_filter.py # Layer 1 + Layer 2 + Matrix merging +├── risk_management.py # Position sizing, hedging, portfolio mgmt +│ +├── create_excel_template.py # Excel/CSV template generator +├── requirements.txt # Python dependencies +│ +├── .env # Live configuration (API keys) +├── .env.example # Configuration template +├── apex.db # SQLite database (auto-created) +│ +├── ui/ +│ ├── __init__.py +│ ├── dashboard_tab.py # Tab 1: Layer 1 fundamental signals +│ ├── entry_tab.py # Tab 2: CPI/PMI data entry +│ ├── layer2_monitor_tab.py # Tab 3: Live Z-scores + Matrix +│ ├── confluence_tab.py # Tab 4: Merged signals +│ ├── history_tab.py # Tab 5: Past signals +│ └── settings_tab.py # Tab 6: Configuration +``` + +--- + +## 4. File-by-File Breakdown + +### 4.1 Entry Point + +#### `main.py` +Launches the PyQt5 application. Validates FRED API key exists, creates `QApplication`, instantiates `MainWindow`, runs event loop. + +- **`main()`** — Application entry point. Checks `config.FRED_API_KEY`, creates `QApplication`, shows `MainWindow`, starts event loop. + +#### `__init__.py` +Package marker. Exports `__version__ = "1.0.0"`. + +--- + +### 4.2 Configuration + +#### `config.py` +Loads `.env` via `python-dotenv`. Defines ALL constants used across the application. + +| Constant | Default | Description | +|----------|---------|-------------| +| `CURRENCIES` | `["USD","EUR","GBP","JPY","AUD","CAD","CHF","NZD"]` | The 8 major currencies | +| `CB_TARGETS` | Per-currency dict | Central bank inflation targets (2.0% most, AUD=2.5, CHF=1.5) | +| `FRED_SERIES` | Per-currency dict | FRED series IDs for interest rates | +| `WEIGHT_RATE` | `0.50` | Interest rate weight in L1 scoring | +| `WEIGHT_CPI` | `0.30` | CPI deviation weight in L1 scoring | +| `WEIGHT_PMI` | `0.20` | PMI composite weight in L1 scoring | +| `MIN_GAP_TO_TRADE` | `20` | Minimum score gap required for signal | +| `Z_SCORE_THRESHOLD` | `2.0` | Overbought/oversold threshold (std devs) | +| `Z_SCORE_LOOKBACK` | `20` | Bars for Z-score calculation | +| `MT5_SYMBOL_SUFFIX` | `""` | Broker-specific MT5 suffix (e.g., `.m`) | +| `ACCOUNT_BALANCE` | `10000` | Starting account balance | +| `RISK_PER_TRADE` | `0.01` | 1% risk per trade | +| `MAX_PORTFOLIO_LEVERAGE` | `2.0` | Max 2:1 leverage | +| `GRID_LEVELS` | `3` | Hedge grid levels | +| `USE_GRID_HEDGING` | `true` | Enable grid hedging | +| `DEBUG` | `false` | Debug output toggle | + +- **`validate_config()`** — Validates all config on import. Raises `ValueError` if FRED key or critical settings are missing. + +--- + +### 4.3 Data Layer + +#### `data_feeder.py` +Two data feeder implementations with the **same interface** (polymorphic): + +**Class `Mt5DataFeeder`** — Real data from MetaTrader 5 terminal. + +| Method | Returns | Description | +|--------|---------|-------------| +| `initialize()` | `bool` | Connect to MT5 terminal | +| `test_connection()` | `bool` | Alias for initialize | +| `shutdown()` | — | Disconnect MT5 | +| `get_connection_status()` | `str` | Human-readable status | +| `get_current_price(pair)` | `dict\|None` | Bid/ask/mid via `symbol_info_tick()` | +| `fetch_all_rates()` | `dict` | Fetch 7 USD pairs, derive all 28 cross rates | +| `get_all_major_pairs()` | `list[str]` | All 28 pairs (56 permutations) | +| `get_exchange_rate(from, to)` | `float\|None` | Single cross rate | +| `get_historical_candles(...)` | `list[dict]\|None` | OHLC bars via `copy_rates_from_pos()` | +| `stream_prices(...)` | — | Threaded polling loop | +| `stop_streaming()` | — | Stop the poll loop | + +**Strategy:** Fetches only 7 major USD pairs (`EUR_USD`, `GBP_USD`, `AUD_USD`, `NZD_USD`, `USD_JPY`, `USD_CAD`, `USD_CHF`), converts each to "how many USD per 1 unit", then derives all 28 cross rates mathematically. This avoids the problem that most MT5 brokers don't have symbols for exotic crosses like `AUDEUR`, `AUDGBP`, etc. + +**Key internal:** +```python +USD_PAIRS = ["EUR_USD", "GBP_USD", "AUD_USD", "NZD_USD", + "USD_JPY", "USD_CAD", "USD_CHF"] + +# For EUR_USD: usd_rates["EUR"] = mid_price +# For USD_JPY: usd_rates["JPY"] = 1.0 / mid_price +# Cross rate: rate[base][quote] = usd_rates[base] / usd_rates[quote] +``` + +**Class `MockDataFeeder`** — Simulates prices with random walk around base prices for 8 major pairs. Same interface as `Mt5DataFeeder` for testability. + +--- + +#### `fred_client.py` +**Class `FredClient`** — Fetches interest rates from FRED API. + +| Method | Returns | Description | +|--------|---------|-------------| +| `__init__(api_key, timeout)` | — | Validates API key | +| `fetch_rate(currency, max_retries)` | `float\|None` | Single currency rate with exponential backoff | +| `fetch_all_rates(max_retries)` | `dict` | All 8 currencies | +| `get_cached_rate(currency)` | `float\|None` | Cache lookup | +| `clear_cache()` | — | Reset cache | + +Uses FRED series IDs from `config.FRED_SERIES`: +- USD → `FEDFUNDS`, EUR → `ECBDFR`, GBP → `BOEBR`, JPY → `IRSTJPN` +- AUD → `RBATCTR`, CAD → `BOCCRT`, CHF → `SNBPOL`, NZD → `RBNZOCR` + +--- + +#### `database.py` +**Class `Database`** — SQLite database with 4 tables. + +| Table | Columns | Purpose | +|-------|---------|---------| +| `rates` | `currency, rate, updated_at, source` | Interest rates from FRED | +| `monthly_data` | `month, currency, cpi_actual, pmi_actual, entered_at` | CPI/PMI entries | +| `scores` | `month, currency, score_rate, score_cpi, score_pmi, total_score, rank` | Calculated scores | +| `signals` | `month, strongest, weakest, gap, signal, status` | Trade signals | + +All tables use `ON CONFLICT ... DO UPDATE` (upsert) for idempotent writes. Foreign keys enforced via PRAGMA. + +Key methods: `upsert_rate()`, `update_monthly_cpi()`, `update_monthly_pmi()`, `save_scores()`, `save_signal()`, `get_month_scores()`, `get_all_signals()`, `get_month_completeness()`. + +--- + +### 4.4 Business Logic — Layer 1 (Fundamental) + +#### `scorer.py` +Pure functions (no classes). Implements the scoring formula: + +``` +Score = (Rate_Differential × 50%) + (CPI_Deviation × 30%) + (PMI × 20%) + +Each component is min-max normalized to 0-100 before weighting. +``` + +| Function | Returns | Description | +|----------|---------|-------------| +| `normalise(values)` | `list[float]` | Min-max scaling to 0-100 | +| `calculate_rate_differentials(rates)` | `dict` | Rate minus G8 average | +| `calculate_cpi_deviations(cpi_values)` | `dict` | Actual CPI minus CB target | +| `score_all_currencies(rates, cpi, pmi)` | `dict` | Full scoring pipeline | +| `get_ranked_list(scores)` | `list[tuples]` | Sorted by score descending | +| `pair_currencies(scores)` | `(strongest, weakest, gap)` | Top vs bottom score | +| `generate_signal(scores)` | `(signal, status, gap_desc)` | "SHORT X/Y" or "NO TRADE" | +| `validate_scores(scores)` | `bool` | Validates all fields and ranges | + +--- + +### 4.5 Business Logic — Layer 2 (Technical) + +#### `layer2_technical.py` +**Class `TechnicalAnalyzer`** — Real-time Z-score engine. + +Initializes 56 deques (all permutations of 8 currencies) with `maxlen=20`. Each incoming price tick appends to the deque and recalculates the Z-score. + +| Method | Description | +|--------|-------------| +| `add_price_data(pair, price, volume)` | Append price, recalculate Z-score | +| `get_z_score(pair)` | Current Z-score for any pair | +| `is_extreme(pair)` | `|Z| >= 2.0` | +| `get_overbought_pairs()` | All pairs with Z >= 2.0 | +| `get_oversold_pairs()` | All pairs with Z <= -2.0 | +| `get_volatility(pair)` | Standard deviation of recent prices | +| `get_mean_price(pair)` | Mean price over lookback | +| `is_mean_reverting(pair)` | `|Z| < 0.5` | +| `get_last_price(pair)` | Most recent price | +| `get_all_z_scores()` | Dict of all 56 pair Z-scores | +| `get_status_for_pair(pair)` | Dict with label (SEVERELY OVERBOUGHT → Neutral) | + +**Z-score formula:** `Z = (current_price - mean) / std_dev` + +**Class `TechnicalSignal`** — Signal generation from Z-scores. +- `should_enter_on_extreme()` → True if `|Z| >= 2.0` +- `should_exit_on_mean_reversion()` → True if `|Z| < 0.5` +- `get_signal_strength()` → 0-100 scale + +--- + +#### `currency_strength_matrix.py` +**Class `CurrencyStrengthMatrix`** — S.A.T.O.R.I. individual currency strength index. + +This is the core mathematical innovation. Deconstructs all 28 pair Z-scores into 8 individual currency strength indices. + +**How it works:** + +For each currency, collects Z-scores from all 7 pairs where it is the **base**: +``` +EUR_Strength = avg(Z(EUR_USD), Z(EUR_GBP), Z(EUR_JPY), Z(EUR_AUD), Z(EUR_CAD), Z(EUR_CHF), Z(EUR_NZD)) +USD_Strength = avg(Z(USD_EUR), Z(USD_GBP), Z(USD_JPY), Z(USD_AUD), Z(USD_CAD), Z(USD_CHF), Z(USD_NZD)) +``` + +**Output:** +| Currency | Avg Z-Score | Direction | +|----------|-------------|-----------| +| EUR | +2.3 | **OVERBOUGHT** | +| USD | +1.1 | NEUTRAL | +| ... | ... | ... | +| JPY | -2.5 | **OVERSOLD** | + +The **Matrix Cross** = Strongest currency vs Weakest currency (e.g., `EUR_JPY`). + +| Method | Description | +|--------|-------------| +| `update(z_scores)` | Recompute from 56 pair Z-scores | +| `get_strongest()` | Highest avg Z-score currency | +| `get_weakest()` | Lowest avg Z-score currency | +| `get_matrix_cross()` | Strongest_Weakest pair | +| `get_divergence_gap()` | strongest_z - weakest_z | +| `has_divergence()` | True if one overbought AND one oversold | +| `get_strong_currencies()` | List of overbought currencies | +| `get_weak_currencies()` | List of oversold currencies | +| `get_ranked_list()` | All 8 sorted by strength | +| `get_report()` | Dict with all matrix data | + +--- + +#### `confluence_filter.py` +**Class `ConfluenceFilter`** — Merges all three signal sources. + +**Entry logic** (two-tier): + +1. **Primary — Matrix Divergence:** + - One currency overbought across ALL pairs + - Another currency oversold across ALL pairs + - Trade the Matrix Cross (strongest vs weakest) + - Confidence = spread / 4.0 × 100 + +2. **Secondary — Layer 1 + Layer 2:** + - Layer 1 bias (fundamental strongest/weakest) + - Layer 2 pair extreme (|Z| >= 2.0 on that specific pair) + - 50% gap confidence + 50% Z confidence + +**Exit logic** (two-tier): +1. Matrix divergence gap collapses (divergence no longer exists) +2. Single-pair Z-score mean reverts below 0.5 + +| Method | Description | +|--------|-------------| +| `set_layer1_bias(strongest, weakest, gap)` | Store current L1 signal | +| `check_entry_confluence()` | `(bool, reason, strength)` | +| `check_exit_confluence()` | `(bool, reason)` | +| `is_conflicting()` | L1 bullish but L2 bearish | +| `get_confluence_report()` | Full report with matrix data | +| `get_all_signals()` | All ranked signals | + +**Class `SignalHistory`** — Tracks up to 1000 signals with win-rate calculation. + +--- + +#### `risk_management.py` +Five classes implementing professional risk management: + +**Class `PositionSizer`** +- Risk-based position sizing: `size = (balance × 0.01) / (stop_loss × pip_value) × confidence_multiplier` +- Clamped to 0.01–5.0 lots + +**Class `GridHedging`** +- Creates N-level hedge grid below entry price +- Each hedge level = 50% × position_size / (N-1) + +**Class `PortfolioExposure`** +- Tracks all open positions +- Enforces max leverage (default 2:1) +- Rejects new positions that would exceed limit + +**Class `BasketHedging`** — S.A.T.O.R.I. statistical arbitrage hedging. +- Pre-defined correlation clusters: + - `EUR_USD` → hedges with `EUR_GBP`, `EUR_JPY`, `GBP_USD` + - `GBP_USD` → hedges with `GBP_JPY`, `EUR_GBP`, `EUR_USD` + - `USD_JPY` → hedges with `USD_CHF`, `USD_CAD`, `EUR_JPY` + - `AUD_USD` → hedges with `AUD_JPY`, `NZD_USD`, `AUD_CAD` + - `NZD_USD` → hedges with `AUD_USD`, `NZD_JPY`, `NZD_CAD` +- Each correlated pair gets 30% of primary size / len(cluster) + +**Class `RiskManagementSystem`** — Combines all four. +- `execute_signal()` → full trade execution with sizing + grid + basket +- `calculate_basket_pnl()` → aggregate unrealized P&L across ALL positions + hedges +- `should_exit_portfolio()` → exit when total P&L > 0 (portfolio-based, not per-pair) +- `close_all_trades()` → close all positions at given exit prices +- `get_portfolio_summary()` → positions, exposure, leverage, P&L + +--- + +### 4.6 UI Layer + +#### `main_window.py` +**Class `MainWindow(QMainWindow)`** — Application shell. + +Creates 6-tab `QTabWidget`, instantiates all tabs, connects inter-tab signals. + +**Data flow assembly:** +``` +1. Entry tab saves data → Dashboard refreshes +2. Entry tab saves data → History tab refreshes +3. Dashboard generates signal → Confluence tab receives bias +4. Dashboard requests fetch → FredFetchWorker starts +5. FRED completes → Dashboard updates rates +``` + +**Class `FredFetchWorker(QThread)`** — Background FRED API fetch. Saves rates to DB, emits `rates_fetched` or `error_occurred`. + +--- + +#### `ui/dashboard_tab.py` — Tab 1 +**Class `DashboardTab(QWidget)`** + +Displays Layer 1 fundamental analysis: +- **Signal card** — Large text: "SHORT JPY/USD" or "NO TRADE", gap score, tier, timestamp +- **Score table** — 8 rows × 8 columns (Rank, Currency🇺🇸, Rate%, CPI%, PMI, Score, Signal, Strength bar) +- Strongest row highlighted green with "BUY" tag +- Weakest row highlighted red with "SELL" tag +- Color-coded score bars (green/red/gray for Rate/CPI/PMI contributions) +- "Fetch Rates (FRED)" button + +Signals: `fetch_rates_requested`, `signal_generated(strongest, weakest, gap)` + +--- + +#### `ui/entry_tab.py` — Tab 2 +**Class `MonthlyEntryTab(QWidget)`** + +Manual data entry for CPI and PMI: +- Month selector (dropdown, 24 months) +- **CPI table**: Currency, Target%, Actual CPI (spinbox), Delta (color-coded), Done +- **PMI table**: Currency, Neutral 50, PMI (spinbox), Signal label (Expanding/Contracting), Done +- Progress bar: X/16 fields filled +- Import Excel button (supports both multi-sheet xlsx and CSV) +- Save button (enabled only when 16/16 complete) +- On save: loads rates from DB → runs `scorer.score_all_currencies()` → saves scores → generates signal → emits `data_saved` + +Signals: `data_saved(month)` + +--- + +#### `ui/layer2_monitor_tab.py` — Tab 3 +**Class `Layer2MonitorTab(QWidget)`** +**Class `DataStreamerThread(QThread)`** + +Real-time technical analysis with S.A.T.O.R.I. matrix: +- **Connection panel**: Source dropdown (MT5 Live / Mock Test), Connect/Disconnect, status indicator +- **Z-score table**: All 28 pairs with Price, Z-Score (red when extreme), Volatility, Mean, Status, Signal +- **Overbought/Oversold alerts**: Comma-separated lists +- **Currency Strength Matrix panel:** + - Matrix Cross label (strongest vs weakest currency) + - Divergence Gap (sigma spread) + - DIVERGENCE DETECTED alert (red) when one currency overbought + one oversold + - Ranked currency table: 8 rows × 4 columns (Rank, Currency, Strength Z, Direction) + - Color-coded: OVERBOUGHT (red), OVERSOLD (green) +- Auto-refresh checkbox, Refresh Now button + +Data flow: Streamer thread polls feeder → emits `price_updated` → feeds `TechnicalAnalyzer` → recomputes `CurrencyStrengthMatrix` → refreshes display. + +--- + +#### `ui/confluence_tab.py` — Tab 4 +**Class `ConfluenceSignalsTab(QWidget)`** + +Merged signal display and execution: +- **Status card**: Layer 1 bias (pair, gap), Layer 2 extreme (pair, Z-score), Matrix Cross, Top 3 → Bottom 3 ranked currencies, Confluence result with confidence % +- **Signals table**: 10 rows × 8 columns (Pair, L1 Gap, L2 Z-Score, Status, Confidence, Entry Price, Position Size, Action) +- Matrix divergence signals shown in purple, standard confluence in green +- **Risk panel**: Portfolio exposure progress bar, leverage ratio +- **Buttons**: Refresh, Execute Top Signal (runs `RiskManagementSystem`) +- Auto-refresh every 5 seconds + +--- + +#### `ui/history_tab.py` — Tab 5 +**Class `HistoryTab(QWidget)`** + +Past signal history: +- Table with 6 columns: Month, Signal, Gap, Strongest (flag), Weakest (flag), Status +- Status color-coded: ACTIVE (green), NO_TRADE (red) +- Click any row → popup with full score breakdown for all 8 currencies +- Auto-refreshes when new data saved + +--- + +#### `ui/settings_tab.py` — Tab 6 +**Class `SettingsTab(QWidget)`** +**Class `FredTestWorker(QThread)`** +**Class `Mt5TestWorker(QThread)`** + +Configuration interface: +- **FRED API**: Key input (masked), Test Connection button, status +- **MT5**: Symbol suffix input, Test Connection button, status +- **CB Targets**: Read-only display of all 8 targets +- **Scoring Weights**: 3 spinboxes (Rate/CPI/PMI %) with live total validation (must = 100%) +- **Trading Rules**: Minimum gap spinbox (5-100) +- **App Settings**: Auto-fetch checkbox +- **Save**: Writes .env file (requires restart) +- **Reset**: Confirmation dialog, restores defaults + +--- + +### 4.7 Utility + +#### `create_excel_template.py` +Generates example Excel/CSV files for data import testing: +- `example_monthly_data.xlsx` (multi-sheet: CPI + PMI) +- `example_monthly_data_single_sheet.xlsx` (all in one sheet) +- `example_monthly_data.csv` + +Each contains 8 currencies with example values. + +--- + +## 5. Data Flow Diagrams + +### Layer 1 (Fundamental) — Monthly Cycle + +``` +User enters CPI/PMI + │ + ▼ +Entry Tab → Save Clicked + │ + ├──► Read all 8 CPI + 8 PMI from spinboxes + ├──► Load interest rates from DB (from FRED) + ├──► scorer.score_all_currencies(rates, cpi, pmi) + │ ├── normalise(rate_differentials) × 0.50 + │ ├── normalise(cpi_deviations) × 0.30 + │ ├── normalise(pmi_raw) × 0.20 + │ └── sum → total_score 0-100 + ├──► scorer.generate_signal(scores) + │ ├── pair_currencies → strongest, weakest, gap + │ ├── gap >= 20 → "SHORT {weak}/{strong}" + │ └── gap < 20 → "NO TRADE" + ├──► database.save_scores() + ├──► database.save_signal() + └──► emit data_saved → Dashboard + History refresh +``` + +### Layer 2 (Technical) — Real-time with Bar Seeding + +``` +MT5 Terminal (or Mock) + │ + ├── On Connect: + │ generate_mock_bars(288) ◄── Mock: simulates 24h of M5 data + │ │ or + │ fetch_historical_bars(288, M5) ◄── MT5: real bars from terminal + │ │ + │ ▼ + │ TechnicalAnalyzer.seed_bars(bars) ◄── Populates bar_history with 288 closes + │ │ ◄── μ and σ now anchored to 24h + │ │ + │ ▼ + │ DataStreamerThread.start() + │ + └──► every 1s: + fetch_all_rates() → 7 USD pairs → derive 28 crosses + │ + ├──► emit price_updated(pair, mid) + │ + ▼ + Layer2MonitorTab._on_price_received + │ + ├──► TechnicalAnalyzer.add_price_data(pair, price) + │ └── _update_z_score(pair) + │ μ, σ = _get_mean_std(pair) + │ │ priority: bar_history (288 bars) → tick_fallback (20 ticks) + │ ▼ + │ Z = (current_price - μ) / σ + │ + ├──► CurrencyStrengthMatrix(z_scores) + │ └── For each currency: avg Z across 7 base pairs + │ + └──► _refresh_display() + ├── Update 28-pair Z-score table + ├── Update currency strength matrix table + ├── Update matrix cross / divergence alerts + ├── Update overbought/oversold lists + └── Show active session (Tokyo/London/New York) +``` + +### Confluence — Entry Signal + +``` +Layer 1 (monthly) Layer 2 (real-time) + │ │ + ▼ ▼ +dashboard_tab.signal_generated ThermalAnalyzer.z_scores + │ │ + ▼ ▼ +ConfluenceFilter.set_layer1_bias CurrencyStrengthMatrix + │ │ + └──────────┬───────────────────────┘ + ▼ + ConfluenceFilter.check_entry_confluence() + │ + ├── Matrix divergence? → YES → Trade matrix cross + ├── L1 + L2 extreme? → YES → Trade paired pair + └── Neither? → NO TRADE + │ + ▼ + RiskManagementSystem.execute_signal() + │ + ├── PositionSizer → size = f(confidence) + ├── GridHedging → 3-level hedge grid + ├── BasketHedging → correlated pair hedges + └── PortfolioExposure → leverage check +``` + +--- + +## 6. Database Schema + +```sql +-- Table 1: Interest rates from FRED +CREATE TABLE rates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + currency TEXT NOT NULL UNIQUE, + rate REAL NOT NULL, + updated_at TEXT NOT NULL, + source TEXT DEFAULT 'FRED', + CONSTRAINT valid_currency CHECK (currency IN ('USD','EUR','GBP','JPY','AUD','CAD','CHF','NZD')) +); + +-- Table 2: Monthly CPI + PMI entries +CREATE TABLE monthly_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + currency TEXT NOT NULL, + cpi_actual REAL, + pmi_actual REAL, + entered_at TEXT NOT NULL, + UNIQUE(month, currency), + CONSTRAINT valid_currency CHECK (currency IN ('USD','EUR','GBP','JPY','AUD','CAD','CHF','NZD')), + CONSTRAINT valid_month CHECK (month LIKE '____-__') +); + +-- Table 3: Calculated scores +CREATE TABLE scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + currency TEXT NOT NULL, + score_rate REAL, + score_cpi REAL, + score_pmi REAL, + total_score REAL NOT NULL, + rank INTEGER NOT NULL, + calculated_at TEXT NOT NULL, + UNIQUE(month, currency) +); + +-- Table 4: Trade signals +CREATE TABLE signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generated_at TEXT NOT NULL, + month TEXT NOT NULL UNIQUE, + strongest TEXT NOT NULL, + weakest TEXT NOT NULL, + gap REAL NOT NULL, + signal TEXT NOT NULL, + status TEXT NOT NULL, + CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED')) +); +``` + +--- + +## 7. Configuration (.env) + +```env +FRED_API_KEY=your_fred_api_key +MT5_SYMBOL_SUFFIX= +DB_PATH=apex.db +DEBUG=true +WEIGHT_RATE=0.50 +WEIGHT_CPI=0.30 +WEIGHT_PMI=0.20 +MIN_GAP=20.0 +AUTO_FETCH_RATES_ON_STARTUP=true +Z_SCORE_THRESHOLD=2.0 +Z_SCORE_LOOKBACK=20 +ACCOUNT_BALANCE=10000.0 +RISK_PER_TRADE=0.01 +MAX_PORTFOLIO_LEVERAGE=2.0 +USE_GRID_HEDGING=true +GRID_LEVELS=3 +``` + +--- + +## 8. Technology Stack + +| Component | Technology | Version | +|-----------|-----------|---------| +| Language | Python | 3.10+ | +| UI Framework | PyQt5 | 5.15.9 | +| Database | SQLite | Built-in | +| HTTP Client | requests | 2.31+ | +| Data Processing | pandas | 2.1+ | +| Excel Support | openpyxl | 3.1+ | +| Environment | python-dotenv | 1.0+ | +| Forex Data | MetaTrader5 | Latest | +| Interest Rates | FRED API | Free tier | +| Packaging | PyInstaller | 6.1+ | + +--- + +## 9. Scoring Formula Reference + +### Layer 1 — Fundamental Score + +``` +rate_diff[i] = rate[i] - G8_average_rate +cpi_dev[i] = actual_cpi[i] - cb_target[i] +pmi_raw[i] = pmi_value[i] + +normalize(x) = (x - min) / (max - min) × 100 // 0-100 scale + +score_total[i] = normalise(rate_diff)[i] × 0.50 + + normalise(cpi_dev)[i] × 0.30 + + normalise(pmi_raw)[i] × 0.20 + +gap = score_total[strongest] - score_total[weakest] +``` + +### Layer 2 — Technical Score (Bar-Anchored) + +``` +Step 1: Seed bar_history with 288 M5 close prices (24 hours) +Step 2: μ_bars = mean(bar_history), σ_bars = stdev(bar_history) +Step 3: For each incoming tick: + + Z[pair] = (current_tick_price - μ_bars) / σ_bars + + Fallback (if bar_history empty): + Z[pair] = (current_tick_price - mean(ticks)) / stdev(ticks) + +Step 4: Individual Currency Strength = avg(Z[currency_X] over all 7 base pairs) + +Step 5: Session Relative Velocity (SRV): + At session open (Tokyo/London/NY), snapshot all prices. + SRV[pair] = ((current_price - session_open_price) / session_open_price) × 100 +``` + +### Entry Conditions + +``` +Matrix Divergence: any(avg_Z > +2.0) AND any(avg_Z < -2.0) → Trade Matrix Cross +Pair Confluence: L1_gap >= 20 AND L2_Z >= 2.0 on same pair → Trade that pair +``` + +--- + +## 10. 28 Currency Pairs (Generated) + +All 8 currencies produce 56 permutations (28 pairs × 2 directions): + +| Base | Pairs (base_quote) | +|------|--------------------| +| USD | USD_EUR, USD_GBP, USD_JPY, USD_AUD, USD_CAD, USD_CHF, USD_NZD | +| EUR | EUR_USD, EUR_GBP, EUR_JPY, EUR_AUD, EUR_CAD, EUR_CHF, EUR_NZD | +| GBP | GBP_USD, GBP_EUR, GBP_JPY, GBP_AUD, GBP_CAD, GBP_CHF, GBP_NZD | +| JPY | JPY_USD, JPY_EUR, JPY_GBP, JPY_AUD, JPY_CAD, JPY_CHF, JPY_NZD | +| AUD | AUD_USD, AUD_EUR, AUD_GBP, AUD_JPY, AUD_CAD, AUD_CHF, AUD_NZD | +| CAD | CAD_USD, CAD_EUR, CAD_GBP, CAD_JPY, CAD_AUD, CAD_CHF, CAD_NZD | +| CHF | CHF_USD, CHF_EUR, CHF_GBP, CHF_JPY, CHF_AUD, CHF_CAD, CHF_NZD | +| NZD | NZD_USD, NZD_EUR, NZD_GBP, NZD_JPY, NZD_AUD, NZD_CAD, NZD_CHF | + +Each currency's individual strength is computed from its 7 base pairs. + +--- + +## 11. Refactoring Changelog (Session-Based Quantitative Engine) + +### Task 1.1 — Statistical Lookback Window (config.py, layer2_technical.py) +- `config.py`: Added `BAR_TIMEFRAME`, `BAR_LOOKBACK_HOURS`, `BAR_LOOKBACK_BARS`, `HISTORICAL_POLL_INTERVAL` constants. Default lookback changed from 20 ticks to 288 bars (24h of M5 data). +- `layer2_technical.py`: `TechnicalAnalyzer` now maintains **two data streams**: + - `bar_history` (deque of M1/M5 close prices, length = `BAR_LOOKBACK_BARS`) — the multi-hour statistical anchor + - `price_history` (short deque of tick/poll data) — for UI display + - `_get_bar_mean_std()` computes μ/σ from bar history only + - `_update_z_score()` uses `Z = (current_tick - μ_bars) / σ_bars` + - `add_bar()` method for feeding completed M1/M5 candles into the historical frame + +### Task 1.2 — Session-Based Indexing (currency_strength_matrix.py) +- New `SessionTracker` class: + - Detects active session from UTC hour (Tokyo 00-08, London 07-16, New York 13-22) + - On session open, snapshots start prices for all 28 pairs + - Computes **Session Relative Velocity (SRV)**: `% change = (current - session_start) / session_start × 100` +- `CurrencyStrengthMatrix.update()` now accepts `current_prices` dict for session tracking +- `CurrencyStrength` dataclass has new `session_srv: float` field +- `get_report()` includes `active_session` key + +### Task 2.1 — Live Order Book Subscriptions (data_feeder.py) +- `Mt5DataFeeder.get_order_book(pair)` — fetches live bid/ask/spread via `mt5.symbol_info_tick()` + `mt5.symbol_info()` for the exact trade symbol +- `PositionSizer.calculate_position_size()` accepts optional `bid`, `ask`, `spread` params; wide spreads reduce position size by up to 20% +- `MockDataFeeder` has matching `get_order_book()` implementation + +### Task 2.2 — SQLite WAL Mode + Bar Cache (database.py) +- Connection now sets: `PRAGMA journal_mode=WAL`, `PRAGMA synchronous=NORMAL` for concurrent read/write performance +- New `bar_cache` table: `(id, pair, timeframe, bar_time, open, high, low, close, volume)` with unique constraint on `(pair, timeframe, bar_time)` and compound index +- New methods: `upsert_bar()`, `get_bars()`, `get_latest_bar_time()` + +### Task 3.1 — Layer 1 as Directional Regime Filter (confluence_filter.py) +- `check_entry_confluence()` now uses Layer 1 as a **Directional Regime Filter**: + - Primary signal: Matrix divergence (self-sufficient) + - Secondary: Layer 2 extremes only valid if **aligned** with Layer 1 macro bias + - Contrarian Layer 2 signals (Z < -threshold opposite Layer 1 direction) → **BLOCKED** with reason + - Aligned signals capped at 70% confidence (downgraded vs matrix divergence) +- `layer1_is_active` flag replaces raw gap comparison + +### Task 3.2 — Dynamic Pearson Correlation (risk_management.py, data_feeder.py) +- New `pearson_correlation(x, y)` function: `r = Σ(x-x̄)(y-ȳ) / √(Σ(x-x̄)² · Σ(y-ȳ)²)` +- New `CorrelationEngine` class: + - `update_series(historical_closes)` — feeds 30 days of close prices + - `get_correlation(pair_a, pair_b)` — computes/caches r between any two pairs + - `get_top_correlated(target, n=3, min_r=0.75)` — returns top N pairs with |r| ≥ 0.75 +- `BasketHedging.get_correlated_pairs()` now delegates to `CorrelationEngine` instead of hardcoded dict +- `Mt5DataFeeder.fetch_historical_closes_all_pairs(days=30)` fetches the required data +- `MockDataFeeder` has matching implementation + +### Task 3.3 — Aggregate Portfolio Profit Target Exit (risk_management.py) +- `get_dynamic_exit_target()` — confidence-scaled profit target (base = 1% of equity, scales with avg confidence) +- Background monitor thread `_monitor_exit_loop()` polls `calculate_basket_pnl()` every second +- When net aggregate P&L > dynamic target, fires `close_all_trades()` via registered callbacks +- `start_exit_monitor()`, `stop_exit_monitor()`, `on_portfolio_exit()` lifecycle management + +### Task 4.1 — Session Visualizations + σ Highlights (ui/layer2_monitor_tab.py) +- Active session indicator label with color-coded background: Tokyo (purple), London (blue), New York (orange), Off-Hours (gray) +- Currency Strength Matrix Z-score cells: solid red background with white text for ≥ +2.0σ, solid green with white text for ≤ -2.0σ +- New 5th column in matrix table: "Session SRV" showing percentage change since session open +- Emoji indicators removed from status labels for cleaner display + +--- + +## 12. Bug Fixes & Stability (Round 2) + +### Fix 1 — Bar History Never Seeded (Z-scores always 0.0) +- `layer2_technical.py`: Added `seed_bars(historical_bars)` method to populate `bar_history` with 288 M5 close prices on connect +- `data_feeder.py (Mock)`: Added `generate_mock_bars(n_bars=288)` — generates 24h of simulated M5 data using an Ornstein-Uhlenbeck process (mean reversion + drift + noise) for all 28 pairs via USD pair derivation +- `ui/layer2_monitor_tab.py`: `_connect()` calls `_seed_historical_bars()` before starting the streamer — bars are always seeded first + +### Fix 2 — Tick Price Anchored to Initial Base, Not Bar Data +- `data_feeder.py (Mock)`: `_tick_price()` now uses the **last bar close** as its anchor with ±0.0002 noise, instead of the initial base price with ±0.01 noise +- `_current_bar_prices()` returns the last cached bar close for each pair +- This ensures Z-scores reflect the bar position relative to 24h history, not random tick noise + +### Fix 3 — Default Source Changed to Mock +- `ui/layer2_monitor_tab.py`: `source_combo` defaults to `"Mock (Test)"` at index 0 to prevent unintended MT5 terminal connections on startup + +### Fix 4 — Persistent Matrix Instance +- `ui/layer2_monitor_tab.py`: `CurrencyStrengthMatrix` is now a persistent `self.matrix` instance, recreated only once. `update()` is called each refresh instead of creating a new object, preserving `SessionTracker` state across refreshes + +### Fix 5 — FRED Series IDs Updated +- `config.py`: Updated 6 invalid/deprecated FRED series IDs (`BOEBR`, `IRSTJPN`, `RBATCTR`, `BOCCRT`, `SNBPOL`, `RBNZOCR`) to commonly used alternatives (`BOEIR`, `IRSTCI01JPM156N`, `RBATR`, `BOCARR`, `SNBON`, `RBNZR`) diff --git a/requirements.txt b/requirements.txt index 16dc95f..f11d660 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ python-dotenv==1.0.0 pandas==2.1.3 openpyxl==3.1.2 pyinstaller==6.1.0 +MetaTrader5==5.0.45 diff --git a/risk_management.py b/risk_management.py new file mode 100644 index 0000000..c401a3d --- /dev/null +++ b/risk_management.py @@ -0,0 +1,466 @@ +from typing import Dict, List, Optional, Tuple, Callable +import threading +import time +import math +import config + + +def pearson_correlation(x: List[float], y: List[float]) -> float: + """Compute Pearson correlation coefficient r between two series. + + r = sum((x - x̄)(y - ȳ)) / sqrt(sum(x - x̄)^2 * sum(y - ȳ)^2) + + Returns value in [-1, 1]. |r| > 0.75 indicates strong correlation. + """ + n = min(len(x), len(y)) + if n < 3: + return 0.0 + x, y = x[:n], y[:n] + x_mean = sum(x) / n + y_mean = sum(y) / n + num = sum((xi - x_mean) * (yi - y_mean) for xi, yi in zip(x, y)) + den_x = math.sqrt(sum((xi - x_mean) ** 2 for xi in x)) + den_y = math.sqrt(sum((yi - y_mean) ** 2 for yi in y)) + if den_x == 0 or den_y == 0: + return 0.0 + r = num / (den_x * den_y) + return max(-1.0, min(1.0, r)) + + +class PositionSizer: + """Calculates position size based on risk and confluence strength.""" + + def __init__(self, account_balance: float = 10000.0, risk_per_trade: float = 0.01): + self.account_balance = account_balance + self.risk_per_trade = risk_per_trade + self.positions = {} + + def calculate_position_size( + self, + pair: str, + confluence_strength: float, + entry_price: float, + stop_loss_pips: float = 50, + bid: float = None, + ask: float = None, + spread: float = None, + ) -> float: + risk_amount = self.account_balance * self.risk_per_trade + confidence_multiplier = confluence_strength / 100.0 + pip_value_per_lot = 10.0 + max_loss_per_lot = stop_loss_pips * pip_value_per_lot + if max_loss_per_lot > 0: + base_position = risk_amount / max_loss_per_lot + position_size = base_position * confidence_multiplier + else: + position_size = 0.0 + # Spread penalty (Task 2.1): wide spreads reduce size by up to 20% + if spread is not None and spread > 0: + spread_penalty = min(spread * 100, 0.2) # cap at 20% penalty + position_size *= (1.0 - spread_penalty) + position_size = max(0.01, min(position_size, 5.0)) + return position_size + + def add_position(self, pair: str, position_size: float, entry_price: float): + self.positions[pair] = { + 'size': position_size, + 'entry_price': entry_price, + 'status': 'OPEN' + } + + def close_position(self, pair: str, exit_price: float) -> Optional[Dict]: + if pair not in self.positions: + return None + pos = self.positions[pair] + price_delta = exit_price - pos['entry_price'] + pl = price_delta * pos['size'] * 100000 + result = { + 'pair': pair, + 'entry': pos['entry_price'], + 'exit': exit_price, + 'size': pos['size'], + 'pnl': pl, + 'pnl_pips': price_delta * 10000 + } + del self.positions[pair] + return result + + +class GridHedging: + """Grid hedging system for drawdown protection.""" + + def __init__(self, grid_levels: int = 3): + self.grid_levels = grid_levels + self.hedges = [] + + def create_hedge_grid( + self, + pair: str, + entry_price: float, + position_size: float, + grid_distance: float = 0.50 + ) -> List[Dict]: + self.hedges = [] + if self.grid_levels < 2: + return self.hedges + hedge_size = position_size * 0.5 / (self.grid_levels - 1) + for level in range(1, self.grid_levels): + hedge_price = entry_price - (grid_distance * level / 10000) + self.hedges.append({ + 'pair': pair, + 'level': level, + 'price': hedge_price, + 'size': hedge_size, + 'type': 'HEDGE' + }) + return self.hedges + + def get_total_hedge_exposure(self) -> float: + return sum(h['size'] for h in self.hedges) + + def get_hedges_for_pair(self, pair: str) -> List[Dict]: + return [h for h in self.hedges if h['pair'] == pair] + + +class PortfolioExposure: + """Manage portfolio-level exposure and leverage.""" + + def __init__(self, max_portfolio_leverage: float = 2.0): + self.max_leverage = max_portfolio_leverage + self.positions = {} + self.total_exposure = 0.0 + + def can_add_position(self, position_size: float, account_balance: float) -> bool: + new_exposure = self.total_exposure + position_size + max_exposure = account_balance * self.max_leverage + return new_exposure <= max_exposure + + def add_position(self, pair: str, position_size: float): + self.positions[pair] = position_size + self.total_exposure = sum(self.positions.values()) + + def remove_position(self, pair: str): + if pair in self.positions: + del self.positions[pair] + self.total_exposure = sum(self.positions.values()) + + def get_leverage_ratio(self, account_balance: float) -> float: + if account_balance <= 0: + return 0.0 + return self.total_exposure / account_balance + + def get_exposure_percentage(self, pair: str) -> float: + if self.total_exposure <= 0: + return 0.0 + return (self.positions.get(pair, 0) / self.total_exposure) * 100 + + +class CorrelationEngine: + """Rolling Pearson correlation matrix (Task 3.2). + + Replaces the hardcoded CORRELATION_CLUSTERS with a dynamic + calculation based on the past 30 days of Close prices. + """ + + def __init__(self): + self.correlation_cache: Dict[Tuple[str, str], float] = {} + self.last_update = None + self.price_series: Dict[str, List[float]] = {} + + def update_series(self, historical_closes: Dict[str, List[float]]): + """Feed 30 days of Close prices for all 28 pairs.""" + self.price_series = historical_closes + self.correlation_cache.clear() + self.last_update = time.time() + + def get_correlation(self, pair_a: str, pair_b: str) -> float: + """Get Pearson r between two pairs.""" + key = tuple(sorted([pair_a, pair_b])) + if key in self.correlation_cache: + return self.correlation_cache[key] + + series_a = self.price_series.get(pair_a, []) + series_b = self.price_series.get(pair_b, []) + r = pearson_correlation(series_a, series_b) + self.correlation_cache[key] = r + return r + + def get_top_correlated( + self, target_pair: str, n: int = 3, min_r: float = 0.75 + ) -> List[Tuple[str, float]]: + """Get top N pairs most correlated (|r| >= min_r) with target.""" + results = [] + for pair in self.price_series: + if pair == target_pair: + continue + r = self.get_correlation(target_pair, pair) + if abs(r) >= min_r: + results.append((pair, r)) + results.sort(key=lambda x: abs(x[1]), reverse=True) + return results[:n] + + +class BasketHedging: + """Dynamic basket hedging using Pearson correlation (Task 3.2). + + Instead of hardcoded correlation clusters, uses CorrelationEngine + to select the top 3 pairs with |r| >= 0.75 to the target cross-pair. + """ + + def __init__(self, correlation_engine: CorrelationEngine = None): + self.basket_positions = [] + self.correlation_engine = correlation_engine or CorrelationEngine() + + def set_correlation_engine(self, engine: CorrelationEngine): + self.correlation_engine = engine + + def get_correlated_pairs(self, pair: str) -> List[str]: + """Get dynamically correlated pairs for basket hedging.""" + top = self.correlation_engine.get_top_correlated(pair, n=3, min_r=0.75) + return [p for p, r in top] + + def create_basket_hedge( + self, + primary_pair: str, + primary_size: float, + confluence_strength: float, + current_prices: Dict[str, float] = None + ) -> List[Dict]: + correlated = self.get_correlated_pairs(primary_pair) + self.basket_positions = [] + + if not correlated: + return self.basket_positions + + hedge_ratio = 0.3 + hedge_size = primary_size * hedge_ratio / len(correlated) + + for cp in correlated: + entry = (current_prices or {}).get(cp, 0) + self.basket_positions.append({ + 'pair': cp, + 'size': hedge_size, + 'entry_price': entry, + 'type': 'BASKET_HEDGE', + 'primary_pair': primary_pair, + }) + + return self.basket_positions + + def get_total_basket_exposure(self) -> float: + return sum(h['size'] for h in self.basket_positions) + + +class RiskManagementSystem: + """Complete risk management system with portfolio-level exit (Task 3.3). + + Features: + - Position sizing with spread penalty (Task 2.1) + - Dynamic basket hedging via Pearson correlation (Task 3.2) + - Aggregate portfolio P&L monitoring with dynamic profit target (Task 3.3) + - Portfolio-based exit: close ALL trades when basket P&L > target + """ + + def __init__(self, account_balance: float = 10000.0): + self.account_balance = account_balance + self.sizer = PositionSizer(account_balance, risk_per_trade=0.01) + self.hedger = GridHedging(grid_levels=config.GRID_LEVELS) + self.portfolio = PortfolioExposure(max_portfolio_leverage=config.MAX_PORTFOLIO_LEVERAGE) + self.correlation_engine = CorrelationEngine() + self.basket = BasketHedging(self.correlation_engine) + self.trades = [] + + self.current_prices: Dict[str, float] = {} + self.current_order_books: Dict[str, Dict] = {} + + # Portfolio exit monitor (Task 3.3) + self._exit_monitor_running = False + self._exit_monitor_thread: Optional[threading.Thread] = None + self._exit_callbacks: List[Callable] = [] + + def update_prices(self, prices: Dict[str, float]): + self.current_prices.update(prices) + + def update_order_books(self, order_books: Dict[str, Dict]): + self.current_order_books.update(order_books) + + def update_correlation_data(self, historical_closes: Dict[str, List[float]]): + """Feed 30-day close prices for dynamic correlation (Task 3.2).""" + self.correlation_engine.update_series(historical_closes) + + def execute_signal( + self, + pair: str, + confluence_strength: float, + entry_price: float, + use_hedging: bool = True, + use_basket: bool = True, + order_book: Dict = None, + ) -> Optional[Dict]: + """Execute a confluence signal with full risk management. + + Uses actual bid/ask/spread from order book (Task 2.1) for + position sizing if available. + """ + bid = (order_book or {}).get('bid', entry_price) + ask = (order_book or {}).get('ask', entry_price) + spread = (order_book or {}).get('spread') + + position_size = self.sizer.calculate_position_size( + pair, confluence_strength, entry_price, + stop_loss_pips=50, bid=bid, ask=ask, spread=spread + ) + + if not self.portfolio.can_add_position(position_size, self.account_balance): + return None + + trade = { + 'pair': pair, + 'entry_price': entry_price, + 'entry_bid': bid, + 'entry_ask': ask, + 'position_size': position_size, + 'confluence_strength': confluence_strength, + 'status': 'OPEN', + } + + if use_hedging and confluence_strength > 70: + trade['grid_hedges'] = self.hedger.create_hedge_grid( + pair, entry_price, position_size + ) + + if use_basket and confluence_strength > 60: + trade['basket_hedges'] = self.basket.create_basket_hedge( + pair, position_size, confluence_strength, self.current_prices + ) + if config.DEBUG: + n_hedges = len(trade.get('basket_hedges', [])) + print(f"[Risk] Created {n_hedges} dynamic basket hedges for {pair}") + + self.portfolio.add_position(pair, position_size) + self.trades.append(trade) + return trade + + def calculate_basket_pnl(self) -> float: + """Calculate aggregate P&L across ALL open positions and hedges.""" + total = 0.0 + for trade in self.trades: + if trade['status'] != 'OPEN': + continue + pair = trade['pair'] + entry = trade['entry_price'] + current = self.current_prices.get(pair, entry) + delta = current - entry + total += delta * trade['position_size'] * 100000 + + for hedge in trade.get('grid_hedges', []): + h_current = self.current_prices.get(hedge['pair'], hedge['price']) + h_delta = h_current - hedge['price'] + total += h_delta * hedge['size'] * 100000 + + for hedge in trade.get('basket_hedges', []): + h_current = self.current_prices.get(hedge['pair'], hedge['entry_price']) + h_delta = h_current - hedge['entry_price'] + total += h_delta * hedge['size'] * 100000 + + return total + + def get_dynamic_exit_target(self) -> float: + """Dynamic profit target based on trade confidence (Task 3.3). + + Higher confidence trades get a larger profit target. + Base: +1% of account balance. + """ + if not self.trades: + return self.account_balance * 0.01 + + avg_confidence = sum( + t.get('confluence_strength', 50) for t in self.trades if t['status'] == 'OPEN' + ) + n_open = max(len([t for t in self.trades if t['status'] == 'OPEN']), 1) + avg_confidence /= n_open + + base_target = self.account_balance * 0.01 + confidence_mult = avg_confidence / 50.0 # 1.0x at 50%, 2.0x at 100% + return base_target * confidence_mult + + def _monitor_exit_loop(self): + """Background loop monitoring basket P&L every second (Task 3.3). + + When aggregate net P&L surpasses the dynamic target, + fires close_all_trades() automatically. + """ + while self._exit_monitor_running: + if not self.trades: + time.sleep(1) + continue + + total_pnl = self.calculate_basket_pnl() + target = self.get_dynamic_exit_target() + + if total_pnl > target: + if config.DEBUG: + print(f"[Risk] Portfolio exit triggered: P&L={total_pnl:.2f} target={target:.2f}") + for cb in self._exit_callbacks: + try: + cb(total_pnl) + except Exception: + pass + break + + time.sleep(1) + + def start_exit_monitor(self): + """Start the background portfolio exit monitor (Task 3.3).""" + if self._exit_monitor_running: + return + self._exit_monitor_running = True + self._exit_monitor_thread = threading.Thread( + target=self._monitor_exit_loop, daemon=True + ) + self._exit_monitor_thread.start() + if config.DEBUG: + print("[Risk] Portfolio exit monitor started") + + def on_portfolio_exit(self, callback: Callable): + """Register a callback for when the portfolio exit fires.""" + self._exit_callbacks.append(callback) + + def stop_exit_monitor(self): + self._exit_monitor_running = False + + def should_exit_portfolio(self) -> Tuple[bool, float]: + """Check if aggregate portfolio P&L has hit the profit target.""" + total_pnl = self.calculate_basket_pnl() + if total_pnl > self.get_dynamic_exit_target(): + return True, total_pnl + return False, total_pnl + + def close_trade(self, pair: str, exit_price: float) -> Optional[Dict]: + result = self.sizer.close_position(pair, exit_price) + if result: + self.portfolio.remove_position(pair) + return result + + def close_all_trades(self, exit_prices: Dict[str, float]): + """Close all open trades at given exit prices.""" + results = [] + for trade in list(self.trades): + if trade['status'] == 'OPEN': + price = exit_prices.get(trade['pair'], trade['entry_price']) + result = self.close_trade(trade['pair'], price) + if result: + results.append(result) + self.stop_exit_monitor() + return results + + def get_portfolio_summary(self) -> Dict: + return { + 'total_positions': len(self.portfolio.positions), + 'total_exposure': self.portfolio.total_exposure, + 'leverage_ratio': self.portfolio.get_leverage_ratio(self.account_balance), + 'open_trades': len([t for t in self.trades if t['status'] == 'OPEN']), + 'basket_pnl': self.calculate_basket_pnl(), + 'exit_target': self.get_dynamic_exit_target(), + 'account_balance': self.account_balance, + } diff --git a/scorer.py b/scorer.py index 08455ae..b9aaa78 100644 --- a/scorer.py +++ b/scorer.py @@ -234,7 +234,7 @@ def generate_signal(scores: Dict[str, Dict]) -> Tuple[str, str, str]: status = "ACTIVE" # Classify gap tier - if gap >= config.GAP_THRESHOLDS["strong"]: + if gap >= 60: tier = "Strong signal" elif gap >= config.GAP_THRESHOLDS["standard"]: tier = "Standard signal" @@ -252,6 +252,40 @@ def generate_signal(scores: Dict[str, Dict]) -> Tuple[str, str, str]: return signal_text, status, gap_desc +def build_directional_bias_matrix(scores: Dict[str, Dict]) -> Dict[str, Dict]: + """Build a permanent monthly directional bias matrix from Layer 1 scores. + + Rules: + - Top 2 scores → "STRONG" — currency must only be longed, never shorted + - Bottom 2 scores → "WEAK" — currency must only be shorted, never longed + - Middle 4 scores → "NEUTRAL" — no directional restriction + + Returns: + { + "USD": {"direction": "STRONG", "score": 85.2, "rank": 1}, + "EUR": {"direction": "NEUTRAL", "score": 55.0, "rank": 4}, + "JPY": {"direction": "WEAK", "score": 22.1, "rank": 8}, + ... + } + """ + ranked = get_ranked_list(scores) + n = len(ranked) + matrix = {} + for i, (currency, total_score, rank) in enumerate(ranked): + if i < 2 and n >= 4: + direction = "STRONG" + elif i >= n - 2 and n >= 4: + direction = "WEAK" + else: + direction = "NEUTRAL" + matrix[currency] = { + "direction": direction, + "score": total_score, + "rank": rank, + } + return matrix + + def get_gap_tier(gap: float) -> str: """ Classify a gap size into trading tiers. @@ -263,7 +297,7 @@ def get_gap_tier(gap: float) -> str: return "no_trade" elif gap < config.GAP_THRESHOLDS["standard"]: return "weak" - elif gap < config.GAP_THRESHOLDS["strong"]: + elif gap < 60: return "standard" else: return "strong" diff --git a/ui/confluence_tab.py b/ui/confluence_tab.py new file mode 100644 index 0000000..77b275f --- /dev/null +++ b/ui/confluence_tab.py @@ -0,0 +1,362 @@ +""" +APEX Confluence Signals Tab — Layer 1 + Layer 2 Merging + +Displays: +- Current Layer 1 fundamental bias +- Layer 2 technical extremes +- Confluence signals (both aligned) +- Risk management details +""" + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, + QPushButton, QFrame, QMessageBox, QProgressBar +) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QColor, QFont, QBrush +from typing import Dict, Optional +from datetime import datetime +import config +from layer2_technical import TechnicalAnalyzer +from confluence_filter import ConfluenceFilter, SignalHistory +from risk_management import RiskManagementSystem +from database import Database + + +class ConfluenceSignalsTab(QWidget): + """Confluence signals monitoring and execution.""" + + def __init__(self, db: Database, tech_analyzer: TechnicalAnalyzer): + super().__init__() + + self.db = db + self.tech_analyzer = tech_analyzer + self.confluence = ConfluenceFilter(tech_analyzer) + self.risk_mgmt = RiskManagementSystem(account_balance=config.ACCOUNT_BALANCE) + self.signal_history = SignalHistory() + + self._init_ui() + self._setup_auto_refresh() + + def _init_ui(self): + """Build UI layout.""" + layout = QVBoxLayout() + + # ====== Confluence Status Card ====== + card = self._build_status_card() + layout.addWidget(card) + layout.addSpacing(15) + + # ====== Active Signals Table ====== + layout.addWidget(QLabel("Confluence Signals (Layer 1 + Layer 2)")) + + self.signals_table = QTableWidget() + self.signals_table.setColumnCount(8) + self.signals_table.setHorizontalHeaderLabels([ + "Pair", "L1 Gap", "L2 Z-Score", "Status", "Confidence", "Entry Price", "Position Size", "Action" + ]) + self.signals_table.setRowCount(10) + + layout.addWidget(self.signals_table) + layout.addSpacing(15) + + # ====== Risk Management Panel ====== + risk_layout = QHBoxLayout() + risk_layout.addWidget(QLabel("Portfolio Exposure:")) + + self.exposure_bar = QProgressBar() + self.exposure_bar.setMaximum(100) + risk_layout.addWidget(self.exposure_bar) + + self.leverage_label = QLabel("Leverage: —") + risk_layout.addWidget(self.leverage_label) + + layout.addLayout(risk_layout) + layout.addSpacing(10) + + # ====== Control Buttons ====== + button_layout = QHBoxLayout() + + refresh_btn = QPushButton("Refresh Signals") + refresh_btn.clicked.connect(self._refresh_signals) + button_layout.addWidget(refresh_btn) + + execute_btn = QPushButton("Execute Top Signal") + execute_btn.clicked.connect(self._execute_signal) + button_layout.addWidget(execute_btn) + + button_layout.addStretch() + layout.addLayout(button_layout) + layout.addStretch() + + self.setLayout(layout) + + def _build_status_card(self) -> QFrame: + """Build confluence status card.""" + card = QFrame() + card.setStyleSheet(""" + QFrame { + background-color: #f8f9fa; + border: 2px solid #dee2e6; + border-radius: 8px; + padding: 15px; + } + """) + + layout = QVBoxLayout() + + title = QLabel("CONFLUENCE STATUS") + title.setFont(QFont("Arial", 10, QFont.Bold)) + layout.addWidget(title) + layout.addSpacing(5) + + # Layer 1 status + layer1_layout = QHBoxLayout() + layer1_layout.addWidget(QLabel("Layer 1 (Fundamental):")) + self.layer1_status_label = QLabel("No bias") + self.layer1_status_label.setFont(QFont("Arial", 11, QFont.Bold)) + layer1_layout.addWidget(self.layer1_status_label) + layer1_layout.addStretch() + layout.addLayout(layer1_layout) + + # Directional Bias Matrix display + bias_layout = QHBoxLayout() + bias_layout.addWidget(QLabel("Macro Boundaries:")) + self.bias_matrix_label = QLabel("No bias matrix") + self.bias_matrix_label.setStyleSheet("color: #8e44ad; font-size: 10px;") + bias_layout.addWidget(self.bias_matrix_label) + bias_layout.addStretch() + layout.addLayout(bias_layout) + + # Layer 2 status + layer2_layout = QHBoxLayout() + layer2_layout.addWidget(QLabel("Layer 2 (Technical):")) + self.layer2_status_label = QLabel("No extreme") + self.layer2_status_label.setStyleSheet("color: #95a5a6;") + layer2_layout.addWidget(self.layer2_status_label) + layer2_layout.addStretch() + layout.addLayout(layer2_layout) + + # Confluence result + conf_layout = QHBoxLayout() + conf_layout.addWidget(QLabel("Confluence Result:")) + self.confluence_status_label = QLabel("❌ NO CONFLUENCE") + self.confluence_status_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + self.confluence_status_label.setFont(QFont("Arial", 12, QFont.Bold)) + conf_layout.addWidget(self.confluence_status_label) + conf_layout.addStretch() + layout.addLayout(conf_layout) + + # Matrix cross (S.A.T.O.R.I.) + matrix_layout = QHBoxLayout() + matrix_layout.addWidget(QLabel("Matrix Cross:")) + self.matrix_cross_label = QLabel("—") + self.matrix_cross_label.setStyleSheet("font-weight: bold; color: #8e44ad;") + matrix_layout.addWidget(self.matrix_cross_label) + matrix_layout.addStretch() + layout.addLayout(matrix_layout) + + self.matrix_detail_label = QLabel("") + self.matrix_detail_label.setStyleSheet("color: #7f8c8d; font-size: 10px;") + layout.addWidget(self.matrix_detail_label) + + card.setLayout(layout) + return card + + def set_layer1_signal(self, strongest: str, weakest: str, gap: float, + bias_matrix: dict = None): + """Update Layer 1 directional bias matrix from Dashboard.""" + self.confluence.set_layer1_bias(strongest, weakest, gap, bias_matrix) + self._refresh_signals() + + def _refresh_signals(self): + """Refresh confluence signal display.""" + try: + report = self.confluence.get_confluence_report() + + # Update bias matrix display + bias = report.get("bias_matrix", {}) + if bias: + parts = [] + for ccy in config.CURRENCIES: + d = bias.get(ccy, "—") + if d == "STRONG": + parts.append(f"{ccy}↑") + elif d == "WEAK": + parts.append(f"{ccy}↓") + else: + parts.append(f"{ccy}—") + self.bias_matrix_label.setText(" ".join(parts)) + self.bias_matrix_label.setStyleSheet("color: #8e44ad; font-size: 10px;") + else: + self.bias_matrix_label.setText("No bias matrix") + self.bias_matrix_label.setStyleSheet("color: #95a5a6; font-size: 10px;") + + # Update matrix cross display + mc = report.get("matrix_cross", "—") + gap = report.get("divergence_gap", 0) + has_div = report.get("has_matrix_divergence", False) + ranked = report.get("matrix_ranked", []) + + if mc and mc != "N/A": + self.matrix_cross_label.setText(f"{mc} (spread: {gap:.2f}σ)") + if has_div: + self.matrix_cross_label.setStyleSheet("font-weight: bold; color: #e74c3c;") + else: + self.matrix_cross_label.setStyleSheet("font-weight: bold; color: #8e44ad;") + else: + self.matrix_cross_label.setText("—") + self.matrix_cross_label.setStyleSheet("font-weight: bold; color: #95a5a6;") + + if ranked: + top3 = [f"{c[0]}({c[1]:+.1f})" for c in ranked[:3]] + bot3 = [f"{c[0]}({c[1]:+.1f})" for c in ranked[-3:]] + self.matrix_detail_label.setText( + f"Strongest → {' | '.join(top3)} — Weakest → {' | '.join(bot3)}" + ) + else: + self.matrix_detail_label.setText("") + + # Check for confluence + should_enter, reason, strength = self.confluence.check_entry_confluence() + + # Update status + if should_enter: + l1s = self.confluence.layer1_strongest or "—" + l1w = self.confluence.layer1_weakest or "—" + self.layer1_status_label.setText(f"🟢 {l1s}/{l1w} (Gap: {self.confluence.layer1_gap:.1f})") + + pair = f"{self.confluence.layer1_strongest}_{self.confluence.layer1_weakest}" if self.confluence.layer1_strongest else "—" + z_score = self.tech_analyzer.get_z_score(pair) if pair != "—" else 0 + self.layer2_status_label.setText(f"🔴 {pair} Z-score: {z_score:.2f}") + self.layer2_status_label.setStyleSheet("color: #27ae60;") + + if has_div: + self.confluence_status_label.setText(f"✅ MATRIX DIVERGENCE: {strength:.0f}%") + else: + self.confluence_status_label.setText(f"✅ CONFLUENCE: {strength:.0f}% confidence") + self.confluence_status_label.setStyleSheet("color: #27ae60; font-weight: bold;") + + self._populate_signal_table(strength) + else: + self.layer1_status_label.setText("No signal") + self.layer2_status_label.setText("No extreme") + self.layer2_status_label.setStyleSheet("color: #95a5a6;") + + self.confluence_status_label.setText("❌ NO CONFLUENCE") + self.confluence_status_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + + # Update risk metrics + portfolio = self.risk_mgmt.get_portfolio_summary() + exposure_pct = min((portfolio['total_exposure'] / config.ACCOUNT_BALANCE) * 100, 100) + self.exposure_bar.setValue(int(exposure_pct)) + self.leverage_label.setText(f"Leverage: {portfolio['leverage_ratio']:.2f}x") + + except Exception as e: + print(f"[Confluence] Error refreshing: {e}") + + def _populate_signal_table(self, confluence_strength: float): + """Populate the signals table with matrix and confluence data.""" + report = self.confluence.get_confluence_report() + signals = self.confluence.get_all_signals() + + self.signals_table.clearContents() + row = 0 + + for pair_key, signal in signals.items(): + if row >= self.signals_table.rowCount(): + break + + pair_item = QTableWidgetItem(pair_key) + pair_item.setFlags(pair_item.flags() & ~Qt.ItemIsEditable) + if signal.get('type') == 'MATRIX_DIVERGENCE': + pair_item.setForeground(QColor("#8e44ad")) + self.signals_table.setItem(row, 0, pair_item) + + # L1 Gap (from report) + gap_item = QTableWidgetItem(f"{report.get('layer1_gap', 0):.1f}") + gap_item.setFlags(gap_item.flags() & ~Qt.ItemIsEditable) + self.signals_table.setItem(row, 1, gap_item) + + # L2 Z-Score + z = report.get('layer2_z_score', 0) + if signal.get('type') == 'MATRIX_DIVERGENCE': + z = report.get('matrix_cross_z', 0) + z_item = QTableWidgetItem(f"{z:.2f}") + z_item.setFlags(z_item.flags() & ~Qt.ItemIsEditable) + self.signals_table.setItem(row, 2, z_item) + + # Status + sig_type = signal.get('type', 'SIGNAL').replace('_', ' ') + status_item = QTableWidgetItem(sig_type) + if 'DIVERGENCE' in sig_type: + status_item.setBackground(QColor("#f3e5f5")) + status_item.setForeground(QColor("#6a1b9a")) + else: + status_item.setBackground(QColor("#e8f5e9")) + status_item.setFlags(status_item.flags() & ~Qt.ItemIsEditable) + self.signals_table.setItem(row, 3, status_item) + + # Confidence + strength = signal.get('strength', confluence_strength) + conf_item = QTableWidgetItem(f"{strength:.0f}%") + conf_item.setFont(QFont("Arial", 10, QFont.Bold)) + conf_item.setFlags(conf_item.flags() & ~Qt.ItemIsEditable) + self.signals_table.setItem(row, 4, conf_item) + + row += 1 + + def _execute_signal(self): + """Execute the top confluence signal.""" + signals = self.confluence.get_all_signals() + if not signals: + QMessageBox.warning(self, "No Signal", "No valid confluence signal to execute") + return + + try: + best = max(signals.values(), key=lambda s: s.get('strength', 0)) + pair = best['pair'] + strength = best['strength'] + + current_price = 1.0 + + trade = self.risk_mgmt.execute_signal( + pair, + strength, + current_price, + use_hedging=config.USE_GRID_HEDGING + ) + + if trade: + msg = ( + f"Trade Executed:\n" + f"Pair: {trade['pair']}\n" + f"Entry: {trade['entry_price']:.4f}\n" + f"Size: {trade['position_size']:.2f} lots\n" + f"Confidence: {trade['confluence_strength']:.0f}%" + ) + QMessageBox.information(self, "Trade Executed", msg) + + self.signal_history.add_signal({ + 'pair': pair, + 'type': best.get('type', 'SIGNAL'), + 'entry_price': current_price, + 'confluence_strength': strength, + }) + else: + QMessageBox.warning( + self, + "Execution Failed", + "Position size would exceed portfolio leverage limits" + ) + + self._refresh_signals() + + except Exception as e: + QMessageBox.critical(self, "Error", f"Execution failed: {e}") + + def _setup_auto_refresh(self): + """Setup automatic refresh timer.""" + self.refresh_timer = QTimer() + self.refresh_timer.timeout.connect(self._refresh_signals) + self.refresh_timer.start(5000) # Refresh every 5 seconds diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py index 5bc864c..1bd9620 100644 --- a/ui/dashboard_tab.py +++ b/ui/dashboard_tab.py @@ -36,6 +36,10 @@ class DashboardTab(QWidget): # Signal to request FRED fetch fetch_rates_requested = pyqtSignal() + # Signal emitted when new signal generated (for Layer 2 confluence) + # Emits: strongest, weakest, gap, directional_bias_matrix + signal_generated = pyqtSignal(str, str, float, dict) + def __init__(self, db: Database): """ Initialize Dashboard tab. @@ -152,6 +156,17 @@ class DashboardTab(QWidget): signal_text = signal_data["signal"] gap = signal_data["gap"] status = signal_data["status"] + strongest = signal_data.get("strongest") + weakest = signal_data.get("weakest") + + # Build directional bias matrix and emit to confluence tab + if strongest and weakest: + scores = self.db.get_month_scores(self.current_month) + if scores: + bias_matrix = scorer.build_directional_bias_matrix(scores) + else: + bias_matrix = {} + self.signal_generated.emit(strongest, weakest, gap, bias_matrix) # Update signal label self.signal_label.setText(signal_text) diff --git a/ui/layer2_monitor_tab.py b/ui/layer2_monitor_tab.py new file mode 100644 index 0000000..f4f5759 --- /dev/null +++ b/ui/layer2_monitor_tab.py @@ -0,0 +1,477 @@ +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, + QPushButton, QComboBox, QCheckBox, QMessageBox, QStatusBar, QProgressBar, + QHeaderView +) +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer +from PyQt5.QtGui import QColor, QFont, QBrush +from typing import Dict, Optional +from datetime import datetime, timezone +import config +from layer2_technical import TechnicalAnalyzer +from data_feeder import Mt5DataFeeder, MockDataFeeder +from currency_strength_matrix import CurrencyStrengthMatrix + + +class DataStreamerThread(QThread): + """Background thread for data source polling.""" + + price_updated = pyqtSignal(dict) + error_occurred = pyqtSignal(str) + connected = pyqtSignal(bool) + + def __init__(self, data_feeder, instruments): + super().__init__() + self.feeder = data_feeder + self.instruments = instruments + self.running = True + + def run(self): + try: + if not self.feeder.test_connection(): + self.connected.emit(False) + self.error_occurred.emit("Failed to connect to data source") + return + + self.connected.emit(True) + self.feeder.stream_prices(self.instruments, callback=self._on_price) + + except Exception as e: + self.error_occurred.emit(str(e)) + self.connected.emit(False) + + def _on_price(self, price_data): + self.price_updated.emit(price_data) + + def stop(self): + self.running = False + if hasattr(self.feeder, 'stop_streaming'): + self.feeder.stop_streaming() + + +class Layer2MonitorTab(QWidget): + """Layer 2 technical analysis monitoring tab. + + Task 4.1 — Dynamic Session Visualizations: + - Active market session indicator (Tokyo / London / New York) + - High-contrast conditional formatting for ±2σ currency strength cells + """ + + def __init__(self, technical_analyzer: TechnicalAnalyzer = None): + super().__init__() + + self.tech_analyzer = technical_analyzer or TechnicalAnalyzer() + self.data_feeder = None + self.streamer_thread = None + self.connected = False + + # Persistent matrix to retain SessionTracker state across refreshes + self.matrix = CurrencyStrengthMatrix() + + self._init_ui() + self._setup_data_source() + self._refresh_display() + + def _init_ui(self): + """Build UI layout.""" + layout = QVBoxLayout() + + # ====== Connection Panel ====== + connection_layout = QHBoxLayout() + + connection_layout.addWidget(QLabel("Data Source:")) + self.source_combo = QComboBox() + self.source_combo.addItems(["Mock (Test)", "MT5 (Live)"]) + self.source_combo.setCurrentIndex(0) + connection_layout.addWidget(self.source_combo) + + self.connect_btn = QPushButton("Connect") + self.connect_btn.clicked.connect(self._on_connect_clicked) + connection_layout.addWidget(self.connect_btn) + + self.status_label = QLabel("Disconnected") + self.status_label.setStyleSheet("color: red; font-weight: bold;") + connection_layout.addWidget(self.status_label) + + connection_layout.addStretch() + layout.addLayout(connection_layout) + layout.addSpacing(10) + + # ====== Active Session Indicator (Task 4.1) ====== + session_layout = QHBoxLayout() + session_layout.addWidget(QLabel("Active Session:")) + self.session_label = QLabel("—") + self.session_label.setStyleSheet( + "font-weight: bold; font-size: 14px; padding: 2px 8px; " + "background-color: #ecf0f1; border-radius: 4px;" + ) + session_layout.addWidget(self.session_label) + session_layout.addStretch() + layout.addLayout(session_layout) + layout.addSpacing(5) + + # ====== Z-Score Table ====== + layout.addWidget(QLabel("Technical Analysis — All Pairs")) + + self.tech_table = QTableWidget() + self.tech_table.setColumnCount(7) + self.tech_table.setHorizontalHeaderLabels([ + "Pair", "Current Price", "Z-Score", "Volatility", "Mean Price", "Status", "Signal" + ]) + self.tech_table.setRowCount(28) + self.tech_table.setAlternatingRowColors(True) + self.tech_table.horizontalHeader().setStretchLastSection(True) + + layout.addWidget(self.tech_table) + layout.addSpacing(10) + + # ====== Alerts Panel ====== + alerts_layout = QHBoxLayout() + + alerts_layout.addWidget(QLabel("Overbought Pairs:")) + self.overbought_label = QLabel("—") + self.overbought_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + alerts_layout.addWidget(self.overbought_label) + + alerts_layout.addSpacing(20) + + alerts_layout.addWidget(QLabel("Oversold Pairs:")) + self.oversold_label = QLabel("—") + self.oversold_label.setStyleSheet("color: #27ae60; font-weight: bold;") + alerts_layout.addWidget(self.oversold_label) + + alerts_layout.addStretch() + layout.addLayout(alerts_layout) + layout.addSpacing(10) + + # ====== Currency Strength Matrix ====== + matrix_group = QWidget() + matrix_layout = QVBoxLayout(matrix_group) + matrix_layout.setContentsMargins(0, 0, 0, 0) + + matrix_layout.addWidget(QLabel("Currency Strength Matrix (S.A.T.O.R.I.)")) + self.matrix_cross_label = QLabel("Matrix Cross: —") + self.matrix_cross_label.setStyleSheet("font-weight: bold; font-size: 13px; color: #2c3e50;") + matrix_layout.addWidget(self.matrix_cross_label) + + self.divergence_label = QLabel("Divergence Gap: 0.0") + self.divergence_label.setStyleSheet("color: #7f8c8d;") + matrix_layout.addWidget(self.divergence_label) + + self.strong_alert = QLabel("") + matrix_layout.addWidget(self.strong_alert) + self.weak_alert = QLabel("") + matrix_layout.addWidget(self.weak_alert) + + self.matrix_table = QTableWidget() + self.matrix_table.setColumnCount(5) + self.matrix_table.setHorizontalHeaderLabels([ + "Rank", "Currency", "Strength Z", "Direction", "Session SRV" + ]) + self.matrix_table.setRowCount(8) + self.matrix_table.setMaximumHeight(240) + self.matrix_table.horizontalHeader().setStretchLastSection(True) + matrix_layout.addWidget(self.matrix_table) + + layout.addWidget(matrix_group) + layout.addSpacing(10) + + # ====== Refresh Button ====== + button_layout = QHBoxLayout() + + self.auto_refresh_check = QCheckBox("Auto-refresh (every 1s)") + self.auto_refresh_check.setChecked(True) + button_layout.addWidget(self.auto_refresh_check) + + refresh_btn = QPushButton("Refresh Now") + refresh_btn.clicked.connect(self._refresh_display) + button_layout.addWidget(refresh_btn) + + button_layout.addStretch() + layout.addLayout(button_layout) + layout.addStretch() + + self.setLayout(layout) + + self.refresh_timer = QTimer() + self.refresh_timer.timeout.connect(self._refresh_display) + + def _setup_data_source(self): + """Initialize data source.""" + source = self.source_combo.currentText() + if "MT5" in source: + self.data_feeder = Mt5DataFeeder() + else: + self.data_feeder = MockDataFeeder() + + def _on_connect_clicked(self): + """Handle connect button click.""" + if self.connected: + self._disconnect() + else: + self._connect() + + def _seed_historical_bars(self): + """Seed the analyzer with 24h of historical M5 bar data for stable Z-scores.""" + if hasattr(self.data_feeder, 'generate_mock_bars'): + bars = self.data_feeder.generate_mock_bars(n_bars=config.BAR_LOOKBACK_BARS) + elif hasattr(self.data_feeder, 'fetch_historical_closes_all_pairs'): + bars = self.data_feeder.fetch_historical_closes_all_pairs( + days=1, interval="5min" + ) + else: + return + self.tech_analyzer.seed_bars(bars) + + def _connect(self): + """Connect to data source.""" + try: + if not self.data_feeder.test_connection(): + reason = getattr(self.data_feeder, 'last_error', 'Unknown error') + QMessageBox.warning(self, "Connection Error", f"Failed to connect to data source:\n{reason}") + return + + # Seed bar_history with 24h of M5 close prices so Z-scores are + # anchored to a meaningful multi-hour frame, not tick noise. + self._seed_historical_bars() + + instruments = self.data_feeder.get_all_major_pairs() + self.streamer_thread = DataStreamerThread(self.data_feeder, instruments) + self.streamer_thread.price_updated.connect(self._on_price_received) + self.streamer_thread.error_occurred.connect(self._on_streamer_error) + self.streamer_thread.connected.connect(self._on_connected) + self.streamer_thread.start() + + if self.auto_refresh_check.isChecked(): + self.refresh_timer.start(1000) + + self.connected = True + self.connect_btn.setText("Disconnect") + self.status_label.setText("Connected") + self.status_label.setStyleSheet("color: #27ae60; font-weight: bold;") + self._refresh_display() + + except Exception as e: + QMessageBox.critical(self, "Error", f"Connection failed: {e}") + + def _disconnect(self): + """Disconnect from data source.""" + if self.streamer_thread: + self.streamer_thread.stop() + self.streamer_thread.quit() + self.streamer_thread.wait() + + self.refresh_timer.stop() + + self.connected = False + self.connect_btn.setText("Connect") + self.status_label.setText("Disconnected") + self.status_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + + def _on_price_received(self, price_data): + """Handle price update from data feeder.""" + pair = price_data.get('pair') + mid_price = price_data.get('mid') + + if pair and mid_price: + self.tech_analyzer.add_price_data(pair, mid_price) + self._refresh_display() + + def _on_connected(self, is_connected): + """Handle connection status change.""" + if is_connected: + self.status_label.setText("Connected") + self.status_label.setStyleSheet("color: #27ae60; font-weight: bold;") + else: + self.status_label.setText("Disconnected") + self.status_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + + def _on_streamer_error(self, error_msg): + """Handle streamer error.""" + print(f"[Layer2] Streamer error: {error_msg}") + + def _refresh_display(self): + """Refresh the technical analysis display.""" + try: + z_scores = self.tech_analyzer.get_all_z_scores() + overbought = self.tech_analyzer.get_overbought_pairs() + oversold = self.tech_analyzer.get_oversold_pairs() + + for row, (pair, z_score) in enumerate(sorted(z_scores.items())): + if row >= self.tech_table.rowCount(): + break + + status = self.tech_analyzer.get_status_for_pair(pair) + + pair_item = QTableWidgetItem(pair) + pair_item.setFlags(pair_item.flags() & ~Qt.ItemIsEditable) + self.tech_table.setItem(row, 0, pair_item) + + last_price = self.tech_analyzer.get_last_price(pair) + price_text = f"{last_price:.4f}" if last_price else "—" + price_item = QTableWidgetItem(price_text) + price_item.setFlags(price_item.flags() & ~Qt.ItemIsEditable) + self.tech_table.setItem(row, 1, price_item) + + z_item = QTableWidgetItem(f"{z_score:.2f}") + z_item.setFlags(z_item.flags() & ~Qt.ItemIsEditable) + z_item.setTextAlignment(Qt.AlignCenter) + + if abs(z_score) >= config.Z_SCORE_THRESHOLD: + z_item.setBackground(QColor("#ffebee")) + z_item.setForeground(QColor("#c62828")) + + self.tech_table.setItem(row, 2, z_item) + + vol_item = QTableWidgetItem(f"{status['volatility']:.4f}") + vol_item.setFlags(vol_item.flags() & ~Qt.ItemIsEditable) + self.tech_table.setItem(row, 3, vol_item) + + mean_item = QTableWidgetItem(f"{status['mean_price']:.4f}") + mean_item.setFlags(mean_item.flags() & ~Qt.ItemIsEditable) + self.tech_table.setItem(row, 4, mean_item) + + status_item = QTableWidgetItem(status['status']) + status_item.setFlags(status_item.flags() & ~Qt.ItemIsEditable) + + if "OVERBOUGHT" in status['status']: + status_item.setBackground(QColor("#ffebee")) + elif "OVERSOLD" in status['status']: + status_item.setBackground(QColor("#e8f5e9")) + + self.tech_table.setItem(row, 5, status_item) + + if status['is_extreme']: + signal = "EXTREME" + signal_item = QTableWidgetItem(signal) + signal_item.setBackground(QColor("#fff3e0")) + else: + signal = "Normal" + signal_item = QTableWidgetItem(signal) + + signal_item.setFlags(signal_item.flags() & ~Qt.ItemIsEditable) + self.tech_table.setItem(row, 6, signal_item) + + overbought_text = ", ".join(overbought) if overbought else "None" + oversold_text = ", ".join(oversold) if oversold else "None" + + self.overbought_label.setText(overbought_text) + self.oversold_label.setText(oversold_text) + + self.tech_table.resizeColumnsToContents() + + # ====== Currency Strength Matrix (persistent instance) ====== + current_prices = {} + for pair in z_scores: + lp = self.tech_analyzer.get_last_price(pair) + if lp is not None: + current_prices[pair] = lp + self.matrix.update(z_scores, current_prices=current_prices) + report = self.matrix.get_report() + + matrix_cross = report["matrix_cross"] + gap = report["divergence_gap"] + self.matrix_cross_label.setText( + f"Matrix Cross: {matrix_cross or '—'} | Spread: {gap:.2f}σ" + ) + + if report["has_divergence"]: + self.matrix_cross_label.setStyleSheet( + "font-weight: bold; font-size: 13px; color: #e74c3c;" + ) + self.divergence_label.setText( + "DIVERGENCE DETECTED — extreme strength vs extreme weakness" + ) + self.divergence_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + else: + self.matrix_cross_label.setStyleSheet( + "font-weight: bold; font-size: 13px; color: #2c3e50;" + ) + self.divergence_label.setText("No extreme divergence") + self.divergence_label.setStyleSheet("color: #7f8c8d;") + + ob_currencies = report["overbought"] + os_currencies = report["oversold"] + self.strong_alert.setText( + f"Overbought Currencies: {', '.join(ob_currencies) if ob_currencies else 'None'}" + ) + self.weak_alert.setText( + f"Oversold Currencies: {', '.join(os_currencies) if os_currencies else 'None'}" + ) + + # ====== Active Session Indicator (Task 4.1) ====== + active_session = report.get("active_session", "—") + session_colors = { + "Tokyo": "#8e44ad", + "London": "#2980b9", + "New York": "#e67e22", + "Off-Hours": "#7f8c8d", + } + session_color = session_colors.get(active_session, "#7f8c8d") + self.session_label.setText(active_session) + self.session_label.setStyleSheet( + f"font-weight: bold; font-size: 14px; padding: 2px 8px; " + f"color: white; background-color: {session_color}; " + f"border-radius: 4px;" + ) + + # ====== Ranked Currency Table with High-Contrast σ (Task 4.1) ====== + ranked = report["ranked"] + for row, entry in enumerate(ranked): + ccy = entry[0] + z_val = entry[1] + direction = entry[2] + srv = entry[3] if len(entry) > 3 else 0.0 + + rank_item = QTableWidgetItem(str(row + 1)) + rank_item.setFlags(rank_item.flags() & ~Qt.ItemIsEditable) + rank_item.setTextAlignment(Qt.AlignCenter) + self.matrix_table.setItem(row, 0, rank_item) + + ccy_item = QTableWidgetItem(ccy) + ccy_item.setFlags(ccy_item.flags() & ~Qt.ItemIsEditable) + self.matrix_table.setItem(row, 1, ccy_item) + + z_item = QTableWidgetItem(f"{z_val:.2f}") + z_item.setFlags(z_item.flags() & ~Qt.ItemIsEditable) + z_item.setTextAlignment(Qt.AlignCenter) + + # High-contrast σ formatting (Task 4.1) + threshold = config.Z_SCORE_THRESHOLD + if z_val >= threshold: + z_item.setBackground(QColor("#c62828")) + z_item.setForeground(QColor("white")) + elif z_val <= -threshold: + z_item.setBackground(QColor("#2e7d32")) + z_item.setForeground(QColor("white")) + + self.matrix_table.setItem(row, 2, z_item) + + dir_item = QTableWidgetItem(direction) + dir_item.setFlags(dir_item.flags() & ~Qt.ItemIsEditable) + if direction == "OVERBOUGHT": + dir_item.setBackground(QColor("#ffebee")) + dir_item.setForeground(QColor("#c62828")) + elif direction == "OVERSOLD": + dir_item.setBackground(QColor("#e8f5e9")) + dir_item.setForeground(QColor("#2e7d32")) + self.matrix_table.setItem(row, 3, dir_item) + + # Session Relative Velocity column + srv_sign = "+" if srv >= 0 else "" + srv_item = QTableWidgetItem(f"{srv_sign}{srv:.4f}%") + srv_item.setFlags(srv_item.flags() & ~Qt.ItemIsEditable) + srv_item.setTextAlignment(Qt.AlignCenter) + if abs(srv) > 0.5: + srv_item.setBackground(QColor("#fff3e0")) + self.matrix_table.setItem(row, 4, srv_item) + + self.matrix_table.resizeColumnsToContents() + + except Exception as e: + print(f"[Layer2] Display error: {e}") + + def closeEvent(self, event): + """Clean up on close.""" + self._disconnect() + event.accept() diff --git a/ui/settings_tab.py b/ui/settings_tab.py index 454d2ea..77ea3bb 100644 --- a/ui/settings_tab.py +++ b/ui/settings_tab.py @@ -20,6 +20,7 @@ from PyQt5.QtCore import Qt, pyqtSignal, QThread from PyQt5.QtGui import QFont from typing import Dict, Optional import config +from data_feeder import Mt5DataFeeder from fred_client import FredClient import os from pathlib import Path @@ -48,6 +49,31 @@ class FredTestWorker(QThread): self.test_complete.emit(False, f"✗ Connection failed: {str(e)}") +class Mt5TestWorker(QThread): + """Background thread for testing MT5 connection.""" + + test_complete = pyqtSignal(bool, str) + + def __init__(self, symbol_suffix: str): + super().__init__() + self.symbol_suffix = symbol_suffix + + def run(self): + try: + feeder = Mt5DataFeeder(symbol_suffix=self.symbol_suffix) + if feeder.initialize(): + price = feeder.get_current_price("EUR_USD") + if price: + self.test_complete.emit(True, f"✓ Connected! EUR/USD bid={price['bid']:.5f} ask={price['ask']:.5f}") + else: + self.test_complete.emit(True, "✓ Connected! (no EUR/USD tick data)") + feeder.shutdown() + else: + self.test_complete.emit(False, f"✗ {feeder.last_error}") + except Exception as e: + self.test_complete.emit(False, f"✗ {e}") + + class SettingsTab(QWidget): """Settings and configuration tab.""" @@ -57,7 +83,7 @@ class SettingsTab(QWidget): def __init__(self): """Initialize Settings tab.""" super().__init__() - self.env_path = Path(__file__).parent.parent.parent / ".env" + self.env_path = Path(__file__).parent.parent / ".env" self._init_ui() self._load_settings() @@ -100,6 +126,32 @@ class SettingsTab(QWidget): layout.addWidget(api_group) layout.addSpacing(10) + # ====== MetaTrader 5 Connection ====== + mt5_group = QGroupBox("MetaTrader 5 (Layer 2 Forex Data)") + mt5_layout = QVBoxLayout() + mt5_layout.addWidget(QLabel( + "MT5 provides real-time forex data from your local MetaTrader 5 terminal.\n" + "Ensure MT5 is installed and running with a demo/live account.\n" + "Symbol suffix is used by some brokers (e.g., .m for OANDA MT5)." + )) + suffix_layout = QHBoxLayout() + suffix_layout.addWidget(QLabel("Symbol Suffix:")) + self.mt5_suffix_input = QLineEdit() + self.mt5_suffix_input.setPlaceholderText("e.g., .m (leave empty if unsure)") + suffix_layout.addWidget(self.mt5_suffix_input) + mt5_layout.addLayout(suffix_layout) + mt5_status_layout = QHBoxLayout() + self.mt5_status_label = QLabel("Status: Not tested") + self.mt5_status_label.setStyleSheet("color: #95a5a6; font-style: italic;") + mt5_status_layout.addWidget(self.mt5_status_label) + mt5_test_btn = QPushButton("Test Connection") + mt5_test_btn.clicked.connect(self._test_mt5_connection) + mt5_status_layout.addWidget(mt5_test_btn) + mt5_layout.addLayout(mt5_status_layout) + mt5_group.setLayout(mt5_layout) + layout.addWidget(mt5_group) + layout.addSpacing(10) + # ====== Central Bank Targets ====== cb_group = QGroupBox("Central Bank Inflation Targets (%)") cb_layout = QVBoxLayout() @@ -267,6 +319,9 @@ class SettingsTab(QWidget): api_key = env_vars.get('FRED_API_KEY', '') self.api_key_input.setText(api_key) + mt5_suffix = env_vars.get('MT5_SYMBOL_SUFFIX', '') + self.mt5_suffix_input.setText(mt5_suffix) + # Load weights (convert from decimal to percentage) weight_rate = float(env_vars.get('WEIGHT_RATE', config.WEIGHT_RATE)) * 100 weight_cpi = float(env_vars.get('WEIGHT_CPI', config.WEIGHT_CPI)) * 100 @@ -332,7 +387,24 @@ class SettingsTab(QWidget): self.test_status.setStyleSheet("color: #27ae60; font-weight: bold;") else: self.test_status.setStyleSheet("color: #e74c3c; font-weight: bold;") - + + def _test_mt5_connection(self): + """Test MT5 connection in background.""" + suffix = self.mt5_suffix_input.text().strip() + self.mt5_status_label.setText("Testing connection...") + self.mt5_status_label.setStyleSheet("color: #95a5a6; font-style: italic;") + self.mt5_test_worker = Mt5TestWorker(suffix) + self.mt5_test_worker.test_complete.connect(self._on_mt5_test_complete) + self.mt5_test_worker.start() + + def _on_mt5_test_complete(self, success: bool, message: str): + """Handle MT5 test completion.""" + self.mt5_status_label.setText(message) + if success: + self.mt5_status_label.setStyleSheet("color: #27ae60; font-weight: bold;") + else: + self.mt5_status_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + def _save_settings(self): """Save settings to .env file.""" try: @@ -351,6 +423,7 @@ class SettingsTab(QWidget): # Prepare new .env content api_key = self.api_key_input.text().strip() + mt5_suffix = self.mt5_suffix_input.text().strip() weight_rate = self.weight_rate_spin.value() / 100 weight_cpi = self.weight_cpi_spin.value() / 100 weight_pmi = self.weight_pmi_spin.value() / 100 @@ -358,6 +431,7 @@ class SettingsTab(QWidget): auto_fetch = "true" if self.auto_fetch_check.isChecked() else "false" env_content = f"""FRED_API_KEY={api_key} +MT5_SYMBOL_SUFFIX={mt5_suffix} DB_PATH=apex.db MIN_GAP={min_gap} WEIGHT_RATE={weight_rate:.2f} @@ -392,6 +466,8 @@ DEBUG=false ) if reply == QMessageBox.Yes: + self.api_key_input.clear() + self.mt5_suffix_input.clear() self.weight_rate_spin.setValue(50) self.weight_cpi_spin.setValue(30) self.weight_pmi_spin.setValue(20)