layer one v3

This commit is contained in:
saber
2026-06-17 11:26:57 +01:00
parent 6bfcc67088
commit e28f2db97c
22 changed files with 4124 additions and 271 deletions
+55 -28
View File
@@ -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
-1
View File
@@ -83,6 +83,5 @@ example_*.csv
~$*
# OS specific
Thumbs.db
.DS_Store
.Thumbs.db
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+89 -41
View File
@@ -19,7 +19,7 @@ from dotenv import load_dotenv
from pathlib import Path
# Load .env file from project root
env_path = Path(__file__).parent.parent / ".env"
env_path = Path(__file__).parent / ".env"
load_dotenv(dotenv_path=env_path)
# ============================================================================
@@ -63,12 +63,12 @@ CB_TARGETS = {
FRED_SERIES = {
"USD": "FEDFUNDS", # US Federal Funds Rate
"EUR": "ECBDFR", # ECB Deposit Rate
"GBP": "BOEBR", # Bank of England Base Rate
"JPY": "IRSTJPN", # Japan Policy Rate (or manual from BOJ website)
"AUD": "RBATCTR", # RBA Cash Target Rate
"CAD": "BOCCRT", # BOC Policy Interest Rate
"CHF": "SNBPOL", # SNB Policy Rate
"NZD": "RBNZOCR", # RBNZ Official Cash Rate
"GBP": "BOEIR", # Bank of England Interest Rate
"JPY": "IRSTCI01JPM156N", # Japan Short-Term Interest Rate
"AUD": "RBATR", # RBA Target Cash Rate
"CAD": "BOCARR", # Bank of Canada Overnight Rate
"CHF": "SNBON", # SNB Policy Rate
"NZD": "RBNZR", # RBNZ Official Cash Rate
}
# ============================================================================
@@ -106,45 +106,15 @@ GAP_THRESHOLDS = {
AUTO_FETCH_RATES_ON_STARTUP = os.getenv("AUTO_FETCH_RATES_ON_STARTUP", "true").lower() == "true"
FRED_FETCH_TIMEOUT = 10 # seconds
# ============================================================================
# UI Settings
# ============================================================================
APP_TITLE = "APEX Layer 1 — Currency Strength Engine"
WINDOW_WIDTH = 1200
WINDOW_HEIGHT = 800
TAB_NAMES = {
"dashboard": "Dashboard",
"entry": "Monthly Entry",
"history": "History",
"settings": "Settings",
}
# Currency display format (with flags for nice UI)
CURRENCY_EMOJIS = {
"USD": "🇺🇸",
"EUR": "🇪🇺",
"GBP": "🇬🇧",
"JPY": "🇯🇵",
"AUD": "🇦🇺",
"CAD": "🇨🇦",
"CHF": "🇨🇭",
"NZD": "🇳🇿",
}
# ============================================================================
# Data Validation Rules
# ============================================================================
# For CPI entry
CPI_MIN = -10.0 # Reasonable lower bound for inflation
CPI_MAX = 50.0 # Reasonable upper bound (hyperinflation)
# For PMI entry
PMI_MIN = 0.0 # PMI is 0-100
PMI_MAX = 100.0
# For interest rates
RATE_MIN = -5.0 # Some CBs have negative rates
RATE_MAX = 20.0 # Reasonable upper bound
CPI_MIN = float(os.getenv("CPI_MIN", -5.0))
CPI_MAX = float(os.getenv("CPI_MAX", 10.0))
PMI_MIN = float(os.getenv("PMI_MIN", 0.0))
PMI_MAX = float(os.getenv("PMI_MAX", 100.0))
# ============================================================================
# Database Settings
@@ -188,12 +158,90 @@ def validate_config():
# ============================================================================
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
# ============================================================================
# UI Configuration
# ============================================================================
APP_TITLE = "APEX — Currency Strength Engine"
WINDOW_WIDTH = 1400
WINDOW_HEIGHT = 850
TAB_NAMES = {
"dashboard": "📊 Dashboard",
"entry": "📝 Data Entry",
"layer2": "📈 Layer 2 (Technical)",
"confluence": "🎯 Confluence Signals",
"history": "📜 History",
"settings": "⚙️ Settings"
}
# Currency emojis for UI
CURRENCY_EMOJIS = {
"USD": "🇺🇸",
"EUR": "🇪🇺",
"GBP": "🇬🇧",
"JPY": "🇯🇵",
"AUD": "🇦🇺",
"CAD": "🇨🇦",
"CHF": "🇨🇭",
"NZD": "🇳🇿",
}
# ============================================================================
# LAYER 2 — Technical Analysis Configuration
# ============================================================================
# MetaTrader 5 (local terminal, no API key needed)
# Symbol suffix varies by broker (e.g., .m for OANDA MT5)
MT5_SYMBOL_SUFFIX = os.getenv("MT5_SYMBOL_SUFFIX", "")
# Technical Analysis Settings
Z_SCORE_THRESHOLD = float(os.getenv("Z_SCORE_THRESHOLD", 2.0)) # Overbought/oversold level
Z_SCORE_LOOKBACK = int(os.getenv("Z_SCORE_LOOKBACK", 288)) # Bars for Z-score calculation (288 M5 bars = 24 hours)
# Historical Bar Configuration (Task 1.1 — Multi-hour anchored lookback)
BAR_TIMEFRAME = os.getenv("BAR_TIMEFRAME", "M5") # M1 or M5 bar intervals
BAR_LOOKBACK_HOURS = int(os.getenv("BAR_LOOKBACK_HOURS", 48)) # Hours of historical data
BAR_LOOKBACK_BARS = int(os.getenv("BAR_LOOKBACK_BARS", 288)) # Total bars (288 M5 bars = 24h)
HISTORICAL_POLL_INTERVAL = int(os.getenv("HISTORICAL_POLL_INTERVAL", 300)) # 5 min in seconds
# Session Detection
SESSION_TOKYO_OPEN = 0 # 00:00 UTC
SESSION_TOKYO_CLOSE = 8 # 08:00 UTC
SESSION_LONDON_OPEN = 7 # 07:00 UTC
SESSION_LONDON_CLOSE = 16 # 16:00 UTC
SESSION_NEWYORK_OPEN = 13 # 13:00 UTC
SESSION_NEWYORK_CLOSE = 21# 21:00 UTC
# Confluence Settings
CONFLUENCE_ENABLED = os.getenv("CONFLUENCE_ENABLED", "true").lower() == "true"
MIN_CONFLUENCE_STRENGTH = float(os.getenv("MIN_CONFLUENCE_STRENGTH", 60.0)) # 60% confidence threshold
# Risk Management
ACCOUNT_BALANCE = float(os.getenv("ACCOUNT_BALANCE", 10000.0)) # Starting balance
RISK_PER_TRADE = float(os.getenv("RISK_PER_TRADE", 0.01)) # 1% per trade
MAX_PORTFOLIO_LEVERAGE = float(os.getenv("MAX_PORTFOLIO_LEVERAGE", 2.0)) # Max 2:1 leverage
USE_GRID_HEDGING = os.getenv("USE_GRID_HEDGING", "true").lower() == "true"
GRID_LEVELS = int(os.getenv("GRID_LEVELS", 3)) # Number of hedging levels
# ============================================================================
# Mock Data Feeder Configuration (previously magic numbers)
# ============================================================================
MOCK_DRIFT = float(os.getenv("MOCK_DRIFT", 0.0001))
MOCK_THETA = float(os.getenv("MOCK_THETA", 0.02))
MOCK_NOISE_STD = float(os.getenv("MOCK_NOISE_STD", 0.0008))
MOCK_SEASONAL_AMP = float(os.getenv("MOCK_SEASONAL_AMP", 0.0003))
MOCK_TICK_NOISE = float(os.getenv("MOCK_TICK_NOISE", 0.0002))
MOCK_BID_ASK_SPREAD = float(os.getenv("MOCK_BID_ASK_SPREAD", 0.0001))
MOCK_HISTORICAL_DAILY_NOISE = float(os.getenv("MOCK_HISTORICAL_DAILY_NOISE", 0.01))
if DEBUG:
print("[CONFIG] Debug mode enabled")
print(f"[CONFIG] FRED API Key: {FRED_API_KEY[:10]}..." if FRED_API_KEY else "[CONFIG] FRED API Key: NOT SET")
print(f"[CONFIG] MT5 symbol suffix: '{MT5_SYMBOL_SUFFIX}'")
print(f"[CONFIG] Database: {DB_PATH}")
print(f"[CONFIG] Weights: Rate={WEIGHT_RATE}, CPI={WEIGHT_CPI}, PMI={WEIGHT_PMI}")
print(f"[CONFIG] Min gap to trade: {MIN_GAP_TO_TRADE}")
print(f"[CONFIG] Z-score threshold: {Z_SCORE_THRESHOLD}")
print(f"[CONFIG] Confluence enabled: {CONFLUENCE_ENABLED}")
# Call validation on import (fail early if config is broken)
+333
View File
@@ -0,0 +1,333 @@
from typing import Dict, Optional, Tuple
from datetime import datetime, timezone
import config
from layer2_technical import TechnicalAnalyzer, TechnicalSignal
from currency_strength_matrix import CurrencyStrengthMatrix
class ConfluenceFilter:
"""Merges Layer 1 + Layer 2 with macro directional boundary enforcement.
Layer 1 produces a permanent monthly directional bias matrix:
STRONG (top 2) — can only be longed, never shorted
WEAK (bottom 2) — can only be shorted, never longed
NEUTRAL (middle 4) — no restriction
Layer 2 operates freely — any short-term divergence can generate an entry,
provided it does not CROSS or VIOLATE the Layer 1 macro boundary.
"""
def __init__(self, technical_analyzer: TechnicalAnalyzer):
self.tech_analyzer = technical_analyzer
self.tech_signal = TechnicalSignal(technical_analyzer)
# Layer 1 directional bias matrix (set monthly from fundamental scores)
self.bias_matrix: Dict[str, Dict] = {}
self.layer1_strongest = None
self.layer1_weakest = None
self.layer1_gap = 0.0
self.layer1_timestamp = None
self.layer1_is_active = False
self.last_confluence_check = None
self.confluence_strength = 0.0
self.matrix: Optional[CurrencyStrengthMatrix] = None
self.matrix_cross_pair = None
def _build_matrix(self, current_prices: Dict[str, float] = None):
z_scores = self.tech_analyzer.get_all_z_scores()
if self.matrix is None:
self.matrix = CurrencyStrengthMatrix()
self.matrix.update(z_scores, current_prices=current_prices)
self.matrix_cross_pair = self.matrix.get_matrix_cross()
def set_layer1_bias(
self,
strongest: str,
weakest: str,
gap: float,
bias_matrix: dict = None,
timestamp: datetime = None
):
"""Set the monthly directional bias matrix from Layer 1 fundamental scores.
Args:
strongest: Top-ranked currency from fundamental scoring
weakest: Bottom-ranked currency
gap: Score spread between strongest and weakest
bias_matrix: Dict of {currency: {direction, score, rank}} —
the permanent macro boundary for the month
"""
self.bias_matrix = bias_matrix or {}
self.layer1_strongest = strongest
self.layer1_weakest = weakest
self.layer1_gap = gap
self.layer1_timestamp = timestamp or datetime.now()
self.layer1_is_active = gap >= 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 = []
+1 -2
View File
@@ -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
+205
View File
@@ -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:0008:00
London: 07:0016:00
New York: 13:0022: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(),
}
+569
View File
@@ -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
+262 -170
View File
@@ -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}")
+2 -2
View File
@@ -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)}"
+227
View File
@@ -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)
+8 -6
View File
@@ -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
+60 -17
View File
@@ -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()
+878
View File
@@ -0,0 +1,878 @@
# APEX — Currency Strength Engine
> **Version:** 1.1.0
> **Timeframe:** Swing / Position Trading (15 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.015.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`)
+1
View File
@@ -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
+466
View File
@@ -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,
}
+36 -2
View File
@@ -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"
+362
View File
@@ -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
+15
View File
@@ -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)
+477
View File
@@ -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()
+78 -2
View File
@@ -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)