mirror of
https://github.com/Sabermrddz/QuantCore-FX.git
synced 2026-08-17 12:48:07 +00:00
layer one v3
This commit is contained in:
+54
-27
@@ -1,10 +1,10 @@
|
|||||||
# APEX Layer 1 — Environment Configuration Example
|
# APEX — Environment Configuration Example
|
||||||
#
|
#
|
||||||
# Copy this file to .env and fill in your values
|
# Copy this file to .env and fill in your values
|
||||||
# cp .env.example .env
|
# 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
|
# Get a free API key from: https://fred.stlouisfed.org
|
||||||
# 1. Register for an account
|
# 1. Register for an account
|
||||||
@@ -12,58 +12,85 @@
|
|||||||
# 3. Copy your key and paste below
|
# 3. Copy your key and paste below
|
||||||
FRED_API_KEY=paste_your_fred_api_key_here
|
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
|
# Database Configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Path to SQLite database file
|
|
||||||
DB_PATH=apex.db
|
DB_PATH=apex.db
|
||||||
|
|
||||||
# Database connection timeout (seconds)
|
|
||||||
DB_TIMEOUT=10
|
|
||||||
|
|
||||||
# Auto-create schema on first run
|
|
||||||
DB_AUTO_CREATE=true
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Debugging & Logging
|
# Debugging & Logging
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Enable debug output to console (true/false)
|
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Scoring Weights (must sum to 1.0)
|
# Scoring Weights (must sum to 1.0)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Interest rate differential weight (50% default)
|
|
||||||
WEIGHT_RATE=0.50
|
WEIGHT_RATE=0.50
|
||||||
|
|
||||||
# CPI deviation weight (30% default)
|
|
||||||
WEIGHT_CPI=0.30
|
WEIGHT_CPI=0.30
|
||||||
|
|
||||||
# PMI composite weight (20% default)
|
|
||||||
WEIGHT_PMI=0.20
|
WEIGHT_PMI=0.20
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Trading Rules
|
# Trading Rules
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Minimum gap in points to generate trade signal (default 20)
|
# Gap < 20: NO TRADE | 20-40: Weak | 40-60: Standard | > 60: Strong
|
||||||
# Gap < 20: NO TRADE
|
|
||||||
# Gap 20-40: Weak signal
|
|
||||||
# Gap 40-60: Standard signal
|
|
||||||
# Gap > 60: Strong signal
|
|
||||||
MIN_GAP=20.0
|
MIN_GAP=20.0
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Auto-Fetch Configuration
|
# Auto-Fetch Configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Automatically fetch rates from FRED on app startup (true/false)
|
|
||||||
AUTO_FETCH_RATES_ON_STARTUP=true
|
AUTO_FETCH_RATES_ON_STARTUP=true
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Application UI Settings
|
# Technical Analysis Settings (Layer 2)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Window title
|
# Z-score threshold for overbought/oversold (±2.0σ)
|
||||||
APP_TITLE=APEX Layer 1 — Currency Strength Engine
|
Z_SCORE_THRESHOLD=2.0
|
||||||
|
|
||||||
# Default window size (width x height)
|
# Statistical lookback window (Task 1.1): 288 M5 bars = 24 hours of data
|
||||||
WINDOW_WIDTH=1200
|
Z_SCORE_LOOKBACK=288
|
||||||
WINDOW_HEIGHT=800
|
|
||||||
|
# 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
|
# OS specific
|
||||||
Thumbs.db
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.Thumbs.db
|
.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
|
from pathlib import Path
|
||||||
|
|
||||||
# Load .env file from project root
|
# 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)
|
load_dotenv(dotenv_path=env_path)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -63,12 +63,12 @@ CB_TARGETS = {
|
|||||||
FRED_SERIES = {
|
FRED_SERIES = {
|
||||||
"USD": "FEDFUNDS", # US Federal Funds Rate
|
"USD": "FEDFUNDS", # US Federal Funds Rate
|
||||||
"EUR": "ECBDFR", # ECB Deposit Rate
|
"EUR": "ECBDFR", # ECB Deposit Rate
|
||||||
"GBP": "BOEBR", # Bank of England Base Rate
|
"GBP": "BOEIR", # Bank of England Interest Rate
|
||||||
"JPY": "IRSTJPN", # Japan Policy Rate (or manual from BOJ website)
|
"JPY": "IRSTCI01JPM156N", # Japan Short-Term Interest Rate
|
||||||
"AUD": "RBATCTR", # RBA Cash Target Rate
|
"AUD": "RBATR", # RBA Target Cash Rate
|
||||||
"CAD": "BOCCRT", # BOC Policy Interest Rate
|
"CAD": "BOCARR", # Bank of Canada Overnight Rate
|
||||||
"CHF": "SNBPOL", # SNB Policy Rate
|
"CHF": "SNBON", # SNB Policy Rate
|
||||||
"NZD": "RBNZOCR", # RBNZ Official Cash 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"
|
AUTO_FETCH_RATES_ON_STARTUP = os.getenv("AUTO_FETCH_RATES_ON_STARTUP", "true").lower() == "true"
|
||||||
FRED_FETCH_TIMEOUT = 10 # seconds
|
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
|
# 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_MIN = -5.0 # Some CBs have negative rates
|
||||||
RATE_MAX = 20.0 # Reasonable upper bound
|
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
|
# Database Settings
|
||||||
@@ -188,12 +158,90 @@ def validate_config():
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
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:
|
if DEBUG:
|
||||||
print("[CONFIG] Debug mode enabled")
|
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] 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] Database: {DB_PATH}")
|
||||||
print(f"[CONFIG] Weights: Rate={WEIGHT_RATE}, CPI={WEIGHT_CPI}, PMI={WEIGHT_PMI}")
|
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] 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)
|
# 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
|
import pandas as pd
|
||||||
from openpyxl import Workbook
|
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment
|
|
||||||
import config
|
import config
|
||||||
from datetime import datetime
|
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
|
||||||
+246
-154
@@ -13,7 +13,8 @@ All operations use parameterized queries to prevent SQL injection.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, List, Tuple
|
from typing import Optional, Dict, List, Tuple
|
||||||
import config
|
import config
|
||||||
@@ -38,6 +39,7 @@ class Database:
|
|||||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
self._lock = threading.Lock()
|
||||||
self.conn = sqlite3.connect(
|
self.conn = sqlite3.connect(
|
||||||
self.db_path,
|
self.db_path,
|
||||||
timeout=config.DB_TIMEOUT,
|
timeout=config.DB_TIMEOUT,
|
||||||
@@ -45,7 +47,9 @@ class Database:
|
|||||||
)
|
)
|
||||||
self.conn.row_factory = sqlite3.Row # Return rows as dicts
|
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")
|
self.conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
if config.DB_AUTO_CREATE:
|
if config.DB_AUTO_CREATE:
|
||||||
@@ -56,74 +60,95 @@ class Database:
|
|||||||
|
|
||||||
def _create_schema(self):
|
def _create_schema(self):
|
||||||
"""Create database schema if it doesn't exist."""
|
"""Create database schema if it doesn't exist."""
|
||||||
cursor = self.conn.cursor()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Table 1: Interest rates (updated via FRED API)
|
with self._lock:
|
||||||
cursor.execute("""
|
cursor = self.conn.cursor()
|
||||||
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)
|
# Table 1: Interest rates (updated via FRED API)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS monthly_data (
|
CREATE TABLE IF NOT EXISTS rates (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
month TEXT NOT NULL,
|
currency TEXT NOT NULL UNIQUE,
|
||||||
currency TEXT NOT NULL,
|
rate REAL NOT NULL,
|
||||||
cpi_actual REAL,
|
updated_at TEXT NOT NULL,
|
||||||
pmi_actual REAL,
|
source TEXT DEFAULT 'FRED',
|
||||||
entered_at TEXT NOT NULL,
|
CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD'))
|
||||||
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)
|
# Table 2: Monthly manual entries (CPI + PMI)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS scores (
|
CREATE TABLE IF NOT EXISTS monthly_data (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
month TEXT NOT NULL,
|
month TEXT NOT NULL,
|
||||||
currency TEXT NOT NULL,
|
currency TEXT NOT NULL,
|
||||||
score_rate REAL,
|
cpi_actual REAL,
|
||||||
score_cpi REAL,
|
pmi_actual REAL,
|
||||||
score_pmi REAL,
|
entered_at TEXT NOT NULL,
|
||||||
total_score REAL NOT NULL,
|
UNIQUE(month, currency),
|
||||||
rank INTEGER NOT NULL,
|
CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')),
|
||||||
calculated_at TEXT NOT NULL,
|
CONSTRAINT valid_month CHECK (month LIKE '____-__')
|
||||||
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)
|
# Table 3: Calculated scores (generated after each data entry)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS signals (
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
generated_at TEXT NOT NULL,
|
month TEXT NOT NULL,
|
||||||
month TEXT NOT NULL UNIQUE,
|
currency TEXT NOT NULL,
|
||||||
strongest TEXT NOT NULL,
|
score_rate REAL,
|
||||||
weakest TEXT NOT NULL,
|
score_cpi REAL,
|
||||||
gap REAL NOT NULL,
|
score_pmi REAL,
|
||||||
signal TEXT NOT NULL,
|
total_score REAL NOT NULL,
|
||||||
status TEXT NOT NULL,
|
rank INTEGER NOT NULL,
|
||||||
CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED')),
|
calculated_at TEXT NOT NULL,
|
||||||
CONSTRAINT valid_month CHECK (month LIKE '____-__')
|
UNIQUE(month, currency),
|
||||||
);
|
CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')),
|
||||||
""")
|
CONSTRAINT valid_month CHECK (month LIKE '____-__')
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
self.conn.commit()
|
# 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);
|
||||||
|
""")
|
||||||
|
|
||||||
if config.DEBUG:
|
# Table 5: Signal log (one per month)
|
||||||
print("[DB] Schema created successfully")
|
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:
|
except sqlite3.Error as e:
|
||||||
self.conn.rollback()
|
self.conn.rollback()
|
||||||
@@ -145,20 +170,21 @@ class Database:
|
|||||||
if currency not in config.CURRENCIES:
|
if currency not in config.CURRENCIES:
|
||||||
raise ValueError(f"Invalid currency: {currency}")
|
raise ValueError(f"Invalid currency: {currency}")
|
||||||
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
with self._lock:
|
||||||
INSERT INTO rates (currency, rate, updated_at, source)
|
cursor = self.conn.cursor()
|
||||||
VALUES (?, ?, ?, ?)
|
cursor.execute("""
|
||||||
ON CONFLICT(currency) DO UPDATE SET
|
INSERT INTO rates (currency, rate, updated_at, source)
|
||||||
rate = excluded.rate,
|
VALUES (?, ?, ?, ?)
|
||||||
updated_at = excluded.updated_at,
|
ON CONFLICT(currency) DO UPDATE SET
|
||||||
source = excluded.source
|
rate = excluded.rate,
|
||||||
""", (currency, rate, datetime.utcnow().isoformat(), source))
|
updated_at = excluded.updated_at,
|
||||||
self.conn.commit()
|
source = excluded.source
|
||||||
|
""", (currency, rate, datetime.now(timezone.utc).isoformat(), source))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"[DB] Rate updated: {currency} = {rate}% (from {source})")
|
print(f"[DB] Rate updated: {currency} = {rate}% (from {source})")
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
self.conn.rollback()
|
self.conn.rollback()
|
||||||
@@ -220,29 +246,30 @@ class Database:
|
|||||||
if currency not in config.CURRENCIES:
|
if currency not in config.CURRENCIES:
|
||||||
raise ValueError(f"Invalid currency: {currency}")
|
raise ValueError(f"Invalid currency: {currency}")
|
||||||
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
try:
|
try:
|
||||||
# First, get existing PMI if any
|
with self._lock:
|
||||||
cursor.execute(
|
cursor = self.conn.cursor()
|
||||||
"SELECT pmi_actual FROM monthly_data WHERE month = ? AND currency = ?",
|
# First, get existing PMI if any
|
||||||
(month, currency)
|
cursor.execute(
|
||||||
)
|
"SELECT pmi_actual FROM monthly_data WHERE month = ? AND currency = ?",
|
||||||
row = cursor.fetchone()
|
(month, currency)
|
||||||
pmi = row["pmi_actual"] if row else None
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
pmi = row["pmi_actual"] if row else None
|
||||||
|
|
||||||
# Upsert with CPI
|
# Upsert with CPI
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at)
|
INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(month, currency) DO UPDATE SET
|
ON CONFLICT(month, currency) DO UPDATE SET
|
||||||
cpi_actual = excluded.cpi_actual,
|
cpi_actual = excluded.cpi_actual,
|
||||||
entered_at = excluded.entered_at
|
entered_at = excluded.entered_at
|
||||||
""", (month, currency, cpi, pmi, datetime.utcnow().isoformat()))
|
""", (month, currency, cpi, pmi, datetime.now(timezone.utc).isoformat()))
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"[DB] CPI saved: {month} {currency} = {cpi}%")
|
print(f"[DB] CPI saved: {month} {currency} = {cpi}%")
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
self.conn.rollback()
|
self.conn.rollback()
|
||||||
@@ -260,29 +287,30 @@ class Database:
|
|||||||
if currency not in config.CURRENCIES:
|
if currency not in config.CURRENCIES:
|
||||||
raise ValueError(f"Invalid currency: {currency}")
|
raise ValueError(f"Invalid currency: {currency}")
|
||||||
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
try:
|
try:
|
||||||
# First, get existing CPI if any
|
with self._lock:
|
||||||
cursor.execute(
|
cursor = self.conn.cursor()
|
||||||
"SELECT cpi_actual FROM monthly_data WHERE month = ? AND currency = ?",
|
# First, get existing CPI if any
|
||||||
(month, currency)
|
cursor.execute(
|
||||||
)
|
"SELECT cpi_actual FROM monthly_data WHERE month = ? AND currency = ?",
|
||||||
row = cursor.fetchone()
|
(month, currency)
|
||||||
cpi = row["cpi_actual"] if row else None
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
cpi = row["cpi_actual"] if row else None
|
||||||
|
|
||||||
# Upsert with PMI
|
# Upsert with PMI
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at)
|
INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(month, currency) DO UPDATE SET
|
ON CONFLICT(month, currency) DO UPDATE SET
|
||||||
pmi_actual = excluded.pmi_actual,
|
pmi_actual = excluded.pmi_actual,
|
||||||
entered_at = excluded.entered_at
|
entered_at = excluded.entered_at
|
||||||
""", (month, currency, cpi, pmi, datetime.utcnow().isoformat()))
|
""", (month, currency, cpi, pmi, datetime.now(timezone.utc).isoformat()))
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"[DB] PMI saved: {month} {currency} = {pmi}")
|
print(f"[DB] PMI saved: {month} {currency} = {pmi}")
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
self.conn.rollback()
|
self.conn.rollback()
|
||||||
@@ -344,6 +372,68 @@ class Database:
|
|||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
raise RuntimeError(f"Failed to check month completeness for {month}: {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
|
# SCORES Table Operations
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -356,34 +446,35 @@ class Database:
|
|||||||
month: Month in format "YYYY-MM"
|
month: Month in format "YYYY-MM"
|
||||||
scores: Dict mapping currency to {score_rate, score_cpi, score_pmi, total_score, rank}
|
scores: Dict mapping currency to {score_rate, score_cpi, score_pmi, total_score, rank}
|
||||||
"""
|
"""
|
||||||
cursor = self.conn.cursor()
|
|
||||||
try:
|
try:
|
||||||
for currency, score_data in scores.items():
|
with self._lock:
|
||||||
cursor.execute("""
|
cursor = self.conn.cursor()
|
||||||
INSERT INTO scores (month, currency, score_rate, score_cpi, score_pmi, total_score, rank, calculated_at)
|
for currency, score_data in scores.items():
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
cursor.execute("""
|
||||||
ON CONFLICT(month, currency) DO UPDATE SET
|
INSERT INTO scores (month, currency, score_rate, score_cpi, score_pmi, total_score, rank, calculated_at)
|
||||||
score_rate = excluded.score_rate,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
score_cpi = excluded.score_cpi,
|
ON CONFLICT(month, currency) DO UPDATE SET
|
||||||
score_pmi = excluded.score_pmi,
|
score_rate = excluded.score_rate,
|
||||||
total_score = excluded.total_score,
|
score_cpi = excluded.score_cpi,
|
||||||
rank = excluded.rank,
|
score_pmi = excluded.score_pmi,
|
||||||
calculated_at = excluded.calculated_at
|
total_score = excluded.total_score,
|
||||||
""", (
|
rank = excluded.rank,
|
||||||
month,
|
calculated_at = excluded.calculated_at
|
||||||
currency,
|
""", (
|
||||||
score_data.get("score_rate"),
|
month,
|
||||||
score_data.get("score_cpi"),
|
currency,
|
||||||
score_data.get("score_pmi"),
|
score_data.get("score_rate"),
|
||||||
score_data["total_score"],
|
score_data.get("score_cpi"),
|
||||||
score_data["rank"],
|
score_data.get("score_pmi"),
|
||||||
datetime.utcnow().isoformat()
|
score_data["total_score"],
|
||||||
))
|
score_data["rank"],
|
||||||
|
datetime.now(timezone.utc).isoformat()
|
||||||
|
))
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"[DB] {len(scores)} scores saved for {month}")
|
print(f"[DB] {len(scores)} scores saved for {month}")
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
self.conn.rollback()
|
self.conn.rollback()
|
||||||
@@ -443,24 +534,25 @@ class Database:
|
|||||||
if status not in ("ACTIVE", "NO_TRADE", "CLOSED"):
|
if status not in ("ACTIVE", "NO_TRADE", "CLOSED"):
|
||||||
raise ValueError(f"Invalid status: {status}")
|
raise ValueError(f"Invalid status: {status}")
|
||||||
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
with self._lock:
|
||||||
INSERT INTO signals (generated_at, month, strongest, weakest, gap, signal, status)
|
cursor = self.conn.cursor()
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
cursor.execute("""
|
||||||
ON CONFLICT(month) DO UPDATE SET
|
INSERT INTO signals (generated_at, month, strongest, weakest, gap, signal, status)
|
||||||
generated_at = excluded.generated_at,
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
strongest = excluded.strongest,
|
ON CONFLICT(month) DO UPDATE SET
|
||||||
weakest = excluded.weakest,
|
generated_at = excluded.generated_at,
|
||||||
gap = excluded.gap,
|
strongest = excluded.strongest,
|
||||||
signal = excluded.signal,
|
weakest = excluded.weakest,
|
||||||
status = excluded.status
|
gap = excluded.gap,
|
||||||
""", (datetime.utcnow().isoformat(), month, strongest, weakest, gap, signal, status))
|
signal = excluded.signal,
|
||||||
|
status = excluded.status
|
||||||
|
""", (datetime.now(timezone.utc).isoformat(), month, strongest, weakest, gap, signal, status))
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"[DB] Signal saved for {month}: {signal} (status={status})")
|
print(f"[DB] Signal saved for {month}: {signal} (status={status})")
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
self.conn.rollback()
|
self.conn.rollback()
|
||||||
|
|||||||
+2
-2
@@ -85,13 +85,13 @@ class FredClient:
|
|||||||
self.last_error = f"{currency}: API timeout (attempt {attempt + 1}/{max_retries})"
|
self.last_error = f"{currency}: API timeout (attempt {attempt + 1}/{max_retries})"
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"[FRED] {self.last_error}")
|
print(f"[FRED] {self.last_error}")
|
||||||
time.sleep(0.5 ** attempt) # Exponential backoff
|
time.sleep(0.5 * (2 ** attempt)) # Exponential backoff
|
||||||
|
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
self.last_error = f"{currency}: Connection error (attempt {attempt + 1}/{max_retries})"
|
self.last_error = f"{currency}: Connection error (attempt {attempt + 1}/{max_retries})"
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"[FRED] {self.last_error}")
|
print(f"[FRED] {self.last_error}")
|
||||||
time.sleep(0.5 ** attempt)
|
time.sleep(0.5 * (2 ** attempt))
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
self.last_error = f"{currency}: {str(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+
|
- Python 3.10+
|
||||||
- PyQt5 5.15+
|
- PyQt5 5.15+
|
||||||
- requests 2.31+
|
- requests 2.31+
|
||||||
- pandas 2.0+ (optional, for data)
|
- pandas 2.0+
|
||||||
- python-dotenv 1.0+
|
- python-dotenv 1.0+
|
||||||
|
- openpyxl 3.1+
|
||||||
|
|
||||||
Installation:
|
Installation:
|
||||||
pip install PyQt5 requests python-dotenv
|
pip install -r requirements.txt
|
||||||
|
|
||||||
First run:
|
First run:
|
||||||
1. Ensure .env file exists with FRED_API_KEY set
|
1. Ensure .env file exists with FRED_API_KEY set
|
||||||
2. Run: python main.py
|
2. (Optional for Layer 2) MetaTrader 5 terminal running
|
||||||
3. App initializes database with schema
|
3. Run: python main.py
|
||||||
4. Auto-fetches rates from FRED if AUTO_FETCH_RATES_ON_STARTUP=true
|
4. App initializes database with schema
|
||||||
5. Ready for manual CPI/PMI entry
|
5. Auto-fetches rates from FRED if AUTO_FETCH_RATES_ON_STARTUP=true
|
||||||
|
6. Ready for manual CPI/PMI entry
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
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:
|
Assembles all 6 tabs:
|
||||||
- Tab 1: Dashboard (main signal + ranking table)
|
- Tab 1: Dashboard (Layer 1 fundamental signals)
|
||||||
- Tab 2: Monthly Entry (CPI + PMI input form)
|
- Tab 2: Monthly Entry (CPI + PMI data input)
|
||||||
- Tab 3: History (past signals)
|
- Tab 3: Layer 2 Monitor (real-time technical analysis)
|
||||||
- Tab 4: Settings (configuration)
|
- Tab 4: Confluence Signals (merged Layer 1 + Layer 2)
|
||||||
|
- Tab 5: History (past signals)
|
||||||
|
- Tab 6: Settings (configuration)
|
||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
- Create QMainWindow with QTabWidget
|
- Create QMainWindow with QTabWidget
|
||||||
- Instantiate all UI tabs
|
- Instantiate all UI tabs
|
||||||
- Manage database connection
|
- Manage database connection
|
||||||
- Run FRED API fetch in background thread (QThread)
|
- Run FRED API fetch in background thread
|
||||||
- Connect inter-tab signals (e.g., entry tab saves → dashboard tab refreshes)
|
- Run Alpha Vantage real-time data fetching
|
||||||
|
- Connect inter-tab signals
|
||||||
- Handle window events and cleanup
|
- Handle window events and cleanup
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout, QWidget, QMessageBox
|
from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox
|
||||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
from PyQt5.QtCore import QThread, pyqtSignal
|
||||||
from PyQt5.QtGui import QFont
|
from PyQt5.QtGui import QFont
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
import config
|
import config
|
||||||
from database import Database
|
from database import Database
|
||||||
from fred_client import FredClient
|
from layer2_technical import TechnicalAnalyzer
|
||||||
from ui.dashboard_tab import DashboardTab
|
from ui.dashboard_tab import DashboardTab
|
||||||
from ui.entry_tab import MonthlyEntryTab
|
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.history_tab import HistoryTab
|
||||||
from ui.settings_tab import SettingsTab
|
from ui.settings_tab import SettingsTab
|
||||||
|
|
||||||
@@ -75,7 +80,7 @@ class FredFetchWorker(QThread):
|
|||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main application window."""
|
"""Main application window — Professional hybrid trading system."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize main window."""
|
"""Initialize main window."""
|
||||||
@@ -92,13 +97,18 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# Initialize Layer 2 components
|
||||||
|
self.tech_analyzer = TechnicalAnalyzer(lookback=config.Z_SCORE_LOOKBACK)
|
||||||
|
|
||||||
# UI components
|
# UI components
|
||||||
self.dashboard_tab = None
|
self.dashboard_tab = None
|
||||||
self.entry_tab = None
|
self.entry_tab = None
|
||||||
|
self.layer2_tab = None
|
||||||
|
self.confluence_tab = None
|
||||||
self.history_tab = None
|
self.history_tab = None
|
||||||
self.settings_tab = None
|
self.settings_tab = None
|
||||||
|
|
||||||
# Worker thread
|
# Worker threads
|
||||||
self.fred_worker = None
|
self.fred_worker = None
|
||||||
|
|
||||||
self._init_ui()
|
self._init_ui()
|
||||||
@@ -113,19 +123,27 @@ class MainWindow(QMainWindow):
|
|||||||
# Tab widget
|
# Tab widget
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
|
|
||||||
# Tab 1: Dashboard
|
# Tab 1: Dashboard (Layer 1)
|
||||||
self.dashboard_tab = DashboardTab(self.db)
|
self.dashboard_tab = DashboardTab(self.db)
|
||||||
tabs.addTab(self.dashboard_tab, config.TAB_NAMES["dashboard"])
|
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)
|
self.entry_tab = MonthlyEntryTab(self.db)
|
||||||
tabs.addTab(self.entry_tab, config.TAB_NAMES["entry"])
|
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)
|
self.history_tab = HistoryTab(self.db)
|
||||||
tabs.addTab(self.history_tab, config.TAB_NAMES["history"])
|
tabs.addTab(self.history_tab, config.TAB_NAMES["history"])
|
||||||
|
|
||||||
# Tab 4: Settings
|
# Tab 6: Settings
|
||||||
self.settings_tab = SettingsTab()
|
self.settings_tab = SettingsTab()
|
||||||
tabs.addTab(self.settings_tab, config.TAB_NAMES["settings"])
|
tabs.addTab(self.settings_tab, config.TAB_NAMES["settings"])
|
||||||
|
|
||||||
@@ -144,6 +162,9 @@ class MainWindow(QMainWindow):
|
|||||||
# Entry tab saves data → History tab refreshes
|
# Entry tab saves data → History tab refreshes
|
||||||
self.entry_tab.data_saved.connect(self.history_tab.refresh_history)
|
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
|
# Dashboard requests FRED fetch → Start worker thread
|
||||||
self.dashboard_tab.fetch_rates_requested.connect(self._fetch_rates)
|
self.dashboard_tab.fetch_rates_requested.connect(self._fetch_rates)
|
||||||
|
|
||||||
@@ -191,6 +212,24 @@ class MainWindow(QMainWindow):
|
|||||||
print(f"[ERROR] {error_msg}")
|
print(f"[ERROR] {error_msg}")
|
||||||
# Don't show error message to user; display gracefully in dashboard
|
# 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):
|
def closeEvent(self, event):
|
||||||
"""Handle window close event."""
|
"""Handle window close event."""
|
||||||
try:
|
try:
|
||||||
@@ -199,6 +238,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.fred_worker.quit()
|
self.fred_worker.quit()
|
||||||
self.fred_worker.wait()
|
self.fred_worker.wait()
|
||||||
|
|
||||||
|
# Stop Layer 2 monitoring
|
||||||
|
if self.layer2_tab:
|
||||||
|
self.layer2_tab.closeEvent(event)
|
||||||
|
|
||||||
# Close database
|
# Close database
|
||||||
self.db.close()
|
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
|
pandas==2.1.3
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
pyinstaller==6.1.0
|
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"
|
status = "ACTIVE"
|
||||||
|
|
||||||
# Classify gap tier
|
# Classify gap tier
|
||||||
if gap >= config.GAP_THRESHOLDS["strong"]:
|
if gap >= 60:
|
||||||
tier = "Strong signal"
|
tier = "Strong signal"
|
||||||
elif gap >= config.GAP_THRESHOLDS["standard"]:
|
elif gap >= config.GAP_THRESHOLDS["standard"]:
|
||||||
tier = "Standard signal"
|
tier = "Standard signal"
|
||||||
@@ -252,6 +252,40 @@ def generate_signal(scores: Dict[str, Dict]) -> Tuple[str, str, str]:
|
|||||||
return signal_text, status, gap_desc
|
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:
|
def get_gap_tier(gap: float) -> str:
|
||||||
"""
|
"""
|
||||||
Classify a gap size into trading tiers.
|
Classify a gap size into trading tiers.
|
||||||
@@ -263,7 +297,7 @@ def get_gap_tier(gap: float) -> str:
|
|||||||
return "no_trade"
|
return "no_trade"
|
||||||
elif gap < config.GAP_THRESHOLDS["standard"]:
|
elif gap < config.GAP_THRESHOLDS["standard"]:
|
||||||
return "weak"
|
return "weak"
|
||||||
elif gap < config.GAP_THRESHOLDS["strong"]:
|
elif gap < 60:
|
||||||
return "standard"
|
return "standard"
|
||||||
else:
|
else:
|
||||||
return "strong"
|
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
|
# Signal to request FRED fetch
|
||||||
fetch_rates_requested = pyqtSignal()
|
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):
|
def __init__(self, db: Database):
|
||||||
"""
|
"""
|
||||||
Initialize Dashboard tab.
|
Initialize Dashboard tab.
|
||||||
@@ -152,6 +156,17 @@ class DashboardTab(QWidget):
|
|||||||
signal_text = signal_data["signal"]
|
signal_text = signal_data["signal"]
|
||||||
gap = signal_data["gap"]
|
gap = signal_data["gap"]
|
||||||
status = signal_data["status"]
|
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
|
# Update signal label
|
||||||
self.signal_label.setText(signal_text)
|
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()
|
||||||
+77
-1
@@ -20,6 +20,7 @@ from PyQt5.QtCore import Qt, pyqtSignal, QThread
|
|||||||
from PyQt5.QtGui import QFont
|
from PyQt5.QtGui import QFont
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
import config
|
import config
|
||||||
|
from data_feeder import Mt5DataFeeder
|
||||||
from fred_client import FredClient
|
from fred_client import FredClient
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -48,6 +49,31 @@ class FredTestWorker(QThread):
|
|||||||
self.test_complete.emit(False, f"✗ Connection failed: {str(e)}")
|
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):
|
class SettingsTab(QWidget):
|
||||||
"""Settings and configuration tab."""
|
"""Settings and configuration tab."""
|
||||||
|
|
||||||
@@ -57,7 +83,7 @@ class SettingsTab(QWidget):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize Settings tab."""
|
"""Initialize Settings tab."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.env_path = Path(__file__).parent.parent.parent / ".env"
|
self.env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
|
||||||
self._init_ui()
|
self._init_ui()
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
@@ -100,6 +126,32 @@ class SettingsTab(QWidget):
|
|||||||
layout.addWidget(api_group)
|
layout.addWidget(api_group)
|
||||||
layout.addSpacing(10)
|
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 ======
|
# ====== Central Bank Targets ======
|
||||||
cb_group = QGroupBox("Central Bank Inflation Targets (%)")
|
cb_group = QGroupBox("Central Bank Inflation Targets (%)")
|
||||||
cb_layout = QVBoxLayout()
|
cb_layout = QVBoxLayout()
|
||||||
@@ -267,6 +319,9 @@ class SettingsTab(QWidget):
|
|||||||
api_key = env_vars.get('FRED_API_KEY', '')
|
api_key = env_vars.get('FRED_API_KEY', '')
|
||||||
self.api_key_input.setText(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)
|
# Load weights (convert from decimal to percentage)
|
||||||
weight_rate = float(env_vars.get('WEIGHT_RATE', config.WEIGHT_RATE)) * 100
|
weight_rate = float(env_vars.get('WEIGHT_RATE', config.WEIGHT_RATE)) * 100
|
||||||
weight_cpi = float(env_vars.get('WEIGHT_CPI', config.WEIGHT_CPI)) * 100
|
weight_cpi = float(env_vars.get('WEIGHT_CPI', config.WEIGHT_CPI)) * 100
|
||||||
@@ -333,6 +388,23 @@ class SettingsTab(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.test_status.setStyleSheet("color: #e74c3c; font-weight: bold;")
|
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):
|
def _save_settings(self):
|
||||||
"""Save settings to .env file."""
|
"""Save settings to .env file."""
|
||||||
try:
|
try:
|
||||||
@@ -351,6 +423,7 @@ class SettingsTab(QWidget):
|
|||||||
|
|
||||||
# Prepare new .env content
|
# Prepare new .env content
|
||||||
api_key = self.api_key_input.text().strip()
|
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_rate = self.weight_rate_spin.value() / 100
|
||||||
weight_cpi = self.weight_cpi_spin.value() / 100
|
weight_cpi = self.weight_cpi_spin.value() / 100
|
||||||
weight_pmi = self.weight_pmi_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"
|
auto_fetch = "true" if self.auto_fetch_check.isChecked() else "false"
|
||||||
|
|
||||||
env_content = f"""FRED_API_KEY={api_key}
|
env_content = f"""FRED_API_KEY={api_key}
|
||||||
|
MT5_SYMBOL_SUFFIX={mt5_suffix}
|
||||||
DB_PATH=apex.db
|
DB_PATH=apex.db
|
||||||
MIN_GAP={min_gap}
|
MIN_GAP={min_gap}
|
||||||
WEIGHT_RATE={weight_rate:.2f}
|
WEIGHT_RATE={weight_rate:.2f}
|
||||||
@@ -392,6 +466,8 @@ DEBUG=false
|
|||||||
)
|
)
|
||||||
|
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
|
self.api_key_input.clear()
|
||||||
|
self.mt5_suffix_input.clear()
|
||||||
self.weight_rate_spin.setValue(50)
|
self.weight_rate_spin.setValue(50)
|
||||||
self.weight_cpi_spin.setValue(30)
|
self.weight_cpi_spin.setValue(30)
|
||||||
self.weight_pmi_spin.setValue(20)
|
self.weight_pmi_spin.setValue(20)
|
||||||
|
|||||||
Reference in New Issue
Block a user