mirror of
https://github.com/Sabermrddz/QuantCore-FX.git
synced 2026-07-27 18:47:51 +00:00
layer one v3
This commit is contained in:
+55
-28
@@ -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
|
||||
|
||||
@@ -83,6 +83,5 @@ example_*.csv
|
||||
~$*
|
||||
|
||||
# OS specific
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
.Thumbs.db
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import statistics
|
||||
import config
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrencyStrength:
|
||||
name: str
|
||||
avg_z_score: float
|
||||
rank: int
|
||||
is_overbought: bool
|
||||
is_oversold: bool
|
||||
direction: str
|
||||
session_srv: float = 0.0 # Session Relative Velocity (Task 1.2)
|
||||
|
||||
|
||||
class SessionTracker:
|
||||
"""Detects the active trading session and tracks session-start snapshots.
|
||||
|
||||
Sessions (UTC):
|
||||
Tokyo: 00:00–08:00
|
||||
London: 07:00–16:00
|
||||
New York: 13:00–22:00
|
||||
Overlapping hours are resolved as London (the dominant session).
|
||||
"""
|
||||
|
||||
TOKYO = "Tokyo"
|
||||
LONDON = "London"
|
||||
NEWYORK = "New York"
|
||||
|
||||
def __init__(self):
|
||||
self.current_session: Optional[str] = None
|
||||
self.session_start_prices: Dict[str, float] = {} # pair -> price at session open
|
||||
self.session_open_time: Optional[datetime] = None
|
||||
|
||||
def get_active_session(self, utc_hour: int = None) -> str:
|
||||
if utc_hour is None:
|
||||
utc_hour = datetime.now(timezone.utc).hour
|
||||
if config.SESSION_LONDON_OPEN <= utc_hour < config.SESSION_LONDON_CLOSE:
|
||||
return self.LONDON
|
||||
if config.SESSION_TOKYO_OPEN <= utc_hour < config.SESSION_TOKYO_CLOSE:
|
||||
return self.TOKYO
|
||||
if config.SESSION_NEWYORK_OPEN <= utc_hour < config.SESSION_NEWYORK_CLOSE:
|
||||
return self.NEWYORK
|
||||
return "Off-Hours"
|
||||
|
||||
def check_new_session(self, current_prices: Dict[str, float]) -> Optional[str]:
|
||||
"""Detect if a new session has started and snapshot prices."""
|
||||
now = datetime.now(timezone.utc)
|
||||
session = self.get_active_session(now.hour)
|
||||
if session != self.current_session and session != "Off-Hours":
|
||||
self.current_session = session
|
||||
self.session_start_prices = dict(current_prices)
|
||||
self.session_open_time = now
|
||||
return session
|
||||
if self.current_session is None:
|
||||
self.current_session = session
|
||||
if session != "Off-Hours":
|
||||
self.session_start_prices = dict(current_prices)
|
||||
self.session_open_time = now
|
||||
return None
|
||||
|
||||
def compute_srv(self, pair: str, current_price: float) -> float:
|
||||
"""Session Relative Velocity: % change from session open to now."""
|
||||
start = self.session_start_prices.get(pair)
|
||||
if start is None or start == 0:
|
||||
return 0.0
|
||||
return ((current_price - start) / start) * 100.0
|
||||
|
||||
|
||||
class CurrencyStrengthMatrix:
|
||||
"""Computes individual currency strength indices from pair Z-scores.
|
||||
|
||||
Includes Session-Based Indexing (Task 1.2):
|
||||
- Tracks performance since Tokyo/London/NY session opens
|
||||
- Session Relative Velocity (SRV) per currency
|
||||
"""
|
||||
|
||||
def __init__(self, z_scores: Dict[str, float] = None):
|
||||
self.currencies = config.CURRENCIES
|
||||
self.threshold = config.Z_SCORE_THRESHOLD
|
||||
self._raw_scores: Dict[str, List[float]] = {}
|
||||
self._strengths: Dict[str, CurrencyStrength] = {}
|
||||
self.session_tracker = SessionTracker()
|
||||
self._srv_map: Dict[str, float] = {} # currency -> avg SRV
|
||||
if z_scores:
|
||||
self.update(z_scores)
|
||||
|
||||
def update(self, z_scores: Dict[str, float], current_prices: Dict[str, float] = None):
|
||||
"""Recompute all currency strengths from 28 pair Z-scores.
|
||||
|
||||
If current_prices is provided, also updates session tracking
|
||||
and computes Session Relative Velocity.
|
||||
"""
|
||||
self._raw_scores = {}
|
||||
for ccy in self.currencies:
|
||||
scores = []
|
||||
for other in self.currencies:
|
||||
if other == ccy:
|
||||
continue
|
||||
pair = f"{ccy}_{other}"
|
||||
z = z_scores.get(pair)
|
||||
if z is not None:
|
||||
scores.append(z)
|
||||
self._raw_scores[ccy] = scores
|
||||
|
||||
strengths = {}
|
||||
for ccy, scores in self._raw_scores.items():
|
||||
avg_z = statistics.mean(scores) if scores else 0.0
|
||||
strengths[ccy] = CurrencyStrength(
|
||||
name=ccy,
|
||||
avg_z_score=avg_z,
|
||||
rank=0,
|
||||
is_overbought=avg_z >= self.threshold,
|
||||
is_oversold=avg_z <= -self.threshold,
|
||||
direction="OVERBOUGHT" if avg_z >= self.threshold else ("OVERSOLD" if avg_z <= -self.threshold else "NEUTRAL"),
|
||||
)
|
||||
|
||||
sorted_ccys = sorted(strengths.keys(), key=lambda c: strengths[c].avg_z_score, reverse=True)
|
||||
for rank, ccy in enumerate(sorted_ccys, 1):
|
||||
strengths[ccy].rank = rank
|
||||
|
||||
# Session tracking (Task 1.2)
|
||||
if current_prices:
|
||||
new_session = self.session_tracker.check_new_session(current_prices)
|
||||
self._compute_srv(current_prices, strengths)
|
||||
|
||||
self._strengths = strengths
|
||||
|
||||
def _compute_srv(self, current_prices: Dict[str, float],
|
||||
strengths: Dict[str, CurrencyStrength]):
|
||||
"""Compute average Session Relative Velocity per currency."""
|
||||
srv_scores: Dict[str, List[float]] = {c: [] for c in self.currencies}
|
||||
for ccy in self.currencies:
|
||||
for other in self.currencies:
|
||||
if other == ccy:
|
||||
continue
|
||||
pair = f"{ccy}_{other}"
|
||||
price = current_prices.get(pair)
|
||||
if price is not None:
|
||||
srv = self.session_tracker.compute_srv(pair, price)
|
||||
srv_scores[ccy].append(srv)
|
||||
for ccy in self.currencies:
|
||||
scores = srv_scores[ccy]
|
||||
self._srv_map[ccy] = statistics.mean(scores) if scores else 0.0
|
||||
if ccy in strengths:
|
||||
strengths[ccy].session_srv = self._srv_map[ccy]
|
||||
|
||||
def get_strongest(self) -> Optional[CurrencyStrength]:
|
||||
return max(self._strengths.values(), key=lambda s: s.avg_z_score) if self._strengths else None
|
||||
|
||||
def get_weakest(self) -> Optional[CurrencyStrength]:
|
||||
return min(self._strengths.values(), key=lambda s: s.avg_z_score) if self._strengths else None
|
||||
|
||||
def get_matrix_cross(self) -> Optional[str]:
|
||||
s = self.get_strongest()
|
||||
w = self.get_weakest()
|
||||
if s and w and s.name != w.name:
|
||||
return f"{s.name}_{w.name}"
|
||||
return None
|
||||
|
||||
def get_divergence_gap(self) -> float:
|
||||
s = self.get_strongest()
|
||||
w = self.get_weakest()
|
||||
return (s.avg_z_score - w.avg_z_score) if s and w else 0.0
|
||||
|
||||
def has_divergence(self) -> bool:
|
||||
s = self.get_strongest()
|
||||
w = self.get_weakest()
|
||||
return bool(s and w and s.is_overbought and w.is_oversold)
|
||||
|
||||
def get_strong_currencies(self) -> List[str]:
|
||||
return [c.name for c in self._strengths.values() if c.is_overbought]
|
||||
|
||||
def get_weak_currencies(self) -> List[str]:
|
||||
return [c.name for c in self._strengths.values() if c.is_oversold]
|
||||
|
||||
def get_ranked_list(self) -> List[CurrencyStrength]:
|
||||
return sorted(self._strengths.values(), key=lambda s: s.rank)
|
||||
|
||||
def get_active_session(self) -> str:
|
||||
return self.session_tracker.get_active_session()
|
||||
|
||||
def get_srv_map(self) -> Dict[str, float]:
|
||||
return dict(self._srv_map)
|
||||
|
||||
def get_report(self) -> Dict:
|
||||
ranked = self.get_ranked_list()
|
||||
s = self.get_strongest()
|
||||
w = self.get_weakest()
|
||||
return {
|
||||
"ranked": [(c.name, round(c.avg_z_score, 2), c.direction, round(c.session_srv, 4)) for c in ranked],
|
||||
"strongest": s.name if s else None,
|
||||
"strongest_z": round(s.avg_z_score, 2) if s else 0,
|
||||
"weakest": w.name if w else None,
|
||||
"weakest_z": round(w.avg_z_score, 2) if w else 0,
|
||||
"matrix_cross": self.get_matrix_cross(),
|
||||
"divergence_gap": round(self.get_divergence_gap(), 2),
|
||||
"has_divergence": self.has_divergence(),
|
||||
"overbought": self.get_strong_currencies(),
|
||||
"oversold": self.get_weak_currencies(),
|
||||
"active_session": self.get_active_session(),
|
||||
}
|
||||
+569
@@ -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
@@ -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
@@ -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)}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
# APEX — Currency Strength Engine
|
||||
|
||||
> **Version:** 1.1.0
|
||||
> **Timeframe:** Swing / Position Trading (1–5 day holds)
|
||||
> **Architecture:** S.A.T.O.R.I. (Statistical Arbitrage Trading & Orchestrated Reversion Index)
|
||||
> **Layer:** Layer 1 (Fundamental) + Layer 2 (Technical/Statistical)
|
||||
> **Methodology:** Dr. Giavon's Deconstructed Currency Strength Indexing
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
APEX is a desktop-based **Currency Strength Engine** that implements institutional-quality **statistical arbitrage (StatArb)** for the forex market. It deconstructs all 28 major cross-pairs to isolate the true strength/weakness of individual currencies, then generates mean-reversion signals when statistical divergences reach extreme thresholds.
|
||||
|
||||
### Core Principle
|
||||
|
||||
Instead of analyzing EUR/USD as a single entity, APEX decomposes every pair to isolate individual currency strength indices:
|
||||
|
||||
```
|
||||
Individual Currency Strength = Average Z-Score Across ALL 7 Pairs Involving That Currency
|
||||
|
||||
EUR_Strength = avg(Z(EUR_USD), Z(EUR_GBP), Z(EUR_JPY), Z(EUR_AUD), Z(EUR_CAD), Z(EUR_CHF), Z(EUR_NZD))
|
||||
USD_Strength = avg(Z(USD_EUR), Z(USD_GBP), Z(USD_JPY), Z(USD_AUD), Z(USD_CAD), Z(USD_CHF), Z(USD_NZD))
|
||||
... and so on for all 8 currencies
|
||||
```
|
||||
|
||||
### Trading Philosophy
|
||||
|
||||
| Component | Strategy |
|
||||
|-----------|----------|
|
||||
| **Timeframe** | Swing / Position — 1 to 5 day holds |
|
||||
| **Entry Trigger** | Matrix Cross divergence: one currency overbought (Z > +2.0) across ALL pairs, another oversold (Z < -2.0) simultaneously |
|
||||
| **Execution** | Short the strongest, buy the weakest — bet on mathematical mean reversion |
|
||||
| **Risk Management** | No single-pair stop losses. Basket hedging across correlated pairs + grid hedging |
|
||||
| **Exit** | Aggregate portfolio P&L goes net positive (portfolio-based exit, not per-pair) |
|
||||
| **Z-Score Anchor** | 288 M5 bars = 24 hours of historical data (not tick noise) |
|
||||
| **Session Tracking** | Tracks Tokyo / London / New York opens with Session Relative Velocity |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ APEX APPLICATION │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ UI LAYER (6 Tabs) │ │
|
||||
│ │ ┌──────────┐ ┌────────┐ ┌──────────┐ ┌────────┐ ┌──────┐ │ │
|
||||
│ │ │Dashboard │ │Data │ │Layer 2 │ │Confluence│ │Hist.│ │ │
|
||||
│ │ │(Fundamen)│ │Entry │ │Monitor │ │Signals │ │ │ │ │
|
||||
│ │ └────┬─────┘ └────────┘ └────┬─────┘ └────┬────┘ └──────┘ │ │
|
||||
│ └───────┼───────────────────────┼─────────────┼────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌───────▼───────────────────────▼─────────────▼────────────────┐ │
|
||||
│ │ BUSINESS LOGIC LAYER │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ Scoring (L1) │ │Technical (L2)│ │ Matrix Engine │ │ │
|
||||
│ │ │ scorer.py │ │layer2_tech │ │ currency_strength│ │ │
|
||||
│ │ │ │ │ .py │ │ _matrix.py │ │ │
|
||||
│ │ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ ┌──────▼────────────────▼──────────────────▼──────────┐ │ │
|
||||
│ │ │ CONFLUENCE FILTER │ │ │
|
||||
│ │ │ confluence_filter.py │ │ │
|
||||
│ │ │ Layer 1 + Layer 2 + Matrix = Entry Signal │ │ │
|
||||
│ │ └──────────────────────┬──────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌──────────────────────▼──────────────────────────────┐ │ │
|
||||
│ │ │ RISK MANAGEMENT SYSTEM │ │ │
|
||||
│ │ │ risk_management.py │ │ │
|
||||
│ │ │ Position Sizing + Grid Hedge + Basket Hedge + │ │ │
|
||||
│ │ │ Portfolio P&L Tracking + Aggregate Exit │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ DATA LAYER │ │
|
||||
│ │ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ FRED API │ │ MT5 Data │ │ SQLite │ │ Excel Import │ │ │
|
||||
│ │ │(interest │ │(forex │ │ Database │ │ (CPI/PMI) │ │ │
|
||||
│ │ │ rates) │ │ prices) │ │ apex.db │ │ │ │ │
|
||||
│ │ └───────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### The Two Layers
|
||||
|
||||
| Layer | Input | Frequency | Output |
|
||||
|-------|-------|-----------|--------|
|
||||
| **Layer 1 (Fundamental)** | Interest rates (FRED), CPI, PMI (manual/Excel) | Monthly | Currency scores (0-100), Strongest/Weakest ranking |
|
||||
| **Layer 2 (Technical)** | 288 M5 bars (24h) + live poll prices | Bar-anchored, tick-displayed | 28 pair Z-scores (anchored to 24h μ/σ), 8 currency strength indices, Matrix Cross |
|
||||
|
||||
**Critical design:** Z-scores are NOT calculated over raw tick polls. On connect, the analyzer is seeded with 288 M5 bars of historical close prices. μ and σ are computed from this 24-hour window. Live tick prices are compared against this stable anchor, producing meaningful multi-hour deviation readings that don't flip on every tick.
|
||||
|
||||
**Fallback:** If bar data hasn't been seeded yet, the system falls back to a 20-tick deque for immediate display. Once `seed_bars()` is called, the bar anchor takes over permanently.
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Structure
|
||||
|
||||
```
|
||||
apex_layer1/
|
||||
│
|
||||
├── __init__.py # Package marker (v1.0.0)
|
||||
├── main.py # Application entry point
|
||||
├── main_window.py # QMainWindow + tab assembly
|
||||
├── config.py # All configuration & constants from .env
|
||||
│
|
||||
├── data_feeder.py # MT5 + Mock data feeders
|
||||
├── fred_client.py # FRED interest rate API client
|
||||
├── database.py # SQLite database manager
|
||||
│
|
||||
├── scorer.py # Layer 1 scoring engine
|
||||
├── layer2_technical.py # Layer 2 Z-score engine (28 pairs)
|
||||
├── currency_strength_matrix.py # S.A.T.O.R.I. currency strength index
|
||||
├── confluence_filter.py # Layer 1 + Layer 2 + Matrix merging
|
||||
├── risk_management.py # Position sizing, hedging, portfolio mgmt
|
||||
│
|
||||
├── create_excel_template.py # Excel/CSV template generator
|
||||
├── requirements.txt # Python dependencies
|
||||
│
|
||||
├── .env # Live configuration (API keys)
|
||||
├── .env.example # Configuration template
|
||||
├── apex.db # SQLite database (auto-created)
|
||||
│
|
||||
├── ui/
|
||||
│ ├── __init__.py
|
||||
│ ├── dashboard_tab.py # Tab 1: Layer 1 fundamental signals
|
||||
│ ├── entry_tab.py # Tab 2: CPI/PMI data entry
|
||||
│ ├── layer2_monitor_tab.py # Tab 3: Live Z-scores + Matrix
|
||||
│ ├── confluence_tab.py # Tab 4: Merged signals
|
||||
│ ├── history_tab.py # Tab 5: Past signals
|
||||
│ └── settings_tab.py # Tab 6: Configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. File-by-File Breakdown
|
||||
|
||||
### 4.1 Entry Point
|
||||
|
||||
#### `main.py`
|
||||
Launches the PyQt5 application. Validates FRED API key exists, creates `QApplication`, instantiates `MainWindow`, runs event loop.
|
||||
|
||||
- **`main()`** — Application entry point. Checks `config.FRED_API_KEY`, creates `QApplication`, shows `MainWindow`, starts event loop.
|
||||
|
||||
#### `__init__.py`
|
||||
Package marker. Exports `__version__ = "1.0.0"`.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Configuration
|
||||
|
||||
#### `config.py`
|
||||
Loads `.env` via `python-dotenv`. Defines ALL constants used across the application.
|
||||
|
||||
| Constant | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CURRENCIES` | `["USD","EUR","GBP","JPY","AUD","CAD","CHF","NZD"]` | The 8 major currencies |
|
||||
| `CB_TARGETS` | Per-currency dict | Central bank inflation targets (2.0% most, AUD=2.5, CHF=1.5) |
|
||||
| `FRED_SERIES` | Per-currency dict | FRED series IDs for interest rates |
|
||||
| `WEIGHT_RATE` | `0.50` | Interest rate weight in L1 scoring |
|
||||
| `WEIGHT_CPI` | `0.30` | CPI deviation weight in L1 scoring |
|
||||
| `WEIGHT_PMI` | `0.20` | PMI composite weight in L1 scoring |
|
||||
| `MIN_GAP_TO_TRADE` | `20` | Minimum score gap required for signal |
|
||||
| `Z_SCORE_THRESHOLD` | `2.0` | Overbought/oversold threshold (std devs) |
|
||||
| `Z_SCORE_LOOKBACK` | `20` | Bars for Z-score calculation |
|
||||
| `MT5_SYMBOL_SUFFIX` | `""` | Broker-specific MT5 suffix (e.g., `.m`) |
|
||||
| `ACCOUNT_BALANCE` | `10000` | Starting account balance |
|
||||
| `RISK_PER_TRADE` | `0.01` | 1% risk per trade |
|
||||
| `MAX_PORTFOLIO_LEVERAGE` | `2.0` | Max 2:1 leverage |
|
||||
| `GRID_LEVELS` | `3` | Hedge grid levels |
|
||||
| `USE_GRID_HEDGING` | `true` | Enable grid hedging |
|
||||
| `DEBUG` | `false` | Debug output toggle |
|
||||
|
||||
- **`validate_config()`** — Validates all config on import. Raises `ValueError` if FRED key or critical settings are missing.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Data Layer
|
||||
|
||||
#### `data_feeder.py`
|
||||
Two data feeder implementations with the **same interface** (polymorphic):
|
||||
|
||||
**Class `Mt5DataFeeder`** — Real data from MetaTrader 5 terminal.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `initialize()` | `bool` | Connect to MT5 terminal |
|
||||
| `test_connection()` | `bool` | Alias for initialize |
|
||||
| `shutdown()` | — | Disconnect MT5 |
|
||||
| `get_connection_status()` | `str` | Human-readable status |
|
||||
| `get_current_price(pair)` | `dict\|None` | Bid/ask/mid via `symbol_info_tick()` |
|
||||
| `fetch_all_rates()` | `dict` | Fetch 7 USD pairs, derive all 28 cross rates |
|
||||
| `get_all_major_pairs()` | `list[str]` | All 28 pairs (56 permutations) |
|
||||
| `get_exchange_rate(from, to)` | `float\|None` | Single cross rate |
|
||||
| `get_historical_candles(...)` | `list[dict]\|None` | OHLC bars via `copy_rates_from_pos()` |
|
||||
| `stream_prices(...)` | — | Threaded polling loop |
|
||||
| `stop_streaming()` | — | Stop the poll loop |
|
||||
|
||||
**Strategy:** Fetches only 7 major USD pairs (`EUR_USD`, `GBP_USD`, `AUD_USD`, `NZD_USD`, `USD_JPY`, `USD_CAD`, `USD_CHF`), converts each to "how many USD per 1 unit", then derives all 28 cross rates mathematically. This avoids the problem that most MT5 brokers don't have symbols for exotic crosses like `AUDEUR`, `AUDGBP`, etc.
|
||||
|
||||
**Key internal:**
|
||||
```python
|
||||
USD_PAIRS = ["EUR_USD", "GBP_USD", "AUD_USD", "NZD_USD",
|
||||
"USD_JPY", "USD_CAD", "USD_CHF"]
|
||||
|
||||
# For EUR_USD: usd_rates["EUR"] = mid_price
|
||||
# For USD_JPY: usd_rates["JPY"] = 1.0 / mid_price
|
||||
# Cross rate: rate[base][quote] = usd_rates[base] / usd_rates[quote]
|
||||
```
|
||||
|
||||
**Class `MockDataFeeder`** — Simulates prices with random walk around base prices for 8 major pairs. Same interface as `Mt5DataFeeder` for testability.
|
||||
|
||||
---
|
||||
|
||||
#### `fred_client.py`
|
||||
**Class `FredClient`** — Fetches interest rates from FRED API.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `__init__(api_key, timeout)` | — | Validates API key |
|
||||
| `fetch_rate(currency, max_retries)` | `float\|None` | Single currency rate with exponential backoff |
|
||||
| `fetch_all_rates(max_retries)` | `dict` | All 8 currencies |
|
||||
| `get_cached_rate(currency)` | `float\|None` | Cache lookup |
|
||||
| `clear_cache()` | — | Reset cache |
|
||||
|
||||
Uses FRED series IDs from `config.FRED_SERIES`:
|
||||
- USD → `FEDFUNDS`, EUR → `ECBDFR`, GBP → `BOEBR`, JPY → `IRSTJPN`
|
||||
- AUD → `RBATCTR`, CAD → `BOCCRT`, CHF → `SNBPOL`, NZD → `RBNZOCR`
|
||||
|
||||
---
|
||||
|
||||
#### `database.py`
|
||||
**Class `Database`** — SQLite database with 4 tables.
|
||||
|
||||
| Table | Columns | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `rates` | `currency, rate, updated_at, source` | Interest rates from FRED |
|
||||
| `monthly_data` | `month, currency, cpi_actual, pmi_actual, entered_at` | CPI/PMI entries |
|
||||
| `scores` | `month, currency, score_rate, score_cpi, score_pmi, total_score, rank` | Calculated scores |
|
||||
| `signals` | `month, strongest, weakest, gap, signal, status` | Trade signals |
|
||||
|
||||
All tables use `ON CONFLICT ... DO UPDATE` (upsert) for idempotent writes. Foreign keys enforced via PRAGMA.
|
||||
|
||||
Key methods: `upsert_rate()`, `update_monthly_cpi()`, `update_monthly_pmi()`, `save_scores()`, `save_signal()`, `get_month_scores()`, `get_all_signals()`, `get_month_completeness()`.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Business Logic — Layer 1 (Fundamental)
|
||||
|
||||
#### `scorer.py`
|
||||
Pure functions (no classes). Implements the scoring formula:
|
||||
|
||||
```
|
||||
Score = (Rate_Differential × 50%) + (CPI_Deviation × 30%) + (PMI × 20%)
|
||||
|
||||
Each component is min-max normalized to 0-100 before weighting.
|
||||
```
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `normalise(values)` | `list[float]` | Min-max scaling to 0-100 |
|
||||
| `calculate_rate_differentials(rates)` | `dict` | Rate minus G8 average |
|
||||
| `calculate_cpi_deviations(cpi_values)` | `dict` | Actual CPI minus CB target |
|
||||
| `score_all_currencies(rates, cpi, pmi)` | `dict` | Full scoring pipeline |
|
||||
| `get_ranked_list(scores)` | `list[tuples]` | Sorted by score descending |
|
||||
| `pair_currencies(scores)` | `(strongest, weakest, gap)` | Top vs bottom score |
|
||||
| `generate_signal(scores)` | `(signal, status, gap_desc)` | "SHORT X/Y" or "NO TRADE" |
|
||||
| `validate_scores(scores)` | `bool` | Validates all fields and ranges |
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Business Logic — Layer 2 (Technical)
|
||||
|
||||
#### `layer2_technical.py`
|
||||
**Class `TechnicalAnalyzer`** — Real-time Z-score engine.
|
||||
|
||||
Initializes 56 deques (all permutations of 8 currencies) with `maxlen=20`. Each incoming price tick appends to the deque and recalculates the Z-score.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `add_price_data(pair, price, volume)` | Append price, recalculate Z-score |
|
||||
| `get_z_score(pair)` | Current Z-score for any pair |
|
||||
| `is_extreme(pair)` | `|Z| >= 2.0` |
|
||||
| `get_overbought_pairs()` | All pairs with Z >= 2.0 |
|
||||
| `get_oversold_pairs()` | All pairs with Z <= -2.0 |
|
||||
| `get_volatility(pair)` | Standard deviation of recent prices |
|
||||
| `get_mean_price(pair)` | Mean price over lookback |
|
||||
| `is_mean_reverting(pair)` | `|Z| < 0.5` |
|
||||
| `get_last_price(pair)` | Most recent price |
|
||||
| `get_all_z_scores()` | Dict of all 56 pair Z-scores |
|
||||
| `get_status_for_pair(pair)` | Dict with label (SEVERELY OVERBOUGHT → Neutral) |
|
||||
|
||||
**Z-score formula:** `Z = (current_price - mean) / std_dev`
|
||||
|
||||
**Class `TechnicalSignal`** — Signal generation from Z-scores.
|
||||
- `should_enter_on_extreme()` → True if `|Z| >= 2.0`
|
||||
- `should_exit_on_mean_reversion()` → True if `|Z| < 0.5`
|
||||
- `get_signal_strength()` → 0-100 scale
|
||||
|
||||
---
|
||||
|
||||
#### `currency_strength_matrix.py`
|
||||
**Class `CurrencyStrengthMatrix`** — S.A.T.O.R.I. individual currency strength index.
|
||||
|
||||
This is the core mathematical innovation. Deconstructs all 28 pair Z-scores into 8 individual currency strength indices.
|
||||
|
||||
**How it works:**
|
||||
|
||||
For each currency, collects Z-scores from all 7 pairs where it is the **base**:
|
||||
```
|
||||
EUR_Strength = avg(Z(EUR_USD), Z(EUR_GBP), Z(EUR_JPY), Z(EUR_AUD), Z(EUR_CAD), Z(EUR_CHF), Z(EUR_NZD))
|
||||
USD_Strength = avg(Z(USD_EUR), Z(USD_GBP), Z(USD_JPY), Z(USD_AUD), Z(USD_CAD), Z(USD_CHF), Z(USD_NZD))
|
||||
```
|
||||
|
||||
**Output:**
|
||||
| Currency | Avg Z-Score | Direction |
|
||||
|----------|-------------|-----------|
|
||||
| EUR | +2.3 | **OVERBOUGHT** |
|
||||
| USD | +1.1 | NEUTRAL |
|
||||
| ... | ... | ... |
|
||||
| JPY | -2.5 | **OVERSOLD** |
|
||||
|
||||
The **Matrix Cross** = Strongest currency vs Weakest currency (e.g., `EUR_JPY`).
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `update(z_scores)` | Recompute from 56 pair Z-scores |
|
||||
| `get_strongest()` | Highest avg Z-score currency |
|
||||
| `get_weakest()` | Lowest avg Z-score currency |
|
||||
| `get_matrix_cross()` | Strongest_Weakest pair |
|
||||
| `get_divergence_gap()` | strongest_z - weakest_z |
|
||||
| `has_divergence()` | True if one overbought AND one oversold |
|
||||
| `get_strong_currencies()` | List of overbought currencies |
|
||||
| `get_weak_currencies()` | List of oversold currencies |
|
||||
| `get_ranked_list()` | All 8 sorted by strength |
|
||||
| `get_report()` | Dict with all matrix data |
|
||||
|
||||
---
|
||||
|
||||
#### `confluence_filter.py`
|
||||
**Class `ConfluenceFilter`** — Merges all three signal sources.
|
||||
|
||||
**Entry logic** (two-tier):
|
||||
|
||||
1. **Primary — Matrix Divergence:**
|
||||
- One currency overbought across ALL pairs
|
||||
- Another currency oversold across ALL pairs
|
||||
- Trade the Matrix Cross (strongest vs weakest)
|
||||
- Confidence = spread / 4.0 × 100
|
||||
|
||||
2. **Secondary — Layer 1 + Layer 2:**
|
||||
- Layer 1 bias (fundamental strongest/weakest)
|
||||
- Layer 2 pair extreme (|Z| >= 2.0 on that specific pair)
|
||||
- 50% gap confidence + 50% Z confidence
|
||||
|
||||
**Exit logic** (two-tier):
|
||||
1. Matrix divergence gap collapses (divergence no longer exists)
|
||||
2. Single-pair Z-score mean reverts below 0.5
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `set_layer1_bias(strongest, weakest, gap)` | Store current L1 signal |
|
||||
| `check_entry_confluence()` | `(bool, reason, strength)` |
|
||||
| `check_exit_confluence()` | `(bool, reason)` |
|
||||
| `is_conflicting()` | L1 bullish but L2 bearish |
|
||||
| `get_confluence_report()` | Full report with matrix data |
|
||||
| `get_all_signals()` | All ranked signals |
|
||||
|
||||
**Class `SignalHistory`** — Tracks up to 1000 signals with win-rate calculation.
|
||||
|
||||
---
|
||||
|
||||
#### `risk_management.py`
|
||||
Five classes implementing professional risk management:
|
||||
|
||||
**Class `PositionSizer`**
|
||||
- Risk-based position sizing: `size = (balance × 0.01) / (stop_loss × pip_value) × confidence_multiplier`
|
||||
- Clamped to 0.01–5.0 lots
|
||||
|
||||
**Class `GridHedging`**
|
||||
- Creates N-level hedge grid below entry price
|
||||
- Each hedge level = 50% × position_size / (N-1)
|
||||
|
||||
**Class `PortfolioExposure`**
|
||||
- Tracks all open positions
|
||||
- Enforces max leverage (default 2:1)
|
||||
- Rejects new positions that would exceed limit
|
||||
|
||||
**Class `BasketHedging`** — S.A.T.O.R.I. statistical arbitrage hedging.
|
||||
- Pre-defined correlation clusters:
|
||||
- `EUR_USD` → hedges with `EUR_GBP`, `EUR_JPY`, `GBP_USD`
|
||||
- `GBP_USD` → hedges with `GBP_JPY`, `EUR_GBP`, `EUR_USD`
|
||||
- `USD_JPY` → hedges with `USD_CHF`, `USD_CAD`, `EUR_JPY`
|
||||
- `AUD_USD` → hedges with `AUD_JPY`, `NZD_USD`, `AUD_CAD`
|
||||
- `NZD_USD` → hedges with `AUD_USD`, `NZD_JPY`, `NZD_CAD`
|
||||
- Each correlated pair gets 30% of primary size / len(cluster)
|
||||
|
||||
**Class `RiskManagementSystem`** — Combines all four.
|
||||
- `execute_signal()` → full trade execution with sizing + grid + basket
|
||||
- `calculate_basket_pnl()` → aggregate unrealized P&L across ALL positions + hedges
|
||||
- `should_exit_portfolio()` → exit when total P&L > 0 (portfolio-based, not per-pair)
|
||||
- `close_all_trades()` → close all positions at given exit prices
|
||||
- `get_portfolio_summary()` → positions, exposure, leverage, P&L
|
||||
|
||||
---
|
||||
|
||||
### 4.6 UI Layer
|
||||
|
||||
#### `main_window.py`
|
||||
**Class `MainWindow(QMainWindow)`** — Application shell.
|
||||
|
||||
Creates 6-tab `QTabWidget`, instantiates all tabs, connects inter-tab signals.
|
||||
|
||||
**Data flow assembly:**
|
||||
```
|
||||
1. Entry tab saves data → Dashboard refreshes
|
||||
2. Entry tab saves data → History tab refreshes
|
||||
3. Dashboard generates signal → Confluence tab receives bias
|
||||
4. Dashboard requests fetch → FredFetchWorker starts
|
||||
5. FRED completes → Dashboard updates rates
|
||||
```
|
||||
|
||||
**Class `FredFetchWorker(QThread)`** — Background FRED API fetch. Saves rates to DB, emits `rates_fetched` or `error_occurred`.
|
||||
|
||||
---
|
||||
|
||||
#### `ui/dashboard_tab.py` — Tab 1
|
||||
**Class `DashboardTab(QWidget)`**
|
||||
|
||||
Displays Layer 1 fundamental analysis:
|
||||
- **Signal card** — Large text: "SHORT JPY/USD" or "NO TRADE", gap score, tier, timestamp
|
||||
- **Score table** — 8 rows × 8 columns (Rank, Currency🇺🇸, Rate%, CPI%, PMI, Score, Signal, Strength bar)
|
||||
- Strongest row highlighted green with "BUY" tag
|
||||
- Weakest row highlighted red with "SELL" tag
|
||||
- Color-coded score bars (green/red/gray for Rate/CPI/PMI contributions)
|
||||
- "Fetch Rates (FRED)" button
|
||||
|
||||
Signals: `fetch_rates_requested`, `signal_generated(strongest, weakest, gap)`
|
||||
|
||||
---
|
||||
|
||||
#### `ui/entry_tab.py` — Tab 2
|
||||
**Class `MonthlyEntryTab(QWidget)`**
|
||||
|
||||
Manual data entry for CPI and PMI:
|
||||
- Month selector (dropdown, 24 months)
|
||||
- **CPI table**: Currency, Target%, Actual CPI (spinbox), Delta (color-coded), Done
|
||||
- **PMI table**: Currency, Neutral 50, PMI (spinbox), Signal label (Expanding/Contracting), Done
|
||||
- Progress bar: X/16 fields filled
|
||||
- Import Excel button (supports both multi-sheet xlsx and CSV)
|
||||
- Save button (enabled only when 16/16 complete)
|
||||
- On save: loads rates from DB → runs `scorer.score_all_currencies()` → saves scores → generates signal → emits `data_saved`
|
||||
|
||||
Signals: `data_saved(month)`
|
||||
|
||||
---
|
||||
|
||||
#### `ui/layer2_monitor_tab.py` — Tab 3
|
||||
**Class `Layer2MonitorTab(QWidget)`**
|
||||
**Class `DataStreamerThread(QThread)`**
|
||||
|
||||
Real-time technical analysis with S.A.T.O.R.I. matrix:
|
||||
- **Connection panel**: Source dropdown (MT5 Live / Mock Test), Connect/Disconnect, status indicator
|
||||
- **Z-score table**: All 28 pairs with Price, Z-Score (red when extreme), Volatility, Mean, Status, Signal
|
||||
- **Overbought/Oversold alerts**: Comma-separated lists
|
||||
- **Currency Strength Matrix panel:**
|
||||
- Matrix Cross label (strongest vs weakest currency)
|
||||
- Divergence Gap (sigma spread)
|
||||
- DIVERGENCE DETECTED alert (red) when one currency overbought + one oversold
|
||||
- Ranked currency table: 8 rows × 4 columns (Rank, Currency, Strength Z, Direction)
|
||||
- Color-coded: OVERBOUGHT (red), OVERSOLD (green)
|
||||
- Auto-refresh checkbox, Refresh Now button
|
||||
|
||||
Data flow: Streamer thread polls feeder → emits `price_updated` → feeds `TechnicalAnalyzer` → recomputes `CurrencyStrengthMatrix` → refreshes display.
|
||||
|
||||
---
|
||||
|
||||
#### `ui/confluence_tab.py` — Tab 4
|
||||
**Class `ConfluenceSignalsTab(QWidget)`**
|
||||
|
||||
Merged signal display and execution:
|
||||
- **Status card**: Layer 1 bias (pair, gap), Layer 2 extreme (pair, Z-score), Matrix Cross, Top 3 → Bottom 3 ranked currencies, Confluence result with confidence %
|
||||
- **Signals table**: 10 rows × 8 columns (Pair, L1 Gap, L2 Z-Score, Status, Confidence, Entry Price, Position Size, Action)
|
||||
- Matrix divergence signals shown in purple, standard confluence in green
|
||||
- **Risk panel**: Portfolio exposure progress bar, leverage ratio
|
||||
- **Buttons**: Refresh, Execute Top Signal (runs `RiskManagementSystem`)
|
||||
- Auto-refresh every 5 seconds
|
||||
|
||||
---
|
||||
|
||||
#### `ui/history_tab.py` — Tab 5
|
||||
**Class `HistoryTab(QWidget)`**
|
||||
|
||||
Past signal history:
|
||||
- Table with 6 columns: Month, Signal, Gap, Strongest (flag), Weakest (flag), Status
|
||||
- Status color-coded: ACTIVE (green), NO_TRADE (red)
|
||||
- Click any row → popup with full score breakdown for all 8 currencies
|
||||
- Auto-refreshes when new data saved
|
||||
|
||||
---
|
||||
|
||||
#### `ui/settings_tab.py` — Tab 6
|
||||
**Class `SettingsTab(QWidget)`**
|
||||
**Class `FredTestWorker(QThread)`**
|
||||
**Class `Mt5TestWorker(QThread)`**
|
||||
|
||||
Configuration interface:
|
||||
- **FRED API**: Key input (masked), Test Connection button, status
|
||||
- **MT5**: Symbol suffix input, Test Connection button, status
|
||||
- **CB Targets**: Read-only display of all 8 targets
|
||||
- **Scoring Weights**: 3 spinboxes (Rate/CPI/PMI %) with live total validation (must = 100%)
|
||||
- **Trading Rules**: Minimum gap spinbox (5-100)
|
||||
- **App Settings**: Auto-fetch checkbox
|
||||
- **Save**: Writes .env file (requires restart)
|
||||
- **Reset**: Confirmation dialog, restores defaults
|
||||
|
||||
---
|
||||
|
||||
### 4.7 Utility
|
||||
|
||||
#### `create_excel_template.py`
|
||||
Generates example Excel/CSV files for data import testing:
|
||||
- `example_monthly_data.xlsx` (multi-sheet: CPI + PMI)
|
||||
- `example_monthly_data_single_sheet.xlsx` (all in one sheet)
|
||||
- `example_monthly_data.csv`
|
||||
|
||||
Each contains 8 currencies with example values.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flow Diagrams
|
||||
|
||||
### Layer 1 (Fundamental) — Monthly Cycle
|
||||
|
||||
```
|
||||
User enters CPI/PMI
|
||||
│
|
||||
▼
|
||||
Entry Tab → Save Clicked
|
||||
│
|
||||
├──► Read all 8 CPI + 8 PMI from spinboxes
|
||||
├──► Load interest rates from DB (from FRED)
|
||||
├──► scorer.score_all_currencies(rates, cpi, pmi)
|
||||
│ ├── normalise(rate_differentials) × 0.50
|
||||
│ ├── normalise(cpi_deviations) × 0.30
|
||||
│ ├── normalise(pmi_raw) × 0.20
|
||||
│ └── sum → total_score 0-100
|
||||
├──► scorer.generate_signal(scores)
|
||||
│ ├── pair_currencies → strongest, weakest, gap
|
||||
│ ├── gap >= 20 → "SHORT {weak}/{strong}"
|
||||
│ └── gap < 20 → "NO TRADE"
|
||||
├──► database.save_scores()
|
||||
├──► database.save_signal()
|
||||
└──► emit data_saved → Dashboard + History refresh
|
||||
```
|
||||
|
||||
### Layer 2 (Technical) — Real-time with Bar Seeding
|
||||
|
||||
```
|
||||
MT5 Terminal (or Mock)
|
||||
│
|
||||
├── On Connect:
|
||||
│ generate_mock_bars(288) ◄── Mock: simulates 24h of M5 data
|
||||
│ │ or
|
||||
│ fetch_historical_bars(288, M5) ◄── MT5: real bars from terminal
|
||||
│ │
|
||||
│ ▼
|
||||
│ TechnicalAnalyzer.seed_bars(bars) ◄── Populates bar_history with 288 closes
|
||||
│ │ ◄── μ and σ now anchored to 24h
|
||||
│ │
|
||||
│ ▼
|
||||
│ DataStreamerThread.start()
|
||||
│
|
||||
└──► every 1s:
|
||||
fetch_all_rates() → 7 USD pairs → derive 28 crosses
|
||||
│
|
||||
├──► emit price_updated(pair, mid)
|
||||
│
|
||||
▼
|
||||
Layer2MonitorTab._on_price_received
|
||||
│
|
||||
├──► TechnicalAnalyzer.add_price_data(pair, price)
|
||||
│ └── _update_z_score(pair)
|
||||
│ μ, σ = _get_mean_std(pair)
|
||||
│ │ priority: bar_history (288 bars) → tick_fallback (20 ticks)
|
||||
│ ▼
|
||||
│ Z = (current_price - μ) / σ
|
||||
│
|
||||
├──► CurrencyStrengthMatrix(z_scores)
|
||||
│ └── For each currency: avg Z across 7 base pairs
|
||||
│
|
||||
└──► _refresh_display()
|
||||
├── Update 28-pair Z-score table
|
||||
├── Update currency strength matrix table
|
||||
├── Update matrix cross / divergence alerts
|
||||
├── Update overbought/oversold lists
|
||||
└── Show active session (Tokyo/London/New York)
|
||||
```
|
||||
|
||||
### Confluence — Entry Signal
|
||||
|
||||
```
|
||||
Layer 1 (monthly) Layer 2 (real-time)
|
||||
│ │
|
||||
▼ ▼
|
||||
dashboard_tab.signal_generated ThermalAnalyzer.z_scores
|
||||
│ │
|
||||
▼ ▼
|
||||
ConfluenceFilter.set_layer1_bias CurrencyStrengthMatrix
|
||||
│ │
|
||||
└──────────┬───────────────────────┘
|
||||
▼
|
||||
ConfluenceFilter.check_entry_confluence()
|
||||
│
|
||||
├── Matrix divergence? → YES → Trade matrix cross
|
||||
├── L1 + L2 extreme? → YES → Trade paired pair
|
||||
└── Neither? → NO TRADE
|
||||
│
|
||||
▼
|
||||
RiskManagementSystem.execute_signal()
|
||||
│
|
||||
├── PositionSizer → size = f(confidence)
|
||||
├── GridHedging → 3-level hedge grid
|
||||
├── BasketHedging → correlated pair hedges
|
||||
└── PortfolioExposure → leverage check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Database Schema
|
||||
|
||||
```sql
|
||||
-- Table 1: Interest rates from FRED
|
||||
CREATE TABLE rates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
currency TEXT NOT NULL UNIQUE,
|
||||
rate REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'FRED',
|
||||
CONSTRAINT valid_currency CHECK (currency IN ('USD','EUR','GBP','JPY','AUD','CAD','CHF','NZD'))
|
||||
);
|
||||
|
||||
-- Table 2: Monthly CPI + PMI entries
|
||||
CREATE TABLE monthly_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
month TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
cpi_actual REAL,
|
||||
pmi_actual REAL,
|
||||
entered_at TEXT NOT NULL,
|
||||
UNIQUE(month, currency),
|
||||
CONSTRAINT valid_currency CHECK (currency IN ('USD','EUR','GBP','JPY','AUD','CAD','CHF','NZD')),
|
||||
CONSTRAINT valid_month CHECK (month LIKE '____-__')
|
||||
);
|
||||
|
||||
-- Table 3: Calculated scores
|
||||
CREATE TABLE scores (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
month TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
score_rate REAL,
|
||||
score_cpi REAL,
|
||||
score_pmi REAL,
|
||||
total_score REAL NOT NULL,
|
||||
rank INTEGER NOT NULL,
|
||||
calculated_at TEXT NOT NULL,
|
||||
UNIQUE(month, currency)
|
||||
);
|
||||
|
||||
-- Table 4: Trade signals
|
||||
CREATE TABLE signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
generated_at TEXT NOT NULL,
|
||||
month TEXT NOT NULL UNIQUE,
|
||||
strongest TEXT NOT NULL,
|
||||
weakest TEXT NOT NULL,
|
||||
gap REAL NOT NULL,
|
||||
signal TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED'))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration (.env)
|
||||
|
||||
```env
|
||||
FRED_API_KEY=your_fred_api_key
|
||||
MT5_SYMBOL_SUFFIX=
|
||||
DB_PATH=apex.db
|
||||
DEBUG=true
|
||||
WEIGHT_RATE=0.50
|
||||
WEIGHT_CPI=0.30
|
||||
WEIGHT_PMI=0.20
|
||||
MIN_GAP=20.0
|
||||
AUTO_FETCH_RATES_ON_STARTUP=true
|
||||
Z_SCORE_THRESHOLD=2.0
|
||||
Z_SCORE_LOOKBACK=20
|
||||
ACCOUNT_BALANCE=10000.0
|
||||
RISK_PER_TRADE=0.01
|
||||
MAX_PORTFOLIO_LEVERAGE=2.0
|
||||
USE_GRID_HEDGING=true
|
||||
GRID_LEVELS=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Technology Stack
|
||||
|
||||
| Component | Technology | Version |
|
||||
|-----------|-----------|---------|
|
||||
| Language | Python | 3.10+ |
|
||||
| UI Framework | PyQt5 | 5.15.9 |
|
||||
| Database | SQLite | Built-in |
|
||||
| HTTP Client | requests | 2.31+ |
|
||||
| Data Processing | pandas | 2.1+ |
|
||||
| Excel Support | openpyxl | 3.1+ |
|
||||
| Environment | python-dotenv | 1.0+ |
|
||||
| Forex Data | MetaTrader5 | Latest |
|
||||
| Interest Rates | FRED API | Free tier |
|
||||
| Packaging | PyInstaller | 6.1+ |
|
||||
|
||||
---
|
||||
|
||||
## 9. Scoring Formula Reference
|
||||
|
||||
### Layer 1 — Fundamental Score
|
||||
|
||||
```
|
||||
rate_diff[i] = rate[i] - G8_average_rate
|
||||
cpi_dev[i] = actual_cpi[i] - cb_target[i]
|
||||
pmi_raw[i] = pmi_value[i]
|
||||
|
||||
normalize(x) = (x - min) / (max - min) × 100 // 0-100 scale
|
||||
|
||||
score_total[i] = normalise(rate_diff)[i] × 0.50
|
||||
+ normalise(cpi_dev)[i] × 0.30
|
||||
+ normalise(pmi_raw)[i] × 0.20
|
||||
|
||||
gap = score_total[strongest] - score_total[weakest]
|
||||
```
|
||||
|
||||
### Layer 2 — Technical Score (Bar-Anchored)
|
||||
|
||||
```
|
||||
Step 1: Seed bar_history with 288 M5 close prices (24 hours)
|
||||
Step 2: μ_bars = mean(bar_history), σ_bars = stdev(bar_history)
|
||||
Step 3: For each incoming tick:
|
||||
|
||||
Z[pair] = (current_tick_price - μ_bars) / σ_bars
|
||||
|
||||
Fallback (if bar_history empty):
|
||||
Z[pair] = (current_tick_price - mean(ticks)) / stdev(ticks)
|
||||
|
||||
Step 4: Individual Currency Strength = avg(Z[currency_X] over all 7 base pairs)
|
||||
|
||||
Step 5: Session Relative Velocity (SRV):
|
||||
At session open (Tokyo/London/NY), snapshot all prices.
|
||||
SRV[pair] = ((current_price - session_open_price) / session_open_price) × 100
|
||||
```
|
||||
|
||||
### Entry Conditions
|
||||
|
||||
```
|
||||
Matrix Divergence: any(avg_Z > +2.0) AND any(avg_Z < -2.0) → Trade Matrix Cross
|
||||
Pair Confluence: L1_gap >= 20 AND L2_Z >= 2.0 on same pair → Trade that pair
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 28 Currency Pairs (Generated)
|
||||
|
||||
All 8 currencies produce 56 permutations (28 pairs × 2 directions):
|
||||
|
||||
| Base | Pairs (base_quote) |
|
||||
|------|--------------------|
|
||||
| USD | USD_EUR, USD_GBP, USD_JPY, USD_AUD, USD_CAD, USD_CHF, USD_NZD |
|
||||
| EUR | EUR_USD, EUR_GBP, EUR_JPY, EUR_AUD, EUR_CAD, EUR_CHF, EUR_NZD |
|
||||
| GBP | GBP_USD, GBP_EUR, GBP_JPY, GBP_AUD, GBP_CAD, GBP_CHF, GBP_NZD |
|
||||
| JPY | JPY_USD, JPY_EUR, JPY_GBP, JPY_AUD, JPY_CAD, JPY_CHF, JPY_NZD |
|
||||
| AUD | AUD_USD, AUD_EUR, AUD_GBP, AUD_JPY, AUD_CAD, AUD_CHF, AUD_NZD |
|
||||
| CAD | CAD_USD, CAD_EUR, CAD_GBP, CAD_JPY, CAD_AUD, CAD_CHF, CAD_NZD |
|
||||
| CHF | CHF_USD, CHF_EUR, CHF_GBP, CHF_JPY, CHF_AUD, CHF_CAD, CHF_NZD |
|
||||
| NZD | NZD_USD, NZD_EUR, NZD_GBP, NZD_JPY, NZD_AUD, NZD_CAD, NZD_CHF |
|
||||
|
||||
Each currency's individual strength is computed from its 7 base pairs.
|
||||
|
||||
---
|
||||
|
||||
## 11. Refactoring Changelog (Session-Based Quantitative Engine)
|
||||
|
||||
### Task 1.1 — Statistical Lookback Window (config.py, layer2_technical.py)
|
||||
- `config.py`: Added `BAR_TIMEFRAME`, `BAR_LOOKBACK_HOURS`, `BAR_LOOKBACK_BARS`, `HISTORICAL_POLL_INTERVAL` constants. Default lookback changed from 20 ticks to 288 bars (24h of M5 data).
|
||||
- `layer2_technical.py`: `TechnicalAnalyzer` now maintains **two data streams**:
|
||||
- `bar_history` (deque of M1/M5 close prices, length = `BAR_LOOKBACK_BARS`) — the multi-hour statistical anchor
|
||||
- `price_history` (short deque of tick/poll data) — for UI display
|
||||
- `_get_bar_mean_std()` computes μ/σ from bar history only
|
||||
- `_update_z_score()` uses `Z = (current_tick - μ_bars) / σ_bars`
|
||||
- `add_bar()` method for feeding completed M1/M5 candles into the historical frame
|
||||
|
||||
### Task 1.2 — Session-Based Indexing (currency_strength_matrix.py)
|
||||
- New `SessionTracker` class:
|
||||
- Detects active session from UTC hour (Tokyo 00-08, London 07-16, New York 13-22)
|
||||
- On session open, snapshots start prices for all 28 pairs
|
||||
- Computes **Session Relative Velocity (SRV)**: `% change = (current - session_start) / session_start × 100`
|
||||
- `CurrencyStrengthMatrix.update()` now accepts `current_prices` dict for session tracking
|
||||
- `CurrencyStrength` dataclass has new `session_srv: float` field
|
||||
- `get_report()` includes `active_session` key
|
||||
|
||||
### Task 2.1 — Live Order Book Subscriptions (data_feeder.py)
|
||||
- `Mt5DataFeeder.get_order_book(pair)` — fetches live bid/ask/spread via `mt5.symbol_info_tick()` + `mt5.symbol_info()` for the exact trade symbol
|
||||
- `PositionSizer.calculate_position_size()` accepts optional `bid`, `ask`, `spread` params; wide spreads reduce position size by up to 20%
|
||||
- `MockDataFeeder` has matching `get_order_book()` implementation
|
||||
|
||||
### Task 2.2 — SQLite WAL Mode + Bar Cache (database.py)
|
||||
- Connection now sets: `PRAGMA journal_mode=WAL`, `PRAGMA synchronous=NORMAL` for concurrent read/write performance
|
||||
- New `bar_cache` table: `(id, pair, timeframe, bar_time, open, high, low, close, volume)` with unique constraint on `(pair, timeframe, bar_time)` and compound index
|
||||
- New methods: `upsert_bar()`, `get_bars()`, `get_latest_bar_time()`
|
||||
|
||||
### Task 3.1 — Layer 1 as Directional Regime Filter (confluence_filter.py)
|
||||
- `check_entry_confluence()` now uses Layer 1 as a **Directional Regime Filter**:
|
||||
- Primary signal: Matrix divergence (self-sufficient)
|
||||
- Secondary: Layer 2 extremes only valid if **aligned** with Layer 1 macro bias
|
||||
- Contrarian Layer 2 signals (Z < -threshold opposite Layer 1 direction) → **BLOCKED** with reason
|
||||
- Aligned signals capped at 70% confidence (downgraded vs matrix divergence)
|
||||
- `layer1_is_active` flag replaces raw gap comparison
|
||||
|
||||
### Task 3.2 — Dynamic Pearson Correlation (risk_management.py, data_feeder.py)
|
||||
- New `pearson_correlation(x, y)` function: `r = Σ(x-x̄)(y-ȳ) / √(Σ(x-x̄)² · Σ(y-ȳ)²)`
|
||||
- New `CorrelationEngine` class:
|
||||
- `update_series(historical_closes)` — feeds 30 days of close prices
|
||||
- `get_correlation(pair_a, pair_b)` — computes/caches r between any two pairs
|
||||
- `get_top_correlated(target, n=3, min_r=0.75)` — returns top N pairs with |r| ≥ 0.75
|
||||
- `BasketHedging.get_correlated_pairs()` now delegates to `CorrelationEngine` instead of hardcoded dict
|
||||
- `Mt5DataFeeder.fetch_historical_closes_all_pairs(days=30)` fetches the required data
|
||||
- `MockDataFeeder` has matching implementation
|
||||
|
||||
### Task 3.3 — Aggregate Portfolio Profit Target Exit (risk_management.py)
|
||||
- `get_dynamic_exit_target()` — confidence-scaled profit target (base = 1% of equity, scales with avg confidence)
|
||||
- Background monitor thread `_monitor_exit_loop()` polls `calculate_basket_pnl()` every second
|
||||
- When net aggregate P&L > dynamic target, fires `close_all_trades()` via registered callbacks
|
||||
- `start_exit_monitor()`, `stop_exit_monitor()`, `on_portfolio_exit()` lifecycle management
|
||||
|
||||
### Task 4.1 — Session Visualizations + σ Highlights (ui/layer2_monitor_tab.py)
|
||||
- Active session indicator label with color-coded background: Tokyo (purple), London (blue), New York (orange), Off-Hours (gray)
|
||||
- Currency Strength Matrix Z-score cells: solid red background with white text for ≥ +2.0σ, solid green with white text for ≤ -2.0σ
|
||||
- New 5th column in matrix table: "Session SRV" showing percentage change since session open
|
||||
- Emoji indicators removed from status labels for cleaner display
|
||||
|
||||
---
|
||||
|
||||
## 12. Bug Fixes & Stability (Round 2)
|
||||
|
||||
### Fix 1 — Bar History Never Seeded (Z-scores always 0.0)
|
||||
- `layer2_technical.py`: Added `seed_bars(historical_bars)` method to populate `bar_history` with 288 M5 close prices on connect
|
||||
- `data_feeder.py (Mock)`: Added `generate_mock_bars(n_bars=288)` — generates 24h of simulated M5 data using an Ornstein-Uhlenbeck process (mean reversion + drift + noise) for all 28 pairs via USD pair derivation
|
||||
- `ui/layer2_monitor_tab.py`: `_connect()` calls `_seed_historical_bars()` before starting the streamer — bars are always seeded first
|
||||
|
||||
### Fix 2 — Tick Price Anchored to Initial Base, Not Bar Data
|
||||
- `data_feeder.py (Mock)`: `_tick_price()` now uses the **last bar close** as its anchor with ±0.0002 noise, instead of the initial base price with ±0.01 noise
|
||||
- `_current_bar_prices()` returns the last cached bar close for each pair
|
||||
- This ensures Z-scores reflect the bar position relative to 24h history, not random tick noise
|
||||
|
||||
### Fix 3 — Default Source Changed to Mock
|
||||
- `ui/layer2_monitor_tab.py`: `source_combo` defaults to `"Mock (Test)"` at index 0 to prevent unintended MT5 terminal connections on startup
|
||||
|
||||
### Fix 4 — Persistent Matrix Instance
|
||||
- `ui/layer2_monitor_tab.py`: `CurrencyStrengthMatrix` is now a persistent `self.matrix` instance, recreated only once. `update()` is called each refresh instead of creating a new object, preserving `SessionTracker` state across refreshes
|
||||
|
||||
### Fix 5 — FRED Series IDs Updated
|
||||
- `config.py`: Updated 6 invalid/deprecated FRED series IDs (`BOEBR`, `IRSTJPN`, `RBATCTR`, `BOCCRT`, `SNBPOL`, `RBNZOCR`) to commonly used alternatives (`BOEIR`, `IRSTCI01JPM156N`, `RBATR`, `BOCARR`, `SNBON`, `RBNZR`)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user