From 5d4ffb1da5f84a3a43315691f6693e6b71d0f6f2 Mon Sep 17 00:00:00 2001 From: saber Date: Thu, 18 Jun 2026 09:35:03 +0100 Subject: [PATCH] layer one v4 --- .env.example | 11 + .gitignore | 2 + EXCEL_IMPORT_GUIDE.md | 178 ----- EXCEL_IMPORT_PROMPT.md | 100 --- GITHUB_QUICK_START.md | 71 -- LICENSE | 21 + README.md | 20 + apex.db-shm | Bin 32768 -> 0 bytes apex.db-wal | Bin 127752 -> 0 bytes config.py | 54 +- confluence_filter.py | 77 ++- create_excel_template.py | 97 --- data_feeder.py | 267 +------- database.py | 62 ++ layer2_technical.py | 155 +++-- main.py | 258 +++++++- main_window.py | 9 +- project_structure_and_resume.md | 878 ------------------------- requirements.txt | 2 + risk_management.py | 29 +- scorer.py | 48 -- tests/__init__.py | 0 tests/test_currency_strength_matrix.py | 107 +++ ui/confluence_tab.py | 218 +++--- ui/dashboard_tab.py | 176 ++--- ui/entry_tab.py | 45 +- ui/history_tab.py | 9 +- ui/layer2_monitor_tab.py | 208 ++++-- ui/settings_tab.py | 21 +- 29 files changed, 1056 insertions(+), 2067 deletions(-) delete mode 100644 EXCEL_IMPORT_GUIDE.md delete mode 100644 EXCEL_IMPORT_PROMPT.md delete mode 100644 GITHUB_QUICK_START.md create mode 100644 LICENSE create mode 100644 README.md delete mode 100644 apex.db-shm delete mode 100644 apex.db-wal delete mode 100644 create_excel_template.py delete mode 100644 project_structure_and_resume.md create mode 100644 tests/__init__.py create mode 100644 tests/test_currency_strength_matrix.py diff --git a/.env.example b/.env.example index 4639769..6382e07 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ DB_PATH=apex.db # Debugging & Logging # ============================================================================ DEBUG=true +LOG_FILE=apex.log # ============================================================================ # Scoring Weights (must sum to 1.0) @@ -84,6 +85,16 @@ MAX_PORTFOLIO_LEVERAGE=2.0 USE_GRID_HEDGING=true GRID_LEVELS=3 +# ============================================================================ +# Live Execution (Phase 4 โ€” OFF by default for safety) +# ============================================================================ +# Set to true ONLY when connected to a funded MT5 demo/live account +LIVE_TRADING_ENABLED=false +# Max daily loss as fraction of account balance (5% default) +MAX_DAILY_LOSS_PCT=0.05 +# Max total open exposure as fraction of account balance (20% default) +MAX_BASKET_EXPOSURE_PCT=0.20 + # ============================================================================ # Mock Data Feeder Configuration (for testing without MT5) # ============================================================================ diff --git a/.gitignore b/.gitignore index 90cdae3..f498c26 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ venv.bak/ # Database files *.db +*.db-shm +*.db-wal *.sqlite *.sqlite3 apex.db diff --git a/EXCEL_IMPORT_GUIDE.md b/EXCEL_IMPORT_GUIDE.md deleted file mode 100644 index eedc819..0000000 --- a/EXCEL_IMPORT_GUIDE.md +++ /dev/null @@ -1,178 +0,0 @@ -# Excel Import Guide โ€” APEX Layer 1 - -## **Quick Start** - -1. **Ask AI for data** (use the prompt in [EXCEL_IMPORT_PROMPT.md](EXCEL_IMPORT_PROMPT.md)) -2. **Download the Excel file** -3. Open APEX Layer 1 app โ†’ **Monthly Entry tab** -4. Click **"๐Ÿ“Š Import Excel"** button -5. Select your file โ†’ Click **"Save & Calculate Scores"** - ---- - -## **Supported File Formats** - -### **Format 1: Multi-Sheet Excel** (Recommended) -**File:** `monthly_data.xlsx` - -**Sheet 1: CPI** -``` -Currency | Target % | Actual CPI % ----------|----------|------------- -USD | 2.0 | 3.2 -EUR | 2.0 | 2.8 -GBP | 2.0 | 3.1 -JPY | 2.0 | 1.9 -AUD | 2.5 | 3.5 -CAD | 2.0 | 2.3 -CHF | 1.5 | 1.2 -NZD | 2.0 | 3.8 -``` - -**Sheet 2: PMI** -``` -Currency | Composite PMI ----------|--------------- -USD | 52.3 -EUR | 48.7 -GBP | 51.2 -JPY | 49.5 -AUD | 50.1 -CAD | 51.8 -CHF | 49.2 -NZD | 52.5 -``` - ---- - -### **Format 2: Single-Sheet Excel** -**File:** `monthly_data.xlsx` - -``` -Currency | Target_CPI | Actual_CPI | Composite_PMI ----------|------------|------------|--------------- -USD | 2.0 | 3.2 | 52.3 -EUR | 2.0 | 2.8 | 48.7 -GBP | 2.0 | 3.1 | 51.2 -JPY | 2.0 | 1.9 | 49.5 -AUD | 2.5 | 3.5 | 50.1 -CAD | 2.0 | 2.3 | 51.8 -CHF | 1.5 | 1.2 | 49.2 -NZD | 2.0 | 3.8 | 52.5 -``` - ---- - -### **Format 3: CSV File** -**File:** `monthly_data.csv` - -```csv -Currency,Target_CPI,Actual_CPI,Composite_PMI -USD,2.0,3.2,52.3 -EUR,2.0,2.8,48.7 -GBP,2.0,3.1,51.2 -JPY,2.0,1.9,49.5 -AUD,2.5,3.5,50.1 -CAD,2.0,2.3,51.8 -CHF,1.5,1.2,49.2 -NZD,2.0,3.8,52.5 -``` - ---- - -## **Data Requirements** - -### **All 8 Currencies Required (in any order):** -- USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD - -### **Value Ranges:** -- **CPI Actual:** Any realistic percentage (e.g., 1.0 - 5.0%) -- **PMI:** 0-100 scale (50 = neutral, >50 = expanding, <50 = contracting) -- **Use decimal format:** `3.45`, not `3.45%` - -### **Important:** -- No merge cells or complex formatting -- Column headers needed (any name with "CPI", "PMI", "Currency" is recognized) -- Empty cells or 0 values = not imported - ---- - -## **Generate Template Files** - -Run this command to create example files: - -```bash -python create_excel_template.py -``` - -This creates: -- `example_monthly_data.xlsx` (multi-sheet) -- `example_monthly_data_single_sheet.xlsx` (single sheet) -- `example_monthly_data.csv` (CSV format) - ---- - -## **AI Prompt for Data Generation** - -See [EXCEL_IMPORT_PROMPT.md](EXCEL_IMPORT_PROMPT.md) for ready-to-use prompt templates. - -### **Quick Prompt:** -``` -Generate realistic monthly economic data for the 8 major currencies -for June 2026 in Excel format: - -CPI: Actual inflation rates (YoY %) -PMI: Composite PMI readings (0-100 scale, 50=neutral) - -Currencies: USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD - -Provide in two sheets: -- Sheet 1: CPI (Currency, Target %, Actual CPI %) -- Sheet 2: PMI (Currency, Composite PMI) -``` - ---- - -## **Troubleshooting** - -| Problem | Solution | -|---------|----------| -| "Import Error: Sheet not found" | Use correct sheet names: "CPI" and "PMI" | -| "No data imported" | Check column names contain "Currency", "CPI", "PMI" | -| "0 values not imported" | Use non-zero values; 0 = skip | -| "File locked" | Close Excel before importing | -| "Column mismatch" | Ensure 8 currencies (USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD) | - ---- - -## **Workflow Example** - -1. **Ask AI:** - ``` - Create an Excel file with CPI and PMI data for the 8 major - currencies for June 2026. Make it realistic based on current - economic trends. - ``` - -2. **Download** the Excel file from AI - -3. **Open APEX Layer 1** โ†’ Monthly Entry tab - -4. **Click Import Excel** โ†’ Select the file - -5. **Data auto-fills** the entry form - -6. **Click Save & Calculate Scores** โ†’ Done! - -7. **Check Dashboard** tab for the generated signal - ---- - -## **Notes** - -- App auto-detects file format (Excel or CSV) -- If Excel has both formats, app tries multi-sheet first -- PMI default is 50 (neutral); enter actual PMI, not delta -- CPI target values are auto-looked up from config -- You can edit values after import before saving - diff --git a/EXCEL_IMPORT_PROMPT.md b/EXCEL_IMPORT_PROMPT.md deleted file mode 100644 index 8ffaf9e..0000000 --- a/EXCEL_IMPORT_PROMPT.md +++ /dev/null @@ -1,100 +0,0 @@ -# APEX Layer 1 โ€” Excel Data Import Prompt - -Use this prompt template to ask AI (ChatGPT, Claude, etc.) to generate monthly economic data in the required Excel format. - ---- - -## **Example Prompt for AI:** - -``` -I need you to create an Excel file with monthly economic data for the 8 major currencies. - -The file should have two sheets: - -**Sheet 1: CPI** -- Column A: Currency (USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD) -- Column B: Target % (2.0, 2.0, 2.0, 2.0, 2.5, 2.0, 1.5, 2.0) -- Column C: Actual CPI % (provide realistic values for June 2026) - -**Sheet 2: PMI** -- Column A: Currency (USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD) -- Column B: Composite PMI (provide realistic values between 40-60, where 50=neutral) - -Format: -- Use decimal values (e.g., 3.45, not "3.45%") -- Include header row -- One row per currency -- No merge cells or formulas - -Provide realistic economic data for June 2026 based on: -- Recent inflation trends -- Manufacturing activity -- Monetary policy directions - -Please generate this as a downloadable Excel file or CSV format. -``` - ---- - -## **Required Data Format:** - -### **CPI Sheet:** -| Currency | Target % | Actual CPI % | -|----------|----------|-------------| -| USD | 2.0 | 3.2 | -| EUR | 2.0 | 2.8 | -| GBP | 2.0 | 3.1 | -| JPY | 2.0 | 1.9 | -| AUD | 2.5 | 3.5 | -| CAD | 2.0 | 2.3 | -| CHF | 1.5 | 1.2 | -| NZD | 2.0 | 3.8 | - -### **PMI Sheet:** -| Currency | Composite PMI | -|----------|---------------| -| USD | 52.3 | -| EUR | 48.7 | -| GBP | 51.2 | -| JPY | 49.5 | -| AUD | 50.1 | -| CAD | 51.8 | -| CHF | 49.2 | -| NZD | 52.5 | - ---- - -## **How to Use:** - -1. Copy the prompt above and send to ChatGPT/Claude -2. Ask for Excel file download -3. Save the Excel file -4. In APEX Layer 1 app โ†’ Monthly Entry tab โ†’ Click "Import Excel" -5. Select your Excel file -6. Data auto-fills the entry form -7. Click "Save & Calculate Scores" - ---- - -## **Example AI Responses to Accept:** - -- **ChatGPT**: Says "I can't create actual files, but here's the data:" โ†’ Copy to Excel manually -- **Claude**: May provide CSV format โ†’ Import that -- **Perplexity/Other**: Often provides downloadable formats directly - ---- - -## **Alternative: Generate Test Data** - -Ask AI: -``` -Create realistic monthly CPI and PMI data for the 8 major currencies (USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD) -for June 2026 in this format: - -Currency,Target_CPI,Actual_CPI,Composite_PMI -... - -Make it realistic based on economic forecasts and recent trends. -``` - -Then paste the CSV into your Excel file. diff --git a/GITHUB_QUICK_START.md b/GITHUB_QUICK_START.md deleted file mode 100644 index afdd12d..0000000 --- a/GITHUB_QUICK_START.md +++ /dev/null @@ -1,71 +0,0 @@ -# GitHub Setup โ€” QUICK START (5 Minutes) - -## **๐Ÿš€ TL;DR โ€” Copy & Paste** - -### **1. Create GitHub Repository** -- Go to [github.com](https://github.com) -- Click **"+" โ†’ New repository** -- Name: `apex_layer1` -- Select **"Add .gitignore: Python"** -- Click **"Create repository"** -- **Copy the HTTPS URL** (looks like: `https://github.com/YOUR_USERNAME/apex_layer1.git`) - -### **2. Open PowerShell** -```powershell -cd "c:\Users\sober\Desktop\QuantCore FX\apex_layer1" - -# First time setup -git config --global user.name "Your Name" -git config --global user.email "your.email@gmail.com" - -# Initialize Git -git init - -# Add all files -git add . - -# Create first commit -git commit -m "Initial commit: APEX Layer 1 โ€” Currency Strength Engine" - -# Add remote (replace with YOUR URL from GitHub) -git remote add origin https://github.com/YOUR_USERNAME/apex_layer1.git - -# Rename branch to main -git branch -M main - -# Push to GitHub -git push -u origin main -``` - -### **3. Authenticate** -When GitHub asks for password: -- Use your **Personal Access Token** (not your password) - -**To create a token:** -1. GitHub โ†’ Settings โ†’ Developer settings โ†’ Personal access tokens -2. Click "Generate new token" -3. Name: `apex_layer1` -4. Scope: โœ… `repo` -5. Copy token -6. Paste when prompted - ---- - -## **โœ… Verify** -- Refresh GitHub in browser -- See your files? โœ… Success! - ---- - -## **๐Ÿ“ Future Updates** (Easy) -```powershell -# After making changes: -git add . -git commit -m "Description of what changed" -git push origin main -``` - ---- - -## **โ“ Need Help?** -See [GITHUB_SETUP_GUIDE.md](GITHUB_SETUP_GUIDE.md) for detailed instructions & troubleshooting. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..12f4f4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sabermrddz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c02540 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# APEX โ€” Currency Strength Engine (QuantCore FX) + +Desktop-based **Currency Strength Engine** implementing institutional-quality **statistical arbitrage (StatArb)** for the forex market across 8 major currencies and 56 directional pairs. + +## Architecture + +Full documentation: [`project_structure_and_resume.md`](project_structure_and_resume.md) + +### Layers +- **Layer 1 (Fundamental)** โ€” Interest rates (FRED), CPI, PMI โ†’ currency scores 0โ€“100 +- **Layer 2 (Technical)** โ€” Bar-anchored Z-scores (288 M5 bars = 24h) across all 28 pairs โ†’ S.A.T.O.R.I. currency strength matrix + +### Quick Start +```bash +pip install -r requirements.txt +cp .env.example .env # add your FRED_API_KEY +python main.py +``` + +> **Warning:** This system generates trading signals for educational/paper-trading use. Live execution (Phase 4) defaults to **disabled**. See rebuild plan for details. diff --git a/apex.db-shm b/apex.db-shm deleted file mode 100644 index 4bd9bd624a403b1e0293f2293b5e1d8558874a50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)sZj(`5C-6Xm-}As;f?@B3@gAu3@8iQnY@P-<(g+s8j z;_Iree)DSTb=Ma_SMP_ZW2|H+=^d21Q1bfa^y9ey`gwNsaB_40^!#{nbbojGc3b`N z|MJvN)bD-`M01_3=Fly5yyRf5XZn5jAK%Rg3IPHH2oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 zh9=M{TVEzJo!QK1F}MDv>`CrMpHcrjD$e9Y}T5}Oy@A<*KlY|8n{E-Z0Wi<+snTvo4x@je4 z%l`1!){og-Yjfykt!1{E2`#2A|FBK1wQfJgX3_jX+6s)))z)({u;YN!PPd)g@qIVA z?4I-RobT&-{<_cSJjdwH+~5g)H^bw}^CVw&>vq*{T-JN|)=f`0?+6{(Dn9c4{qyVI z`uL5O_Y%Xo$pg}Ek91u8;)MVL2q1s}0tg_000IagfB*u{2^9Gf~tmkUqp`R zAz2S8`VvKpD{5R;!m6gLQLVbZsS;M$sEREq!t2q1s}0tg_000Iag@P8Mul^2*Jsy5{XcD|W)q_OdZ z70K@f{8HE0{sQ8S7Xk<%fB*srAbz^+5I_I{ z1Q0*~0R#|000CzNvi$j8@kK;1`JUXrFw>u(Ep{gq7h~FYaOLK$>V^IHEaW}{XNHMh z5kLR|1Q0*~0R#|0009ILuqR;aK7yd=%XA+>BD!GdAK&ho&3y#+MuAocAbtC#;vyqC8$6gvl$=LX6;dJ+NimWEe15U!@Y!0*vd-(UaXjz01NZkln_f&c;t zAbz5xN8(B(98+{fEuHLn1kt{_g~x`z86YoUHHsWZ009ILKmY**5I_I{1Q0+Vodj&< z1?GtgOnHI!><6~px8<}%ULc*umTN%(0R#|0009ILKmY**5O9+~%JKpe%m12T=&|JT zzs3Fn6Ah-!3+!zEI=W_l_2=XT+%)5;1px#QKmY**5I_I{1Q0;L{Q|b~0`rB7DKGHV zv5`H#*ADC^FW~+eLxl(+fB*srAbjQ$b!JoyL}HK>z^+5I_I{ z1Q0*~0R#|mlR(Py0+*NnwXkeNqot95@H_(Ryg<#R;p-QdJa`j%0XNM!YC!-21Q0*~ z0R#|0009ILaKC`9yg*2}nDPR5H7#HA@Y}ubk{59QjG;mV5I_I{1Q0*~0R#|000CzN zEb; zk3d#5RaTQx1aYky;wQIm-LBe=%X$wt1U|Xg_Q8v%$O~AFBF7Ox009ILKmY**5I_I{ z1Q19k0b6;25>bIEFK}+KzkF8t7WNlNr=jIq5I_I{1Q0*~0R#|0009JCC6JoDfNA+( zSEGj2^9W20rpgO^|Iw*?x9@$jn7n|irX0l}fB*srAb 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) - """ + ) -> Tuple[bool, str, float, Optional[Dict]]: 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() @@ -131,18 +116,22 @@ class ConfluenceFilter: 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, - ) + direction = "SHORT" if confidence > 50 else "LONG" + entry = self.tech_analyzer.get_last_price(mc) or 0.0 + sl_tp = self.tech_analyzer.calculate_sl_tp(mc, direction, entry) + if self.db: + self.db.save_confluence_signal( + pair=mc, signal_type="MATRIX_DIVERGENCE", + confidence=confidence, z_score=None, gap=gap, + reason=f"Matrix cross {mc} gap={gap:.1f}ฯƒ", + layer1_active=self.layer1_is_active, + ) + return (True, f"MATRIX DIVERGENCE: {mc} (Gap: {gap:.1f}ฯƒ)", confidence, sl_tp) else: self.confluence_strength = 0.0 self.last_confluence_check = datetime.now() - return False, f"MATRIX DIVERGENCE BLOCKED โ€” {reason}", 0.0 + return (False, f"MATRIX DIVERGENCE BLOCKED โ€” {reason}", 0.0, None) - # === 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) @@ -161,14 +150,19 @@ class ConfluenceFilter: 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, - ) + direction = "SHORT" if z_score > 0 else "LONG" + entry = self.tech_analyzer.get_last_price(pair) or 0.0 + sl_tp = self.tech_analyzer.calculate_sl_tp(pair, direction, entry) + if self.db: + self.db.save_confluence_signal( + pair=pair, signal_type="PAIR_EXTREME", + confidence=confidence, z_score=z_score, + reason=f"Z={z_score:.2f} within macro boundary", + layer1_active=self.layer1_is_active, + ) + return (True, f"PAIR EXTREME: {pair} Z={z_score:.2f}", confidence, sl_tp) - return False, "No valid signals within macro boundary", 0.0 + return (False, "No valid signals within macro boundary", 0.0, None) def check_exit_confluence(self) -> Tuple[bool, str]: """Check if position should exit (mean reversion / boundary shift).""" @@ -247,7 +241,7 @@ class ConfluenceFilter: } def get_all_signals(self, current_prices: Dict[str, float] = None) -> Dict[str, Dict]: - """Get all available signals ranked by strength.""" + """Get all available signals ranked by strength, with SL/TP.""" signals = {} self._build_matrix(current_prices) @@ -256,20 +250,21 @@ class ConfluenceFilter: 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: + direction = 'SHORT' if strength > 50 else 'LONG' + entry = self.tech_analyzer.get_last_price(mc) or 0.0 + sl_tp = self.tech_analyzer.calculate_sl_tp(mc, "LONG" if direction == "LONG" else "SHORT", entry) signals[mc] = { 'pair': mc, 'type': 'MATRIX_DIVERGENCE', 'strength': strength, 'reason': f"Matrix cross {mc} (spread: {gap:.2f}ฯƒ)", - 'direction': 'SHORT' if strength > 50 else 'LONG', + 'direction': direction, + **sl_tp, } - # 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 @@ -288,12 +283,16 @@ class ConfluenceFilter: allowed, _ = self._check_boundary(short_ccy, long_ccy) if allowed: strength = min(abs(z_score) / 3.0, 1.0) * 100 + direction = 'SHORT' if z_score > 0 else 'LONG' + entry = self.tech_analyzer.get_last_price(pair) or 0.0 + sl_tp = self.tech_analyzer.calculate_sl_tp(pair, "LONG" if direction == "LONG" else "SHORT", entry) 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', + 'direction': direction, + **sl_tp, } return signals diff --git a/create_excel_template.py b/create_excel_template.py deleted file mode 100644 index 4d4343f..0000000 --- a/create_excel_template.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -APEX Layer 1 โ€” Example Excel Template Generator - -Run this script to create example Excel files with the correct format. - -Usage: - python create_excel_template.py - -This will generate: - - example_monthly_data.xlsx (multi-sheet format) - - example_monthly_data_single_sheet.xlsx (single sheet format) -""" - -import pandas as pd - -import config -from datetime import datetime - -def create_multi_sheet_template(): - """Create Excel with separate CPI and PMI sheets.""" - - # CPI Data - cpi_data = { - 'Currency': config.CURRENCIES, - 'Target %': [config.CB_TARGETS[c] for c in config.CURRENCIES], - 'Actual CPI %': [3.2, 2.8, 3.1, 1.9, 3.5, 2.3, 1.2, 3.8] # Example values - } - - # PMI Data - pmi_data = { - 'Currency': config.CURRENCIES, - 'Composite PMI': [52.3, 48.7, 51.2, 49.5, 50.1, 51.8, 49.2, 52.5] # Example values - } - - # Create Excel file - with pd.ExcelWriter('example_monthly_data.xlsx', engine='openpyxl') as writer: - pd.DataFrame(cpi_data).to_excel(writer, sheet_name='CPI', index=False) - pd.DataFrame(pmi_data).to_excel(writer, sheet_name='PMI', index=False) - - print("โœ“ Created: example_monthly_data.xlsx") - print(" - Sheet 1: CPI data") - print(" - Sheet 2: PMI data") - -def create_single_sheet_template(): - """Create Excel with all data in one sheet.""" - - data = { - 'Currency': config.CURRENCIES, - 'Target_CPI': [config.CB_TARGETS[c] for c in config.CURRENCIES], - 'Actual_CPI': [3.2, 2.8, 3.1, 1.9, 3.5, 2.3, 1.2, 3.8], - 'Composite_PMI': [52.3, 48.7, 51.2, 49.5, 50.1, 51.8, 49.2, 52.5] - } - - df = pd.DataFrame(data) - df.to_excel('example_monthly_data_single_sheet.xlsx', index=False) - - print("โœ“ Created: example_monthly_data_single_sheet.xlsx") - print(" - All data in one sheet") - -def create_csv_template(): - """Create CSV example.""" - - data = { - 'Currency': config.CURRENCIES, - 'Target_CPI': [config.CB_TARGETS[c] for c in config.CURRENCIES], - 'Actual_CPI': [3.2, 2.8, 3.1, 1.9, 3.5, 2.3, 1.2, 3.8], - 'Composite_PMI': [52.3, 48.7, 51.2, 49.5, 50.1, 51.8, 49.2, 52.5] - } - - df = pd.DataFrame(data) - df.to_csv('example_monthly_data.csv', index=False) - - print("โœ“ Created: example_monthly_data.csv") - -if __name__ == "__main__": - print(f"APEX Layer 1 - Template Generator") - print(f"Month: {datetime.now().strftime('%Y-%m')}\n") - - try: - create_multi_sheet_template() - create_single_sheet_template() - create_csv_template() - - print("\n" + "="*60) - print("Templates created successfully!") - print("="*60) - print("\nUsage:") - print("1. Open any template file") - print("2. Replace example values with real economic data") - print("3. In APEX app: Monthly Entry tab โ†’ Import Excel") - print("4. Select your file and click Open") - print("5. Click 'Save & Calculate Scores'") - - except Exception as e: - print(f"\nโœ— Error creating templates: {e}") - print("\nMake sure you have openpyxl and pandas installed:") - print(" pip install openpyxl pandas") diff --git a/data_feeder.py b/data_feeder.py index 48e3f36..f6ffab9 100644 --- a/data_feeder.py +++ b/data_feeder.py @@ -10,9 +10,6 @@ Fetches forex data from a local MetaTrader 5 terminal. Requirements: - MetaTrader 5 terminal installed and running with a demo/live account - pip install MetaTrader5 - -Fallback: -- MockDataFeeder for testing without MT5 """ import time @@ -219,7 +216,7 @@ class Mt5DataFeeder: 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] + closes = [r["close"] for r in rates] all_closes[pair] = closes return all_closes @@ -263,11 +260,11 @@ class Mt5DataFeeder: 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, + "time": datetime.fromtimestamp(r["time"]).isoformat(), + "open": r["open"], + "high": r["high"], + "low": r["low"], + "close": r["close"], }) return candles @@ -314,256 +311,4 @@ class Mt5DataFeeder: print("[MT5] Streaming stopped") -class MockDataFeeder: - """Mock data feeder for testing โ€” simulates intraday prices for all 28 pairs.""" - USD_PAIRS = ["EUR_USD", "GBP_USD", "AUD_USD", "NZD_USD", - "USD_JPY", "USD_CAD", "USD_CHF"] - - def __init__(self): - self.base_prices = { - "EUR_USD": 1.0850, - "GBP_USD": 1.2650, - "AUD_USD": 0.6650, - "NZD_USD": 0.6050, - "USD_JPY": 149.50, - "USD_CAD": 1.3750, - "USD_CHF": 0.8920, - } - self.price_callbacks = [] - self.connected = True - self._running = False - self._cached_bars: Dict[str, List[float]] = {} - self._tick_index = 0 - - def test_connection(self) -> bool: - return True - - def get_all_major_pairs(self) -> List[str]: - pairs = [] - for base in config.CURRENCIES: - for quote in config.CURRENCIES: - if base != quote: - pairs.append(f"{base}_{quote}") - return pairs - - def generate_mock_bars(self, n_bars: int = 288) -> Dict[str, List[float]]: - """Generate n_bars simulated M5 close prices with realistic behavior. - - Uses an Ornstein-Uhlenbeck process (mean-reverting random walk with - drift) for each of the 7 USD pairs, then derives all 28 cross rates. - This gives 24h (288 M5 bars) of realistic forex data where Z-scores - reflect genuine multi-hour deviations. - - Caches the generated bars so subsequent tick prices are anchored - to the last bar close โ€” not the initial base price. - """ - import random - bars: Dict[str, List[float]] = {} - usd_pair_bars: Dict[str, List[float]] = {} - - for pair in self.USD_PAIRS: - base = self.base_prices.get(pair, 1.0) - series = [] - price = base - drift = random.uniform(-config.MOCK_DRIFT, config.MOCK_DRIFT) - theta = config.MOCK_THETA - long_term_mean = base - - for i in range(n_bars): - noise = random.gauss(0, config.MOCK_NOISE_STD) - reversion = theta * (long_term_mean - price) - seasonal = config.MOCK_SEASONAL_AMP * random.uniform(-1, 1) - price = price + reversion + drift + seasonal + noise - series.append(price) - - usd_pair_bars[pair] = series - - pairs_list = self.get_all_major_pairs() - for pair in pairs_list: - base_c, quote_c = pair.split("_") - derived = [] - for i in range(n_bars): - usd_rates = {"USD": 1.0} - for up in self.USD_PAIRS: - b, q = up.split("_") - mid = usd_pair_bars[up][i] - if b == "USD": - usd_rates[q] = 1.0 / mid if mid else 0 - else: - usd_rates[b] = mid - bv = usd_rates.get(base_c, 0) - qv = usd_rates.get(quote_c, 1) - derived.append(bv / qv if qv else 0) - bars[pair] = derived - - self._cached_bars = bars - self._tick_index = 0 - return bars - - def _current_bar_prices(self) -> Dict[str, float]: - """Get the latest bar close prices for all pairs.""" - if not self._cached_bars: - return {} - prices = {} - for pair in self.get_all_major_pairs(): - bars = self._cached_bars.get(pair) - if bars: - prices[pair] = bars[-1] - return prices - - def _tick_price(self, pair: str) -> float: - """Return price anchored to last bar close + small noise. - - Uses the last bar close from _cached_bars as the anchor, so the - tick price is always near the most recent bar and Z-scores reflect - the bar position relative to the 24-hour history, not random noise. - """ - import random - last_bars = self._cached_bars.get(pair) if self._cached_bars else None - if last_bars and len(last_bars) > 0: - base = last_bars[-1] - else: - base = self.base_prices.get(pair, 1.0) - return base + random.uniform(-config.MOCK_TICK_NOISE, config.MOCK_TICK_NOISE) - - def get_current_price(self, currency_pair: str) -> Optional[Dict]: - """Get price for any pair, deriving cross rates from USD pairs.""" - import random - usd_rates = {} - for p in self.USD_PAIRS: - base, quote = p.split("_") - mid = self._tick_price(p) - if base == "USD": - usd_rates[quote] = 1.0 / mid if mid != 0 else None - else: - usd_rates[base] = mid - usd_rates["USD"] = 1.0 - - base_c, quote_c = currency_pair.split("_") - base_val = usd_rates.get(base_c) - quote_val = usd_rates.get(quote_c) - if base_val is None or quote_val is None: - return None - price = base_val / quote_val - return { - "pair": currency_pair, - "time": datetime.now().isoformat(), - "mid": price, - "bid": price - config.MOCK_BID_ASK_SPREAD, - "ask": price + config.MOCK_BID_ASK_SPREAD, - } - - def fetch_all_rates(self) -> Dict[str, float]: - """Derive all 28 cross rates from 7 USD pairs (same as Mt5DataFeeder).""" - usd_rates: Dict[str, Optional[float]] = {} - for p in self.USD_PAIRS: - base, quote = p.split("_") - mid = self._tick_price(p) - if base == "USD": - usd_rates[quote] = 1.0 / mid if mid != 0 else None - else: - usd_rates[base] = mid - usd_rates["USD"] = 1.0 - - rates = {} - for base in config.CURRENCIES: - for quote in config.CURRENCIES: - if base == quote: - continue - bv = usd_rates.get(base) - qv = usd_rates.get(quote) - if bv is not None and qv is not None: - rates[f"{base}_{quote}"] = bv / qv - return rates - - def get_order_book(self, currency_pair: str) -> Optional[Dict]: - """Mock order book โ€” simulated bid/ask/spread.""" - price = self.get_current_price(currency_pair) - if not price: - return None - return { - "pair": currency_pair, - "bid": price["mid"] - config.MOCK_BID_ASK_SPREAD, - "ask": price["mid"] + config.MOCK_BID_ASK_SPREAD, - "spread": config.MOCK_BID_ASK_SPREAD * 2, - "mid": price["mid"], - "time": datetime.now().isoformat(), - } - - def fetch_historical_closes_all_pairs( - self, days: int = 30, interval: str = "1d" - ) -> Dict[str, List[float]]: - """Mock historical close prices โ€” random walk for all 28 pairs.""" - import random - closes: Dict[str, List[float]] = {} - pairs = self.get_all_major_pairs() - all_rates = self.fetch_all_rates() - for pair in pairs: - base = all_rates.get(pair, 1.0) - series = [] - price = base - for _ in range(days): - price += random.uniform(-config.MOCK_HISTORICAL_DAILY_NOISE, config.MOCK_HISTORICAL_DAILY_NOISE) - series.append(price) - closes[pair] = series - return closes - - def get_historical_candles( - self, - from_currency: str = "USD", - to_currency: str = "JPY", - interval: str = "1min", - outputsize: str = "compact", - ) -> Optional[List[Dict]]: - import random - candles = [] - count = 100 if outputsize == "full" else 20 - pair = f"{from_currency}_{to_currency}" - base = self._cached_bars.get(pair, [None])[-1] if self._cached_bars.get(pair) else 1.0 - for i in range(count): - noise = random.uniform(-0.005, 0.005) - price = base + noise - candles.append({ - "time": (datetime.now() - timedelta(minutes=count - i)).isoformat(), - "open": price, - "high": price + 0.01, - "low": price - 0.01, - "close": price + random.uniform(-0.005, 0.005), - }) - return candles - - def stream_prices(self, instruments: List[str], callback: Callable, poll_interval: int = 1): - """Derive all 28 rates and feed callback for each instrument (same as MT5).""" - self.price_callbacks.append(callback) - self._running = True - - def mock_stream(): - while self._running: - all_rates = self.fetch_all_rates() - for pair in instruments: - rate = all_rates.get(pair) - if rate: - callback({ - "pair": pair, - "time": datetime.now().isoformat(), - "mid": rate, - "bid": rate, - "ask": rate, - }) - time.sleep(poll_interval) - - thread = threading.Thread(target=mock_stream, daemon=True) - thread.start() - - def stop_streaming(self): - """Stop the mock data stream.""" - self._running = False - if config.DEBUG: - print("[Mock] Streaming stopped") - - def on_price_update(self, callback: Callable): - self.price_callbacks.append(callback) - - def on_error(self, callback: Callable): - pass diff --git a/database.py b/database.py index e540600..5a29610 100644 --- a/database.py +++ b/database.py @@ -144,6 +144,26 @@ class Database: CONSTRAINT valid_month CHECK (month LIKE '____-__') ); """) + + # Table 6: Confluence signal audit log (real-time triggers) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS confluence_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + triggered_at TEXT NOT NULL, + pair TEXT NOT NULL, + signal_type TEXT NOT NULL, + confidence REAL NOT NULL, + z_score REAL, + gap REAL, + reason TEXT, + layer1_active INTEGER DEFAULT 0, + status TEXT DEFAULT 'PENDING' + ); + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_confluence_log_time + ON confluence_log(triggered_at DESC); + """) self.conn.commit() @@ -624,6 +644,48 @@ class Database: except sqlite3.Error as e: raise RuntimeError(f"Failed to fetch signals: {e}") + # ======================================================================== + # CONFLUENCE_LOG Table Operations (Phase 3 audit trail) + # ======================================================================== + + def save_confluence_signal(self, pair: str, signal_type: str, confidence: float, + z_score: float = None, gap: float = None, + reason: str = None, layer1_active: bool = False) -> None: + """Persist a real-time confluence trigger to the audit log. + + Unlike save_signal() (monthly Layer 1 signals), this logs every + live/backtested trigger with sub-second granularity. + """ + try: + with self._lock: + cursor = self.conn.cursor() + cursor.execute(""" + INSERT INTO confluence_log + (triggered_at, pair, signal_type, confidence, z_score, gap, reason, layer1_active, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING') + """, ( + datetime.now(timezone.utc).isoformat(), + pair, signal_type, confidence, z_score, gap, reason, + 1 if layer1_active else 0 + )) + self.conn.commit() + except sqlite3.Error as e: + self.conn.rollback() + + def get_confluence_log(self, limit: int = 100) -> List[Dict]: + """Get the most recent confluence triggers.""" + cursor = self.conn.cursor() + try: + cursor.execute(""" + SELECT triggered_at, pair, signal_type, confidence, z_score, gap, reason, status + FROM confluence_log + ORDER BY triggered_at DESC LIMIT ? + """, (limit,)) + rows = cursor.fetchall() + return [dict(r) for r in rows] + except sqlite3.Error: + return [] + # ======================================================================== # Utility Methods # ======================================================================== diff --git a/layer2_technical.py b/layer2_technical.py index 8e32e30..ac056d4 100644 --- a/layer2_technical.py +++ b/layer2_technical.py @@ -1,32 +1,22 @@ from typing import Dict, List, Optional, Tuple from collections import deque import statistics +import math 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): + def __init__(self, lookback: int = None, timeframe: str = None): + self.current_timeframe = timeframe or config.DEFAULT_TIMEFRAME + tf_config = config.TIMEFRAMES.get(self.current_timeframe, config.TIMEFRAMES["M15"]) if lookback is None: - lookback = config.BAR_LOOKBACK_BARS + lookback = tf_config["bars"] self.bar_lookback = lookback self.tick_lookback = 20 + self.atr_period = config.SL_ATR_PERIOD self.bar_history: Dict[str, deque] = {} + self.ohlc_history: Dict[str, deque] = {} self.price_history: Dict[str, deque] = {} self.volume_history: Dict[str, deque] = {} self.z_scores: Dict[str, float] = {} @@ -37,50 +27,60 @@ class TechnicalAnalyzer: if base != quote: pair = f"{base}_{quote}" self.bar_history[pair] = deque(maxlen=self.bar_lookback) + self.ohlc_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 set_timeframe(self, tf_key: str): + tf_config = config.TIMEFRAMES.get(tf_key) + if not tf_config: + return + self.current_timeframe = tf_key + new_lookback = tf_config["bars"] + if new_lookback != self.bar_lookback: + self.bar_lookback = new_lookback + for pair in self.bar_history: + self.bar_history[pair] = deque( + list(self.bar_history[pair])[-new_lookback:], + maxlen=new_lookback, + ) + self.ohlc_history[pair] = deque( + list(self.ohlc_history[pair])[-new_lookback:], + maxlen=new_lookback, + ) + 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) + if high is not None and low is not None: + self.ohlc_history[currency_pair].append((close, high, low)) 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): @@ -89,13 +89,11 @@ class TechnicalAnalyzer: 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 @@ -114,7 +112,6 @@ class TechnicalAnalyzer: 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: @@ -130,7 +127,6 @@ class TechnicalAnalyzer: 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) @@ -179,40 +175,97 @@ class TechnicalAnalyzer: 'volatility': volatility, 'mean_price': mean_price, 'is_extreme': is_extreme, - 'status': status + '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 + if pair not in self.bar_history: + continue + 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 seed_ohlc(self, ohlc_data: Dict[str, List[Dict]]): + """Seed both bar_history and ohlc_history from full candle data. + Each dict in the list must have 'close', 'high', 'low' keys. + """ + for pair, candles in ohlc_data.items(): + if pair not in self.bar_history: + continue + self.bar_history[pair].clear() + self.ohlc_history[pair].clear() + n_bars = min(len(candles), self.bar_lookback) + for i in range(-n_bars, 0): + c = candles[i] + self.bar_history[pair].append(c["close"]) + self.ohlc_history[pair].append((c["close"], c["high"], c["low"])) + 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.ohlc_history[pair].clear() self.price_history[pair].clear() self.volume_history[pair].clear() self.z_scores[pair] = 0.0 self.extremes[pair] = False + # ------------------------------------------------------------------ + # ATR + SL/TP + # ------------------------------------------------------------------ + def calculate_atr(self, pair: str, period: int = None) -> Optional[float]: + if period is None: + period = self.atr_period + ohlc = list(self.ohlc_history.get(pair, [])) + if len(ohlc) < period + 1: + return None + tr_values = [] + for i in range(1, len(ohlc)): + _, h, l = ohlc[i] + _, prev_c, _ = ohlc[i - 1] + tr = max(h - l, abs(h - prev_c), abs(l - prev_c)) + tr_values.append(tr) + if len(tr_values) < period: + return None + atr = sum(tr_values[-period:]) / period + return atr + + def calculate_sl_tp( + self, pair: str, direction: str, entry_price: float + ) -> Dict[str, float]: + atr = self.calculate_atr(pair) + result = {"entry": entry_price, "sl": None, "tp": None, "atr": atr} + + sl_mult = config.SL_ATR_MULTIPLIER + rr = config.TRADE_RR_RATIO + + if atr is not None and atr > 0: + sl_distance = atr * sl_mult + tp_distance = sl_distance * rr + if direction == "LONG": + result["sl"] = entry_price - sl_distance + result["tp"] = entry_price + tp_distance + else: + result["sl"] = entry_price + sl_distance + result["tp"] = entry_price - tp_distance + return result + class TechnicalSignal: - """Generates technical entry/exit signals based on Z-scores.""" - def __init__(self, analyzer: TechnicalAnalyzer): self.analyzer = analyzer diff --git a/main.py b/main.py index 1a24417..7f2f2c6 100644 --- a/main.py +++ b/main.py @@ -54,8 +54,264 @@ def main(): # Create QApplication app = QApplication(sys.argv) - # Set application-wide stylesheet (optional) + # Set Fusion style app.setStyle('Fusion') + + # Global professional stylesheet + app.setStyleSheet(f""" + /***************************************************************** + * APEX Professional Trading System โ€” Global Stylesheet + *****************************************************************/ + + /* ----- Root / Background ----- */ + QMainWindow {{ + background-color: #f0f2f5; + }} + QWidget {{ + font-family: "Segoe UI", "Arial", sans-serif; + font-size: 13px; + color: #2c3e50; + }} + + /* ----- Tab Widget (Navigation Bar) ----- */ + QTabWidget::pane {{ + border: none; + background: #f0f2f5; + top: -1px; + }} + QTabBar::tab {{ + background: #2c3e50; + color: #95a5a6; + padding: 10px 22px; + margin-right: 2px; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + font-size: 13px; + font-weight: 600; + }} + QTabBar::tab:selected {{ + background: #f0f2f5; + color: #2c3e50; + border-bottom: 2px solid #3498db; + }} + QTabBar::tab:hover:!selected {{ + background: #34495e; + color: #ecf0f1; + }} + + /* ----- Cards (QFrame) ----- */ + QFrame#card {{ + background-color: #ffffff; + border: 1px solid #e0e4e8; + border-radius: 10px; + padding: 18px; + }} + QFrame#card:hover {{ + border-color: #c0c8d0; + }} + QFrame#statusCard {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #ffffff, stop:1 #f8f9fb); + border: 1px solid #e0e4e8; + border-radius: 10px; + padding: 20px; + }} + + /* ----- Labels ----- */ + QLabel {{ + color: #2c3e50; + }} + QLabel[heading="true"] {{ + font-size: 16px; + font-weight: 700; + color: #1a1a2e; + padding-bottom: 4px; + }} + QLabel[subheading="true"] {{ + font-size: 13px; + font-weight: 600; + color: #7f8c8d; + letter-spacing: 1px; + }} + QLabel[value="true"] {{ + font-size: 28px; + font-weight: 700; + }} + + /* ----- Tables ----- */ + QTableWidget {{ + background-color: #ffffff; + border: 1px solid #e0e4e8; + border-radius: 8px; + gridline-color: #f0f2f5; + selection-background-color: #ebf5fb; + selection-color: #2c3e50; + padding: 4px; + }} + QTableWidget::item {{ + padding: 6px 10px; + border-bottom: 1px solid #f0f2f5; + }} + QTableWidget::item:selected {{ + background-color: #ebf5fb; + color: #2c3e50; + }} + QHeaderView::section {{ + background-color: #f8f9fb; + color: #7f8c8d; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + padding: 8px 10px; + border: none; + border-bottom: 2px solid #e0e4e8; + }} + + /* ----- Buttons ----- */ + QPushButton {{ + background-color: #3498db; + color: #ffffff; + border: none; + border-radius: 6px; + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + }} + QPushButton:hover {{ + background-color: #2980b9; + }} + QPushButton:pressed {{ + background-color: #2471a3; + }} + QPushButton:disabled {{ + background-color: #bdc3c7; + color: #95a5a6; + }} + + QPushButton#success {{ + background-color: #27ae60; + }} + QPushButton#success:hover {{ + background-color: #229954; + }} + QPushButton#success:disabled {{ + background-color: #bdc3c7; + }} + + QPushButton#danger {{ + background-color: #e74c3c; + }} + QPushButton#danger:hover {{ + background-color: #cb4335; + }} + + QPushButton#secondary {{ + background-color: #95a5a6; + }} + QPushButton#secondary:hover {{ + background-color: #7f8c8d; + }} + + /* ----- Progress Bar ----- */ + QProgressBar {{ + background-color: #ecf0f1; + border: none; + border-radius: 6px; + height: 18px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: #2c3e50; + }} + QProgressBar::chunk {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #3498db, stop:1 #2ecc71); + border-radius: 6px; + }} + + /* ----- Combo Box / Spinner ----- */ + QComboBox {{ + background-color: #ffffff; + border: 1px solid #d5d8dc; + border-radius: 6px; + padding: 6px 12px; + min-height: 20px; + }} + QComboBox:hover {{ + border-color: #3498db; + }} + QComboBox::drop-down {{ + border: none; + width: 24px; + }} + QDoubleSpinBox, QSpinBox, QLineEdit {{ + background-color: #ffffff; + border: 1px solid #d5d8dc; + border-radius: 6px; + padding: 6px 10px; + min-height: 20px; + }} + QDoubleSpinBox:focus, QSpinBox:focus, QLineEdit:focus {{ + border-color: #3498db; + }} + + /* ----- Group Box ----- */ + QGroupBox {{ + font-size: 14px; + font-weight: 700; + color: #2c3e50; + border: 1px solid #e0e4e8; + border-radius: 10px; + margin-top: 12px; + padding: 20px 16px 16px 16px; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 4px 12px; + background-color: #f0f2f5; + border-radius: 4px; + margin-left: 10px; + }} + + /* ----- Scroll Area ----- */ + QScrollArea {{ + border: none; + background: transparent; + }} + + /* ----- Check Box ----- */ + QCheckBox {{ + spacing: 8px; + }} + QCheckBox::indicator {{ + width: 18px; + height: 18px; + border-radius: 4px; + border: 2px solid #bdc3c7; + }} + QCheckBox::indicator:checked {{ + background-color: #3498db; + border-color: #3498db; + }} + + /* ----- Tooltip ----- */ + QToolTip {{ + background-color: #2c3e50; + color: #ffffff; + border: none; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + }} + + /* ----- Status Bar ----- */ + QStatusBar {{ + background-color: #2c3e50; + color: #ecf0f1; + font-size: 12px; + }} + """) # Create and show main window window = MainWindow() diff --git a/main_window.py b/main_window.py index 2b68491..c954748 100644 --- a/main_window.py +++ b/main_window.py @@ -21,7 +21,6 @@ Responsibilities: from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox from PyQt5.QtCore import QThread, pyqtSignal -from PyQt5.QtGui import QFont from typing import Dict, Optional import config from database import Database @@ -32,6 +31,7 @@ from ui.layer2_monitor_tab import Layer2MonitorTab from ui.confluence_tab import ConfluenceSignalsTab from ui.history_tab import HistoryTab from ui.settings_tab import SettingsTab +from fred_client import FredClient class FredFetchWorker(QThread): @@ -98,7 +98,8 @@ class MainWindow(QMainWindow): raise # Initialize Layer 2 components - self.tech_analyzer = TechnicalAnalyzer(lookback=config.Z_SCORE_LOOKBACK) + bars = config.TIMEFRAMES[config.DEFAULT_TIMEFRAME]["bars"] + self.tech_analyzer = TechnicalAnalyzer(lookback=bars) # UI components self.dashboard_tab = None @@ -149,10 +150,6 @@ class MainWindow(QMainWindow): # Set main widget self.setCentralWidget(tabs) - - # Style tabs - tab_font = QFont("Arial", 11) - tabs.setFont(tab_font) def _connect_signals(self): """Connect inter-tab signals.""" diff --git a/project_structure_and_resume.md b/project_structure_and_resume.md deleted file mode 100644 index 193f893..0000000 --- a/project_structure_and_resume.md +++ /dev/null @@ -1,878 +0,0 @@ -# APEX โ€” Currency Strength Engine - -> **Version:** 1.1.0 -> **Timeframe:** Swing / Position Trading (1โ€“5 day holds) -> **Architecture:** S.A.T.O.R.I. (Statistical Arbitrage Trading & Orchestrated Reversion Index) -> **Layer:** Layer 1 (Fundamental) + Layer 2 (Technical/Statistical) -> **Methodology:** Dr. Giavon's Deconstructed Currency Strength Indexing - ---- - -## 1. Project Overview - -APEX is a desktop-based **Currency Strength Engine** that implements institutional-quality **statistical arbitrage (StatArb)** for the forex market. It deconstructs all 28 major cross-pairs to isolate the true strength/weakness of individual currencies, then generates mean-reversion signals when statistical divergences reach extreme thresholds. - -### Core Principle - -Instead of analyzing EUR/USD as a single entity, APEX decomposes every pair to isolate individual currency strength indices: - -``` -Individual Currency Strength = Average Z-Score Across ALL 7 Pairs Involving That Currency - -EUR_Strength = avg(Z(EUR_USD), Z(EUR_GBP), Z(EUR_JPY), Z(EUR_AUD), Z(EUR_CAD), Z(EUR_CHF), Z(EUR_NZD)) -USD_Strength = avg(Z(USD_EUR), Z(USD_GBP), Z(USD_JPY), Z(USD_AUD), Z(USD_CAD), Z(USD_CHF), Z(USD_NZD)) -... and so on for all 8 currencies -``` - -### Trading Philosophy - -| Component | Strategy | -|-----------|----------| -| **Timeframe** | Swing / Position โ€” 1 to 5 day holds | -| **Entry Trigger** | Matrix Cross divergence: one currency overbought (Z > +2.0) across ALL pairs, another oversold (Z < -2.0) simultaneously | -| **Execution** | Short the strongest, buy the weakest โ€” bet on mathematical mean reversion | -| **Risk Management** | No single-pair stop losses. Basket hedging across correlated pairs + grid hedging | -| **Exit** | Aggregate portfolio P&L goes net positive (portfolio-based exit, not per-pair) | -| **Z-Score Anchor** | 288 M5 bars = 24 hours of historical data (not tick noise) | -| **Session Tracking** | Tracks Tokyo / London / New York opens with Session Relative Velocity | - ---- - -## 2. Architecture - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ APEX APPLICATION โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ UI LAYER (6 Tabs) โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚Dashboard โ”‚ โ”‚Data โ”‚ โ”‚Layer 2 โ”‚ โ”‚Confluenceโ”‚ โ”‚Hist.โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚(Fundamen)โ”‚ โ”‚Entry โ”‚ โ”‚Monitor โ”‚ โ”‚Signals โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ BUSINESS LOGIC LAYER โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Scoring (L1) โ”‚ โ”‚Technical (L2)โ”‚ โ”‚ Matrix Engine โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ scorer.py โ”‚ โ”‚layer2_tech โ”‚ โ”‚ currency_strengthโ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ .py โ”‚ โ”‚ _matrix.py โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ CONFLUENCE FILTER โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ confluence_filter.py โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Layer 1 + Layer 2 + Matrix = Entry Signal โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ RISK MANAGEMENT SYSTEM โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ risk_management.py โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Position Sizing + Grid Hedge + Basket Hedge + โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Portfolio P&L Tracking + Aggregate Exit โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ DATA LAYER โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ FRED API โ”‚ โ”‚ MT5 Data โ”‚ โ”‚ SQLite โ”‚ โ”‚ Excel Import โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚(interest โ”‚ โ”‚(forex โ”‚ โ”‚ Database โ”‚ โ”‚ (CPI/PMI) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ rates) โ”‚ โ”‚ prices) โ”‚ โ”‚ apex.db โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -### The Two Layers - -| Layer | Input | Frequency | Output | -|-------|-------|-----------|--------| -| **Layer 1 (Fundamental)** | Interest rates (FRED), CPI, PMI (manual/Excel) | Monthly | Currency scores (0-100), Strongest/Weakest ranking | -| **Layer 2 (Technical)** | 288 M5 bars (24h) + live poll prices | Bar-anchored, tick-displayed | 28 pair Z-scores (anchored to 24h ฮผ/ฯƒ), 8 currency strength indices, Matrix Cross | - -**Critical design:** Z-scores are NOT calculated over raw tick polls. On connect, the analyzer is seeded with 288 M5 bars of historical close prices. ฮผ and ฯƒ are computed from this 24-hour window. Live tick prices are compared against this stable anchor, producing meaningful multi-hour deviation readings that don't flip on every tick. - -**Fallback:** If bar data hasn't been seeded yet, the system falls back to a 20-tick deque for immediate display. Once `seed_bars()` is called, the bar anchor takes over permanently. - ---- - -## 3. Directory Structure - -``` -apex_layer1/ -โ”‚ -โ”œโ”€โ”€ __init__.py # Package marker (v1.0.0) -โ”œโ”€โ”€ main.py # Application entry point -โ”œโ”€โ”€ main_window.py # QMainWindow + tab assembly -โ”œโ”€โ”€ config.py # All configuration & constants from .env -โ”‚ -โ”œโ”€โ”€ data_feeder.py # MT5 + Mock data feeders -โ”œโ”€โ”€ fred_client.py # FRED interest rate API client -โ”œโ”€โ”€ database.py # SQLite database manager -โ”‚ -โ”œโ”€โ”€ scorer.py # Layer 1 scoring engine -โ”œโ”€โ”€ layer2_technical.py # Layer 2 Z-score engine (28 pairs) -โ”œโ”€โ”€ currency_strength_matrix.py # S.A.T.O.R.I. currency strength index -โ”œโ”€โ”€ confluence_filter.py # Layer 1 + Layer 2 + Matrix merging -โ”œโ”€โ”€ risk_management.py # Position sizing, hedging, portfolio mgmt -โ”‚ -โ”œโ”€โ”€ create_excel_template.py # Excel/CSV template generator -โ”œโ”€โ”€ requirements.txt # Python dependencies -โ”‚ -โ”œโ”€โ”€ .env # Live configuration (API keys) -โ”œโ”€โ”€ .env.example # Configuration template -โ”œโ”€โ”€ apex.db # SQLite database (auto-created) -โ”‚ -โ”œโ”€โ”€ ui/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”œโ”€โ”€ dashboard_tab.py # Tab 1: Layer 1 fundamental signals -โ”‚ โ”œโ”€โ”€ entry_tab.py # Tab 2: CPI/PMI data entry -โ”‚ โ”œโ”€โ”€ layer2_monitor_tab.py # Tab 3: Live Z-scores + Matrix -โ”‚ โ”œโ”€โ”€ confluence_tab.py # Tab 4: Merged signals -โ”‚ โ”œโ”€โ”€ history_tab.py # Tab 5: Past signals -โ”‚ โ””โ”€โ”€ settings_tab.py # Tab 6: Configuration -``` - ---- - -## 4. File-by-File Breakdown - -### 4.1 Entry Point - -#### `main.py` -Launches the PyQt5 application. Validates FRED API key exists, creates `QApplication`, instantiates `MainWindow`, runs event loop. - -- **`main()`** โ€” Application entry point. Checks `config.FRED_API_KEY`, creates `QApplication`, shows `MainWindow`, starts event loop. - -#### `__init__.py` -Package marker. Exports `__version__ = "1.0.0"`. - ---- - -### 4.2 Configuration - -#### `config.py` -Loads `.env` via `python-dotenv`. Defines ALL constants used across the application. - -| Constant | Default | Description | -|----------|---------|-------------| -| `CURRENCIES` | `["USD","EUR","GBP","JPY","AUD","CAD","CHF","NZD"]` | The 8 major currencies | -| `CB_TARGETS` | Per-currency dict | Central bank inflation targets (2.0% most, AUD=2.5, CHF=1.5) | -| `FRED_SERIES` | Per-currency dict | FRED series IDs for interest rates | -| `WEIGHT_RATE` | `0.50` | Interest rate weight in L1 scoring | -| `WEIGHT_CPI` | `0.30` | CPI deviation weight in L1 scoring | -| `WEIGHT_PMI` | `0.20` | PMI composite weight in L1 scoring | -| `MIN_GAP_TO_TRADE` | `20` | Minimum score gap required for signal | -| `Z_SCORE_THRESHOLD` | `2.0` | Overbought/oversold threshold (std devs) | -| `Z_SCORE_LOOKBACK` | `20` | Bars for Z-score calculation | -| `MT5_SYMBOL_SUFFIX` | `""` | Broker-specific MT5 suffix (e.g., `.m`) | -| `ACCOUNT_BALANCE` | `10000` | Starting account balance | -| `RISK_PER_TRADE` | `0.01` | 1% risk per trade | -| `MAX_PORTFOLIO_LEVERAGE` | `2.0` | Max 2:1 leverage | -| `GRID_LEVELS` | `3` | Hedge grid levels | -| `USE_GRID_HEDGING` | `true` | Enable grid hedging | -| `DEBUG` | `false` | Debug output toggle | - -- **`validate_config()`** โ€” Validates all config on import. Raises `ValueError` if FRED key or critical settings are missing. - ---- - -### 4.3 Data Layer - -#### `data_feeder.py` -Two data feeder implementations with the **same interface** (polymorphic): - -**Class `Mt5DataFeeder`** โ€” Real data from MetaTrader 5 terminal. - -| Method | Returns | Description | -|--------|---------|-------------| -| `initialize()` | `bool` | Connect to MT5 terminal | -| `test_connection()` | `bool` | Alias for initialize | -| `shutdown()` | โ€” | Disconnect MT5 | -| `get_connection_status()` | `str` | Human-readable status | -| `get_current_price(pair)` | `dict\|None` | Bid/ask/mid via `symbol_info_tick()` | -| `fetch_all_rates()` | `dict` | Fetch 7 USD pairs, derive all 28 cross rates | -| `get_all_major_pairs()` | `list[str]` | All 28 pairs (56 permutations) | -| `get_exchange_rate(from, to)` | `float\|None` | Single cross rate | -| `get_historical_candles(...)` | `list[dict]\|None` | OHLC bars via `copy_rates_from_pos()` | -| `stream_prices(...)` | โ€” | Threaded polling loop | -| `stop_streaming()` | โ€” | Stop the poll loop | - -**Strategy:** Fetches only 7 major USD pairs (`EUR_USD`, `GBP_USD`, `AUD_USD`, `NZD_USD`, `USD_JPY`, `USD_CAD`, `USD_CHF`), converts each to "how many USD per 1 unit", then derives all 28 cross rates mathematically. This avoids the problem that most MT5 brokers don't have symbols for exotic crosses like `AUDEUR`, `AUDGBP`, etc. - -**Key internal:** -```python -USD_PAIRS = ["EUR_USD", "GBP_USD", "AUD_USD", "NZD_USD", - "USD_JPY", "USD_CAD", "USD_CHF"] - -# For EUR_USD: usd_rates["EUR"] = mid_price -# For USD_JPY: usd_rates["JPY"] = 1.0 / mid_price -# Cross rate: rate[base][quote] = usd_rates[base] / usd_rates[quote] -``` - -**Class `MockDataFeeder`** โ€” Simulates prices with random walk around base prices for 8 major pairs. Same interface as `Mt5DataFeeder` for testability. - ---- - -#### `fred_client.py` -**Class `FredClient`** โ€” Fetches interest rates from FRED API. - -| Method | Returns | Description | -|--------|---------|-------------| -| `__init__(api_key, timeout)` | โ€” | Validates API key | -| `fetch_rate(currency, max_retries)` | `float\|None` | Single currency rate with exponential backoff | -| `fetch_all_rates(max_retries)` | `dict` | All 8 currencies | -| `get_cached_rate(currency)` | `float\|None` | Cache lookup | -| `clear_cache()` | โ€” | Reset cache | - -Uses FRED series IDs from `config.FRED_SERIES`: -- USD โ†’ `FEDFUNDS`, EUR โ†’ `ECBDFR`, GBP โ†’ `BOEBR`, JPY โ†’ `IRSTJPN` -- AUD โ†’ `RBATCTR`, CAD โ†’ `BOCCRT`, CHF โ†’ `SNBPOL`, NZD โ†’ `RBNZOCR` - ---- - -#### `database.py` -**Class `Database`** โ€” SQLite database with 4 tables. - -| Table | Columns | Purpose | -|-------|---------|---------| -| `rates` | `currency, rate, updated_at, source` | Interest rates from FRED | -| `monthly_data` | `month, currency, cpi_actual, pmi_actual, entered_at` | CPI/PMI entries | -| `scores` | `month, currency, score_rate, score_cpi, score_pmi, total_score, rank` | Calculated scores | -| `signals` | `month, strongest, weakest, gap, signal, status` | Trade signals | - -All tables use `ON CONFLICT ... DO UPDATE` (upsert) for idempotent writes. Foreign keys enforced via PRAGMA. - -Key methods: `upsert_rate()`, `update_monthly_cpi()`, `update_monthly_pmi()`, `save_scores()`, `save_signal()`, `get_month_scores()`, `get_all_signals()`, `get_month_completeness()`. - ---- - -### 4.4 Business Logic โ€” Layer 1 (Fundamental) - -#### `scorer.py` -Pure functions (no classes). Implements the scoring formula: - -``` -Score = (Rate_Differential ร— 50%) + (CPI_Deviation ร— 30%) + (PMI ร— 20%) - -Each component is min-max normalized to 0-100 before weighting. -``` - -| Function | Returns | Description | -|----------|---------|-------------| -| `normalise(values)` | `list[float]` | Min-max scaling to 0-100 | -| `calculate_rate_differentials(rates)` | `dict` | Rate minus G8 average | -| `calculate_cpi_deviations(cpi_values)` | `dict` | Actual CPI minus CB target | -| `score_all_currencies(rates, cpi, pmi)` | `dict` | Full scoring pipeline | -| `get_ranked_list(scores)` | `list[tuples]` | Sorted by score descending | -| `pair_currencies(scores)` | `(strongest, weakest, gap)` | Top vs bottom score | -| `generate_signal(scores)` | `(signal, status, gap_desc)` | "SHORT X/Y" or "NO TRADE" | -| `validate_scores(scores)` | `bool` | Validates all fields and ranges | - ---- - -### 4.5 Business Logic โ€” Layer 2 (Technical) - -#### `layer2_technical.py` -**Class `TechnicalAnalyzer`** โ€” Real-time Z-score engine. - -Initializes 56 deques (all permutations of 8 currencies) with `maxlen=20`. Each incoming price tick appends to the deque and recalculates the Z-score. - -| Method | Description | -|--------|-------------| -| `add_price_data(pair, price, volume)` | Append price, recalculate Z-score | -| `get_z_score(pair)` | Current Z-score for any pair | -| `is_extreme(pair)` | `|Z| >= 2.0` | -| `get_overbought_pairs()` | All pairs with Z >= 2.0 | -| `get_oversold_pairs()` | All pairs with Z <= -2.0 | -| `get_volatility(pair)` | Standard deviation of recent prices | -| `get_mean_price(pair)` | Mean price over lookback | -| `is_mean_reverting(pair)` | `|Z| < 0.5` | -| `get_last_price(pair)` | Most recent price | -| `get_all_z_scores()` | Dict of all 56 pair Z-scores | -| `get_status_for_pair(pair)` | Dict with label (SEVERELY OVERBOUGHT โ†’ Neutral) | - -**Z-score formula:** `Z = (current_price - mean) / std_dev` - -**Class `TechnicalSignal`** โ€” Signal generation from Z-scores. -- `should_enter_on_extreme()` โ†’ True if `|Z| >= 2.0` -- `should_exit_on_mean_reversion()` โ†’ True if `|Z| < 0.5` -- `get_signal_strength()` โ†’ 0-100 scale - ---- - -#### `currency_strength_matrix.py` -**Class `CurrencyStrengthMatrix`** โ€” S.A.T.O.R.I. individual currency strength index. - -This is the core mathematical innovation. Deconstructs all 28 pair Z-scores into 8 individual currency strength indices. - -**How it works:** - -For each currency, collects Z-scores from all 7 pairs where it is the **base**: -``` -EUR_Strength = avg(Z(EUR_USD), Z(EUR_GBP), Z(EUR_JPY), Z(EUR_AUD), Z(EUR_CAD), Z(EUR_CHF), Z(EUR_NZD)) -USD_Strength = avg(Z(USD_EUR), Z(USD_GBP), Z(USD_JPY), Z(USD_AUD), Z(USD_CAD), Z(USD_CHF), Z(USD_NZD)) -``` - -**Output:** -| Currency | Avg Z-Score | Direction | -|----------|-------------|-----------| -| EUR | +2.3 | **OVERBOUGHT** | -| USD | +1.1 | NEUTRAL | -| ... | ... | ... | -| JPY | -2.5 | **OVERSOLD** | - -The **Matrix Cross** = Strongest currency vs Weakest currency (e.g., `EUR_JPY`). - -| Method | Description | -|--------|-------------| -| `update(z_scores)` | Recompute from 56 pair Z-scores | -| `get_strongest()` | Highest avg Z-score currency | -| `get_weakest()` | Lowest avg Z-score currency | -| `get_matrix_cross()` | Strongest_Weakest pair | -| `get_divergence_gap()` | strongest_z - weakest_z | -| `has_divergence()` | True if one overbought AND one oversold | -| `get_strong_currencies()` | List of overbought currencies | -| `get_weak_currencies()` | List of oversold currencies | -| `get_ranked_list()` | All 8 sorted by strength | -| `get_report()` | Dict with all matrix data | - ---- - -#### `confluence_filter.py` -**Class `ConfluenceFilter`** โ€” Merges all three signal sources. - -**Entry logic** (two-tier): - -1. **Primary โ€” Matrix Divergence:** - - One currency overbought across ALL pairs - - Another currency oversold across ALL pairs - - Trade the Matrix Cross (strongest vs weakest) - - Confidence = spread / 4.0 ร— 100 - -2. **Secondary โ€” Layer 1 + Layer 2:** - - Layer 1 bias (fundamental strongest/weakest) - - Layer 2 pair extreme (|Z| >= 2.0 on that specific pair) - - 50% gap confidence + 50% Z confidence - -**Exit logic** (two-tier): -1. Matrix divergence gap collapses (divergence no longer exists) -2. Single-pair Z-score mean reverts below 0.5 - -| Method | Description | -|--------|-------------| -| `set_layer1_bias(strongest, weakest, gap)` | Store current L1 signal | -| `check_entry_confluence()` | `(bool, reason, strength)` | -| `check_exit_confluence()` | `(bool, reason)` | -| `is_conflicting()` | L1 bullish but L2 bearish | -| `get_confluence_report()` | Full report with matrix data | -| `get_all_signals()` | All ranked signals | - -**Class `SignalHistory`** โ€” Tracks up to 1000 signals with win-rate calculation. - ---- - -#### `risk_management.py` -Five classes implementing professional risk management: - -**Class `PositionSizer`** -- Risk-based position sizing: `size = (balance ร— 0.01) / (stop_loss ร— pip_value) ร— confidence_multiplier` -- Clamped to 0.01โ€“5.0 lots - -**Class `GridHedging`** -- Creates N-level hedge grid below entry price -- Each hedge level = 50% ร— position_size / (N-1) - -**Class `PortfolioExposure`** -- Tracks all open positions -- Enforces max leverage (default 2:1) -- Rejects new positions that would exceed limit - -**Class `BasketHedging`** โ€” S.A.T.O.R.I. statistical arbitrage hedging. -- Pre-defined correlation clusters: - - `EUR_USD` โ†’ hedges with `EUR_GBP`, `EUR_JPY`, `GBP_USD` - - `GBP_USD` โ†’ hedges with `GBP_JPY`, `EUR_GBP`, `EUR_USD` - - `USD_JPY` โ†’ hedges with `USD_CHF`, `USD_CAD`, `EUR_JPY` - - `AUD_USD` โ†’ hedges with `AUD_JPY`, `NZD_USD`, `AUD_CAD` - - `NZD_USD` โ†’ hedges with `AUD_USD`, `NZD_JPY`, `NZD_CAD` -- Each correlated pair gets 30% of primary size / len(cluster) - -**Class `RiskManagementSystem`** โ€” Combines all four. -- `execute_signal()` โ†’ full trade execution with sizing + grid + basket -- `calculate_basket_pnl()` โ†’ aggregate unrealized P&L across ALL positions + hedges -- `should_exit_portfolio()` โ†’ exit when total P&L > 0 (portfolio-based, not per-pair) -- `close_all_trades()` โ†’ close all positions at given exit prices -- `get_portfolio_summary()` โ†’ positions, exposure, leverage, P&L - ---- - -### 4.6 UI Layer - -#### `main_window.py` -**Class `MainWindow(QMainWindow)`** โ€” Application shell. - -Creates 6-tab `QTabWidget`, instantiates all tabs, connects inter-tab signals. - -**Data flow assembly:** -``` -1. Entry tab saves data โ†’ Dashboard refreshes -2. Entry tab saves data โ†’ History tab refreshes -3. Dashboard generates signal โ†’ Confluence tab receives bias -4. Dashboard requests fetch โ†’ FredFetchWorker starts -5. FRED completes โ†’ Dashboard updates rates -``` - -**Class `FredFetchWorker(QThread)`** โ€” Background FRED API fetch. Saves rates to DB, emits `rates_fetched` or `error_occurred`. - ---- - -#### `ui/dashboard_tab.py` โ€” Tab 1 -**Class `DashboardTab(QWidget)`** - -Displays Layer 1 fundamental analysis: -- **Signal card** โ€” Large text: "SHORT JPY/USD" or "NO TRADE", gap score, tier, timestamp -- **Score table** โ€” 8 rows ร— 8 columns (Rank, Currency๐Ÿ‡บ๐Ÿ‡ธ, Rate%, CPI%, PMI, Score, Signal, Strength bar) -- Strongest row highlighted green with "BUY" tag -- Weakest row highlighted red with "SELL" tag -- Color-coded score bars (green/red/gray for Rate/CPI/PMI contributions) -- "Fetch Rates (FRED)" button - -Signals: `fetch_rates_requested`, `signal_generated(strongest, weakest, gap)` - ---- - -#### `ui/entry_tab.py` โ€” Tab 2 -**Class `MonthlyEntryTab(QWidget)`** - -Manual data entry for CPI and PMI: -- Month selector (dropdown, 24 months) -- **CPI table**: Currency, Target%, Actual CPI (spinbox), Delta (color-coded), Done -- **PMI table**: Currency, Neutral 50, PMI (spinbox), Signal label (Expanding/Contracting), Done -- Progress bar: X/16 fields filled -- Import Excel button (supports both multi-sheet xlsx and CSV) -- Save button (enabled only when 16/16 complete) -- On save: loads rates from DB โ†’ runs `scorer.score_all_currencies()` โ†’ saves scores โ†’ generates signal โ†’ emits `data_saved` - -Signals: `data_saved(month)` - ---- - -#### `ui/layer2_monitor_tab.py` โ€” Tab 3 -**Class `Layer2MonitorTab(QWidget)`** -**Class `DataStreamerThread(QThread)`** - -Real-time technical analysis with S.A.T.O.R.I. matrix: -- **Connection panel**: Source dropdown (MT5 Live / Mock Test), Connect/Disconnect, status indicator -- **Z-score table**: All 28 pairs with Price, Z-Score (red when extreme), Volatility, Mean, Status, Signal -- **Overbought/Oversold alerts**: Comma-separated lists -- **Currency Strength Matrix panel:** - - Matrix Cross label (strongest vs weakest currency) - - Divergence Gap (sigma spread) - - DIVERGENCE DETECTED alert (red) when one currency overbought + one oversold - - Ranked currency table: 8 rows ร— 4 columns (Rank, Currency, Strength Z, Direction) - - Color-coded: OVERBOUGHT (red), OVERSOLD (green) -- Auto-refresh checkbox, Refresh Now button - -Data flow: Streamer thread polls feeder โ†’ emits `price_updated` โ†’ feeds `TechnicalAnalyzer` โ†’ recomputes `CurrencyStrengthMatrix` โ†’ refreshes display. - ---- - -#### `ui/confluence_tab.py` โ€” Tab 4 -**Class `ConfluenceSignalsTab(QWidget)`** - -Merged signal display and execution: -- **Status card**: Layer 1 bias (pair, gap), Layer 2 extreme (pair, Z-score), Matrix Cross, Top 3 โ†’ Bottom 3 ranked currencies, Confluence result with confidence % -- **Signals table**: 10 rows ร— 8 columns (Pair, L1 Gap, L2 Z-Score, Status, Confidence, Entry Price, Position Size, Action) -- Matrix divergence signals shown in purple, standard confluence in green -- **Risk panel**: Portfolio exposure progress bar, leverage ratio -- **Buttons**: Refresh, Execute Top Signal (runs `RiskManagementSystem`) -- Auto-refresh every 5 seconds - ---- - -#### `ui/history_tab.py` โ€” Tab 5 -**Class `HistoryTab(QWidget)`** - -Past signal history: -- Table with 6 columns: Month, Signal, Gap, Strongest (flag), Weakest (flag), Status -- Status color-coded: ACTIVE (green), NO_TRADE (red) -- Click any row โ†’ popup with full score breakdown for all 8 currencies -- Auto-refreshes when new data saved - ---- - -#### `ui/settings_tab.py` โ€” Tab 6 -**Class `SettingsTab(QWidget)`** -**Class `FredTestWorker(QThread)`** -**Class `Mt5TestWorker(QThread)`** - -Configuration interface: -- **FRED API**: Key input (masked), Test Connection button, status -- **MT5**: Symbol suffix input, Test Connection button, status -- **CB Targets**: Read-only display of all 8 targets -- **Scoring Weights**: 3 spinboxes (Rate/CPI/PMI %) with live total validation (must = 100%) -- **Trading Rules**: Minimum gap spinbox (5-100) -- **App Settings**: Auto-fetch checkbox -- **Save**: Writes .env file (requires restart) -- **Reset**: Confirmation dialog, restores defaults - ---- - -### 4.7 Utility - -#### `create_excel_template.py` -Generates example Excel/CSV files for data import testing: -- `example_monthly_data.xlsx` (multi-sheet: CPI + PMI) -- `example_monthly_data_single_sheet.xlsx` (all in one sheet) -- `example_monthly_data.csv` - -Each contains 8 currencies with example values. - ---- - -## 5. Data Flow Diagrams - -### Layer 1 (Fundamental) โ€” Monthly Cycle - -``` -User enters CPI/PMI - โ”‚ - โ–ผ -Entry Tab โ†’ Save Clicked - โ”‚ - โ”œโ”€โ”€โ–บ Read all 8 CPI + 8 PMI from spinboxes - โ”œโ”€โ”€โ–บ Load interest rates from DB (from FRED) - โ”œโ”€โ”€โ–บ scorer.score_all_currencies(rates, cpi, pmi) - โ”‚ โ”œโ”€โ”€ normalise(rate_differentials) ร— 0.50 - โ”‚ โ”œโ”€โ”€ normalise(cpi_deviations) ร— 0.30 - โ”‚ โ”œโ”€โ”€ normalise(pmi_raw) ร— 0.20 - โ”‚ โ””โ”€โ”€ sum โ†’ total_score 0-100 - โ”œโ”€โ”€โ–บ scorer.generate_signal(scores) - โ”‚ โ”œโ”€โ”€ pair_currencies โ†’ strongest, weakest, gap - โ”‚ โ”œโ”€โ”€ gap >= 20 โ†’ "SHORT {weak}/{strong}" - โ”‚ โ””โ”€โ”€ gap < 20 โ†’ "NO TRADE" - โ”œโ”€โ”€โ–บ database.save_scores() - โ”œโ”€โ”€โ–บ database.save_signal() - โ””โ”€โ”€โ–บ emit data_saved โ†’ Dashboard + History refresh -``` - -### Layer 2 (Technical) โ€” Real-time with Bar Seeding - -``` -MT5 Terminal (or Mock) - โ”‚ - โ”œโ”€โ”€ On Connect: - โ”‚ generate_mock_bars(288) โ—„โ”€โ”€ Mock: simulates 24h of M5 data - โ”‚ โ”‚ or - โ”‚ fetch_historical_bars(288, M5) โ—„โ”€โ”€ MT5: real bars from terminal - โ”‚ โ”‚ - โ”‚ โ–ผ - โ”‚ TechnicalAnalyzer.seed_bars(bars) โ—„โ”€โ”€ Populates bar_history with 288 closes - โ”‚ โ”‚ โ—„โ”€โ”€ ฮผ and ฯƒ now anchored to 24h - โ”‚ โ”‚ - โ”‚ โ–ผ - โ”‚ DataStreamerThread.start() - โ”‚ - โ””โ”€โ”€โ–บ every 1s: - fetch_all_rates() โ†’ 7 USD pairs โ†’ derive 28 crosses - โ”‚ - โ”œโ”€โ”€โ–บ emit price_updated(pair, mid) - โ”‚ - โ–ผ - Layer2MonitorTab._on_price_received - โ”‚ - โ”œโ”€โ”€โ–บ TechnicalAnalyzer.add_price_data(pair, price) - โ”‚ โ””โ”€โ”€ _update_z_score(pair) - โ”‚ ฮผ, ฯƒ = _get_mean_std(pair) - โ”‚ โ”‚ priority: bar_history (288 bars) โ†’ tick_fallback (20 ticks) - โ”‚ โ–ผ - โ”‚ Z = (current_price - ฮผ) / ฯƒ - โ”‚ - โ”œโ”€โ”€โ–บ CurrencyStrengthMatrix(z_scores) - โ”‚ โ””โ”€โ”€ For each currency: avg Z across 7 base pairs - โ”‚ - โ””โ”€โ”€โ–บ _refresh_display() - โ”œโ”€โ”€ Update 28-pair Z-score table - โ”œโ”€โ”€ Update currency strength matrix table - โ”œโ”€โ”€ Update matrix cross / divergence alerts - โ”œโ”€โ”€ Update overbought/oversold lists - โ””โ”€โ”€ Show active session (Tokyo/London/New York) -``` - -### Confluence โ€” Entry Signal - -``` -Layer 1 (monthly) Layer 2 (real-time) - โ”‚ โ”‚ - โ–ผ โ–ผ -dashboard_tab.signal_generated ThermalAnalyzer.z_scores - โ”‚ โ”‚ - โ–ผ โ–ผ -ConfluenceFilter.set_layer1_bias CurrencyStrengthMatrix - โ”‚ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ–ผ - ConfluenceFilter.check_entry_confluence() - โ”‚ - โ”œโ”€โ”€ Matrix divergence? โ†’ YES โ†’ Trade matrix cross - โ”œโ”€โ”€ L1 + L2 extreme? โ†’ YES โ†’ Trade paired pair - โ””โ”€โ”€ Neither? โ†’ NO TRADE - โ”‚ - โ–ผ - RiskManagementSystem.execute_signal() - โ”‚ - โ”œโ”€โ”€ PositionSizer โ†’ size = f(confidence) - โ”œโ”€โ”€ GridHedging โ†’ 3-level hedge grid - โ”œโ”€โ”€ BasketHedging โ†’ correlated pair hedges - โ””โ”€โ”€ PortfolioExposure โ†’ leverage check -``` - ---- - -## 6. Database Schema - -```sql --- Table 1: Interest rates from FRED -CREATE TABLE rates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - currency TEXT NOT NULL UNIQUE, - rate REAL NOT NULL, - updated_at TEXT NOT NULL, - source TEXT DEFAULT 'FRED', - CONSTRAINT valid_currency CHECK (currency IN ('USD','EUR','GBP','JPY','AUD','CAD','CHF','NZD')) -); - --- Table 2: Monthly CPI + PMI entries -CREATE TABLE monthly_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - month TEXT NOT NULL, - currency TEXT NOT NULL, - cpi_actual REAL, - pmi_actual REAL, - entered_at TEXT NOT NULL, - UNIQUE(month, currency), - CONSTRAINT valid_currency CHECK (currency IN ('USD','EUR','GBP','JPY','AUD','CAD','CHF','NZD')), - CONSTRAINT valid_month CHECK (month LIKE '____-__') -); - --- Table 3: Calculated scores -CREATE TABLE scores ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - month TEXT NOT NULL, - currency TEXT NOT NULL, - score_rate REAL, - score_cpi REAL, - score_pmi REAL, - total_score REAL NOT NULL, - rank INTEGER NOT NULL, - calculated_at TEXT NOT NULL, - UNIQUE(month, currency) -); - --- Table 4: Trade signals -CREATE TABLE signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - generated_at TEXT NOT NULL, - month TEXT NOT NULL UNIQUE, - strongest TEXT NOT NULL, - weakest TEXT NOT NULL, - gap REAL NOT NULL, - signal TEXT NOT NULL, - status TEXT NOT NULL, - CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED')) -); -``` - ---- - -## 7. Configuration (.env) - -```env -FRED_API_KEY=your_fred_api_key -MT5_SYMBOL_SUFFIX= -DB_PATH=apex.db -DEBUG=true -WEIGHT_RATE=0.50 -WEIGHT_CPI=0.30 -WEIGHT_PMI=0.20 -MIN_GAP=20.0 -AUTO_FETCH_RATES_ON_STARTUP=true -Z_SCORE_THRESHOLD=2.0 -Z_SCORE_LOOKBACK=20 -ACCOUNT_BALANCE=10000.0 -RISK_PER_TRADE=0.01 -MAX_PORTFOLIO_LEVERAGE=2.0 -USE_GRID_HEDGING=true -GRID_LEVELS=3 -``` - ---- - -## 8. Technology Stack - -| Component | Technology | Version | -|-----------|-----------|---------| -| Language | Python | 3.10+ | -| UI Framework | PyQt5 | 5.15.9 | -| Database | SQLite | Built-in | -| HTTP Client | requests | 2.31+ | -| Data Processing | pandas | 2.1+ | -| Excel Support | openpyxl | 3.1+ | -| Environment | python-dotenv | 1.0+ | -| Forex Data | MetaTrader5 | Latest | -| Interest Rates | FRED API | Free tier | -| Packaging | PyInstaller | 6.1+ | - ---- - -## 9. Scoring Formula Reference - -### Layer 1 โ€” Fundamental Score - -``` -rate_diff[i] = rate[i] - G8_average_rate -cpi_dev[i] = actual_cpi[i] - cb_target[i] -pmi_raw[i] = pmi_value[i] - -normalize(x) = (x - min) / (max - min) ร— 100 // 0-100 scale - -score_total[i] = normalise(rate_diff)[i] ร— 0.50 - + normalise(cpi_dev)[i] ร— 0.30 - + normalise(pmi_raw)[i] ร— 0.20 - -gap = score_total[strongest] - score_total[weakest] -``` - -### Layer 2 โ€” Technical Score (Bar-Anchored) - -``` -Step 1: Seed bar_history with 288 M5 close prices (24 hours) -Step 2: ฮผ_bars = mean(bar_history), ฯƒ_bars = stdev(bar_history) -Step 3: For each incoming tick: - - Z[pair] = (current_tick_price - ฮผ_bars) / ฯƒ_bars - - Fallback (if bar_history empty): - Z[pair] = (current_tick_price - mean(ticks)) / stdev(ticks) - -Step 4: Individual Currency Strength = avg(Z[currency_X] over all 7 base pairs) - -Step 5: Session Relative Velocity (SRV): - At session open (Tokyo/London/NY), snapshot all prices. - SRV[pair] = ((current_price - session_open_price) / session_open_price) ร— 100 -``` - -### Entry Conditions - -``` -Matrix Divergence: any(avg_Z > +2.0) AND any(avg_Z < -2.0) โ†’ Trade Matrix Cross -Pair Confluence: L1_gap >= 20 AND L2_Z >= 2.0 on same pair โ†’ Trade that pair -``` - ---- - -## 10. 28 Currency Pairs (Generated) - -All 8 currencies produce 56 permutations (28 pairs ร— 2 directions): - -| Base | Pairs (base_quote) | -|------|--------------------| -| USD | USD_EUR, USD_GBP, USD_JPY, USD_AUD, USD_CAD, USD_CHF, USD_NZD | -| EUR | EUR_USD, EUR_GBP, EUR_JPY, EUR_AUD, EUR_CAD, EUR_CHF, EUR_NZD | -| GBP | GBP_USD, GBP_EUR, GBP_JPY, GBP_AUD, GBP_CAD, GBP_CHF, GBP_NZD | -| JPY | JPY_USD, JPY_EUR, JPY_GBP, JPY_AUD, JPY_CAD, JPY_CHF, JPY_NZD | -| AUD | AUD_USD, AUD_EUR, AUD_GBP, AUD_JPY, AUD_CAD, AUD_CHF, AUD_NZD | -| CAD | CAD_USD, CAD_EUR, CAD_GBP, CAD_JPY, CAD_AUD, CAD_CHF, CAD_NZD | -| CHF | CHF_USD, CHF_EUR, CHF_GBP, CHF_JPY, CHF_AUD, CHF_CAD, CHF_NZD | -| NZD | NZD_USD, NZD_EUR, NZD_GBP, NZD_JPY, NZD_AUD, NZD_CAD, NZD_CHF | - -Each currency's individual strength is computed from its 7 base pairs. - ---- - -## 11. Refactoring Changelog (Session-Based Quantitative Engine) - -### Task 1.1 โ€” Statistical Lookback Window (config.py, layer2_technical.py) -- `config.py`: Added `BAR_TIMEFRAME`, `BAR_LOOKBACK_HOURS`, `BAR_LOOKBACK_BARS`, `HISTORICAL_POLL_INTERVAL` constants. Default lookback changed from 20 ticks to 288 bars (24h of M5 data). -- `layer2_technical.py`: `TechnicalAnalyzer` now maintains **two data streams**: - - `bar_history` (deque of M1/M5 close prices, length = `BAR_LOOKBACK_BARS`) โ€” the multi-hour statistical anchor - - `price_history` (short deque of tick/poll data) โ€” for UI display - - `_get_bar_mean_std()` computes ฮผ/ฯƒ from bar history only - - `_update_z_score()` uses `Z = (current_tick - ฮผ_bars) / ฯƒ_bars` - - `add_bar()` method for feeding completed M1/M5 candles into the historical frame - -### Task 1.2 โ€” Session-Based Indexing (currency_strength_matrix.py) -- New `SessionTracker` class: - - Detects active session from UTC hour (Tokyo 00-08, London 07-16, New York 13-22) - - On session open, snapshots start prices for all 28 pairs - - Computes **Session Relative Velocity (SRV)**: `% change = (current - session_start) / session_start ร— 100` -- `CurrencyStrengthMatrix.update()` now accepts `current_prices` dict for session tracking -- `CurrencyStrength` dataclass has new `session_srv: float` field -- `get_report()` includes `active_session` key - -### Task 2.1 โ€” Live Order Book Subscriptions (data_feeder.py) -- `Mt5DataFeeder.get_order_book(pair)` โ€” fetches live bid/ask/spread via `mt5.symbol_info_tick()` + `mt5.symbol_info()` for the exact trade symbol -- `PositionSizer.calculate_position_size()` accepts optional `bid`, `ask`, `spread` params; wide spreads reduce position size by up to 20% -- `MockDataFeeder` has matching `get_order_book()` implementation - -### Task 2.2 โ€” SQLite WAL Mode + Bar Cache (database.py) -- Connection now sets: `PRAGMA journal_mode=WAL`, `PRAGMA synchronous=NORMAL` for concurrent read/write performance -- New `bar_cache` table: `(id, pair, timeframe, bar_time, open, high, low, close, volume)` with unique constraint on `(pair, timeframe, bar_time)` and compound index -- New methods: `upsert_bar()`, `get_bars()`, `get_latest_bar_time()` - -### Task 3.1 โ€” Layer 1 as Directional Regime Filter (confluence_filter.py) -- `check_entry_confluence()` now uses Layer 1 as a **Directional Regime Filter**: - - Primary signal: Matrix divergence (self-sufficient) - - Secondary: Layer 2 extremes only valid if **aligned** with Layer 1 macro bias - - Contrarian Layer 2 signals (Z < -threshold opposite Layer 1 direction) โ†’ **BLOCKED** with reason - - Aligned signals capped at 70% confidence (downgraded vs matrix divergence) -- `layer1_is_active` flag replaces raw gap comparison - -### Task 3.2 โ€” Dynamic Pearson Correlation (risk_management.py, data_feeder.py) -- New `pearson_correlation(x, y)` function: `r = ฮฃ(x-xฬ„)(y-ศณ) / โˆš(ฮฃ(x-xฬ„)ยฒ ยท ฮฃ(y-ศณ)ยฒ)` -- New `CorrelationEngine` class: - - `update_series(historical_closes)` โ€” feeds 30 days of close prices - - `get_correlation(pair_a, pair_b)` โ€” computes/caches r between any two pairs - - `get_top_correlated(target, n=3, min_r=0.75)` โ€” returns top N pairs with |r| โ‰ฅ 0.75 -- `BasketHedging.get_correlated_pairs()` now delegates to `CorrelationEngine` instead of hardcoded dict -- `Mt5DataFeeder.fetch_historical_closes_all_pairs(days=30)` fetches the required data -- `MockDataFeeder` has matching implementation - -### Task 3.3 โ€” Aggregate Portfolio Profit Target Exit (risk_management.py) -- `get_dynamic_exit_target()` โ€” confidence-scaled profit target (base = 1% of equity, scales with avg confidence) -- Background monitor thread `_monitor_exit_loop()` polls `calculate_basket_pnl()` every second -- When net aggregate P&L > dynamic target, fires `close_all_trades()` via registered callbacks -- `start_exit_monitor()`, `stop_exit_monitor()`, `on_portfolio_exit()` lifecycle management - -### Task 4.1 โ€” Session Visualizations + ฯƒ Highlights (ui/layer2_monitor_tab.py) -- Active session indicator label with color-coded background: Tokyo (purple), London (blue), New York (orange), Off-Hours (gray) -- Currency Strength Matrix Z-score cells: solid red background with white text for โ‰ฅ +2.0ฯƒ, solid green with white text for โ‰ค -2.0ฯƒ -- New 5th column in matrix table: "Session SRV" showing percentage change since session open -- Emoji indicators removed from status labels for cleaner display - ---- - -## 12. Bug Fixes & Stability (Round 2) - -### Fix 1 โ€” Bar History Never Seeded (Z-scores always 0.0) -- `layer2_technical.py`: Added `seed_bars(historical_bars)` method to populate `bar_history` with 288 M5 close prices on connect -- `data_feeder.py (Mock)`: Added `generate_mock_bars(n_bars=288)` โ€” generates 24h of simulated M5 data using an Ornstein-Uhlenbeck process (mean reversion + drift + noise) for all 28 pairs via USD pair derivation -- `ui/layer2_monitor_tab.py`: `_connect()` calls `_seed_historical_bars()` before starting the streamer โ€” bars are always seeded first - -### Fix 2 โ€” Tick Price Anchored to Initial Base, Not Bar Data -- `data_feeder.py (Mock)`: `_tick_price()` now uses the **last bar close** as its anchor with ยฑ0.0002 noise, instead of the initial base price with ยฑ0.01 noise -- `_current_bar_prices()` returns the last cached bar close for each pair -- This ensures Z-scores reflect the bar position relative to 24h history, not random tick noise - -### Fix 3 โ€” Default Source Changed to Mock -- `ui/layer2_monitor_tab.py`: `source_combo` defaults to `"Mock (Test)"` at index 0 to prevent unintended MT5 terminal connections on startup - -### Fix 4 โ€” Persistent Matrix Instance -- `ui/layer2_monitor_tab.py`: `CurrencyStrengthMatrix` is now a persistent `self.matrix` instance, recreated only once. `update()` is called each refresh instead of creating a new object, preserving `SessionTracker` state across refreshes - -### Fix 5 โ€” FRED Series IDs Updated -- `config.py`: Updated 6 invalid/deprecated FRED series IDs (`BOEBR`, `IRSTJPN`, `RBATCTR`, `BOCCRT`, `SNBPOL`, `RBNZOCR`) to commonly used alternatives (`BOEIR`, `IRSTCI01JPM156N`, `RBATR`, `BOCARR`, `SNBON`, `RBNZR`) diff --git a/requirements.txt b/requirements.txt index f11d660..66c33d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ pandas==2.1.3 openpyxl==3.1.2 pyinstaller==6.1.0 MetaTrader5==5.0.45 +pytest==8.3.4 +pytest-cov==6.0.0 diff --git a/risk_management.py b/risk_management.py index c401a3d..8e5a3e8 100644 --- a/risk_management.py +++ b/risk_management.py @@ -263,6 +263,7 @@ class RiskManagementSystem: def __init__(self, account_balance: float = 10000.0): self.account_balance = account_balance + self._lock = threading.Lock() 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) @@ -337,8 +338,9 @@ class RiskManagementSystem: 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) + with self._lock: + self.portfolio.add_position(pair, position_size) + self.trades.append(trade) return trade def calculate_basket_pnl(self) -> float: @@ -437,21 +439,24 @@ class RiskManagementSystem: 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) + with self._lock: + 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() + with self._lock: + for trade in list(self.trades): + if trade['status'] == 'OPEN': + price = exit_prices.get(trade['pair'], trade['entry_price']) + result = self.sizer.close_position(trade['pair'], price) + if result: + self.portfolio.remove_position(trade['pair']) + results.append(result) + self.stop_exit_monitor() return results def get_portfolio_summary(self) -> Dict: diff --git a/scorer.py b/scorer.py index b9aaa78..82986e0 100644 --- a/scorer.py +++ b/scorer.py @@ -338,51 +338,3 @@ def validate_scores(scores: Dict[str, Dict]) -> bool: return True -# Example usage (for testing) -if __name__ == "__main__": - # Mock data - test_rates = { - "USD": 5.25, - "EUR": 4.50, - "GBP": 5.25, - "JPY": 0.10, - "AUD": 4.35, - "CAD": 5.00, - "CHF": 1.75, - "NZD": 5.50, - } - - test_cpi = { - "USD": 3.2, - "EUR": 2.6, - "GBP": 3.4, - "JPY": 2.8, - "AUD": 3.8, - "CAD": 2.8, - "CHF": 1.8, - "NZD": 3.5, - } - - test_pmi = { - "USD": 54.2, - "EUR": 48.9, - "GBP": 52.1, - "JPY": 51.4, - "AUD": 46.2, - "CAD": 49.2, - "CHF": 49.8, - "NZD": 47.1, - } - - # Score all currencies - scores = score_all_currencies(test_rates, test_cpi, test_pmi) - - print("Scores:") - for currency, score_data in sorted(scores.items(), key=lambda x: x[1]['rank']): - print(f" {currency}: {score_data}") - - # Generate signal - signal, status, gap_desc = generate_signal(scores) - print(f"\nSignal: {signal}") - print(f"Status: {status}") - print(f"Gap: {gap_desc}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_currency_strength_matrix.py b/tests/test_currency_strength_matrix.py new file mode 100644 index 0000000..d00c6ed --- /dev/null +++ b/tests/test_currency_strength_matrix.py @@ -0,0 +1,107 @@ +import sys +import math +sys.path.insert(0, ".") + +import pytest +from currency_strength_matrix import CurrencyStrengthMatrix, SessionTracker + + +def build_synthetic_z_scores(base_vals: dict, quote_vals: dict) -> dict: + z_scores = {} + currencies = list(base_vals.keys()) + for base in currencies: + for quote in currencies: + if base == quote: + continue + pair = f"{base}_{quote}" + z_scores[pair] = base_vals[base] - quote_vals[quote] + return z_scores + + +class TestCurrencyStrengthMatrix: + def test_strongest_weakest_detection(self): + base = {"EUR": 2.0, "USD": 1.0, "GBP": 0.5, "JPY": -1.0} + quote = {"EUR": 0.0, "USD": 0.0, "GBP": 0.0, "JPY": 0.0} + z = build_synthetic_z_scores(base, quote) + matrix = CurrencyStrengthMatrix(z) + assert matrix.get_strongest().name == "EUR" + assert matrix.get_weakest().name == "JPY" + + def test_divergence_detected(self): + base = {"EUR": 2.5, "USD": 0.0, "GBP": 0.0, "JPY": -2.5} + quote = {"EUR": 0.0, "USD": 0.0, "GBP": 0.0, "JPY": 0.0} + z = build_synthetic_z_scores(base, quote) + matrix = CurrencyStrengthMatrix(z) + assert matrix.has_divergence() is True + assert matrix.get_matrix_cross() == "EUR_JPY" + assert matrix.get_divergence_gap() == pytest.approx(5.0, abs=0.01) + + def test_no_divergence_within_threshold(self): + base = {"EUR": 1.5, "USD": 0.0, "GBP": 0.0, "JPY": -1.5} + quote = {"EUR": 0.0, "USD": 0.0, "GBP": 0.0, "JPY": 0.0} + z = build_synthetic_z_scores(base, quote) + matrix = CurrencyStrengthMatrix(z) + assert matrix.has_divergence() is False + + def test_ranked_list_order(self): + base = {"EUR": 3.0, "USD": 1.0, "GBP": 2.0, "JPY": 0.0} + quote = {"EUR": 0.0, "USD": 0.0, "GBP": 0.0, "JPY": 0.0} + z = build_synthetic_z_scores(base, quote) + matrix = CurrencyStrengthMatrix(z) + ranked = matrix.get_ranked_list() + names = [c.name for c in ranked] + assert names == ["EUR", "GBP", "USD", "JPY"] + + def test_sign_error_base_quote_inversion(self): + base = {"EUR": 0.0, "USD": 0.0} + quote = {"EUR": 0.0, "USD": 0.0} + z = build_synthetic_z_scores(base, quote) + z["EUR_USD"] = 2.0 + z["USD_EUR"] = -2.0 + matrix = CurrencyStrengthMatrix(z) + s = matrix.get_strongest() + w = matrix.get_weakest() + assert s is not None and s.name == "EUR" + assert w is not None and w.name == "USD" + + def test_get_report_structure(self): + base = {"EUR": 2.0, "USD": 1.0, "GBP": 0.0, "JPY": -2.0} + quote = {"EUR": 0.0, "USD": 0.0, "GBP": 0.0, "JPY": 0.0} + z = build_synthetic_z_scores(base, quote) + matrix = CurrencyStrengthMatrix(z) + report = matrix.get_report() + assert "ranked" in report + assert "strongest" in report + assert "weakest" in report + assert "matrix_cross" in report + assert "divergence_gap" in report + assert "has_divergence" in report + assert report["has_divergence"] is False + + +class TestSessionTracker: + def test_active_session_returns_string(self): + tracker = SessionTracker() + session = tracker.get_active_session() + assert session in ("Tokyo", "London", "New York", "Off-Hours") + + def test_check_new_session_snapshots_prices(self): + tracker = SessionTracker() + prices = {"EUR_USD": 1.08, "USD_JPY": 149.0} + result = tracker.check_new_session(prices) + assert result is not None or tracker.current_session is not None + + def test_compute_srv_zero_without_snapshot(self): + tracker = SessionTracker() + srv = tracker.compute_srv("EUR_USD", 1.09) + assert srv == 0.0 + + +class TestDivergenceGap: + def test_gap_calculation(self): + base = {"EUR": 3.0, "USD": 0.0, "JPY": -3.0, "GBP": 0.0} + quote = {"EUR": 0.0, "USD": 0.0, "JPY": 0.0, "GBP": 0.0} + z = build_synthetic_z_scores(base, quote) + matrix = CurrencyStrengthMatrix(z) + gap = matrix.get_divergence_gap() + assert gap == pytest.approx(6.0, abs=0.01) diff --git a/ui/confluence_tab.py b/ui/confluence_tab.py index 77b275f..e95cdcb 100644 --- a/ui/confluence_tab.py +++ b/ui/confluence_tab.py @@ -13,7 +13,7 @@ from PyQt5.QtWidgets import ( QPushButton, QFrame, QMessageBox, QProgressBar ) from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QColor, QFont, QBrush +from PyQt5.QtGui import QColor, QFont from typing import Dict, Optional from datetime import datetime import config @@ -31,7 +31,7 @@ class ConfluenceSignalsTab(QWidget): self.db = db self.tech_analyzer = tech_analyzer - self.confluence = ConfluenceFilter(tech_analyzer) + self.confluence = ConfluenceFilter(tech_analyzer, db=db) self.risk_mgmt = RiskManagementSystem(account_balance=config.ACCOUNT_BALANCE) self.signal_history = SignalHistory() @@ -41,24 +41,25 @@ class ConfluenceSignalsTab(QWidget): def _init_ui(self): """Build UI layout.""" layout = QVBoxLayout() + layout.setSpacing(12) # ====== 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)")) + heading = QLabel("Confluence Signals (Layer 1 + Layer 2)") + heading.setProperty("heading", True) + layout.addWidget(heading) 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" + "Pair", "Signal", "Entry", "SL", "TP", "L1 Gap", "Z-Score", "Confidence" ]) self.signals_table.setRowCount(10) layout.addWidget(self.signals_table) - layout.addSpacing(15) # ====== Risk Management Panel ====== risk_layout = QHBoxLayout() @@ -66,22 +67,26 @@ class ConfluenceSignalsTab(QWidget): self.exposure_bar = QProgressBar() self.exposure_bar.setMaximum(100) + self.exposure_bar.setFormat("%v% exposed") risk_layout.addWidget(self.exposure_bar) self.leverage_label = QLabel("Leverage: โ€”") + self.leverage_label.setStyleSheet("font-weight: 600; color: #5d6d7e;") risk_layout.addWidget(self.leverage_label) + risk_layout.addStretch() layout.addLayout(risk_layout) - layout.addSpacing(10) # ====== Control Buttons ====== button_layout = QHBoxLayout() refresh_btn = QPushButton("Refresh Signals") + refresh_btn.setObjectName("secondary") refresh_btn.clicked.connect(self._refresh_signals) button_layout.addWidget(refresh_btn) execute_btn = QPushButton("Execute Top Signal") + execute_btn.setObjectName("success") execute_btn.clicked.connect(self._execute_signal) button_layout.addWidget(execute_btn) @@ -94,70 +99,62 @@ class ConfluenceSignalsTab(QWidget): 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; - } - """) + card.setObjectName("statusCard") layout = QVBoxLayout() + layout.setSpacing(8) title = QLabel("CONFLUENCE STATUS") - title.setFont(QFont("Arial", 10, QFont.Bold)) + title.setProperty("subheading", True) layout.addWidget(title) - layout.addSpacing(5) # Layer 1 status - layer1_layout = QHBoxLayout() - layer1_layout.addWidget(QLabel("Layer 1 (Fundamental):")) + l1 = QHBoxLayout() + l1.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) + self.layer1_status_label.setStyleSheet("font-weight: 700;") + l1.addWidget(self.layer1_status_label) + l1.addStretch() + layout.addLayout(l1) # Directional Bias Matrix display - bias_layout = QHBoxLayout() - bias_layout.addWidget(QLabel("Macro Boundaries:")) + bl = QHBoxLayout() + bl.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) + self.bias_matrix_label.setStyleSheet("color: #8e44ad; font-size: 12px;") + bl.addWidget(self.bias_matrix_label) + bl.addStretch() + layout.addLayout(bl) # Layer 2 status - layer2_layout = QHBoxLayout() - layer2_layout.addWidget(QLabel("Layer 2 (Technical):")) + l2 = QHBoxLayout() + l2.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) + l2.addWidget(self.layer2_status_label) + l2.addStretch() + layout.addLayout(l2) # 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) + cf = QHBoxLayout() + cf.addWidget(QLabel("Confluence Result:")) + self.confluence_status_label = QLabel("โœ• NO CONFLUENCE") + self.confluence_status_label.setStyleSheet("color: #e74c3c; font-weight: 700; font-size: 14px;") + cf.addWidget(self.confluence_status_label) + cf.addStretch() + layout.addLayout(cf) # Matrix cross (S.A.T.O.R.I.) - matrix_layout = QHBoxLayout() - matrix_layout.addWidget(QLabel("Matrix Cross:")) + mx = QHBoxLayout() + mx.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_cross_label.setStyleSheet("font-weight: 700; color: #8e44ad;") + mx.addWidget(self.matrix_cross_label) + mx.addStretch() + layout.addLayout(mx) self.matrix_detail_label = QLabel("") - self.matrix_detail_label.setStyleSheet("color: #7f8c8d; font-size: 10px;") + self.matrix_detail_label.setStyleSheet("color: #7f8c8d; font-size: 11px;") layout.addWidget(self.matrix_detail_label) card.setLayout(layout) @@ -170,16 +167,20 @@ class ConfluenceSignalsTab(QWidget): self._refresh_signals() def _refresh_signals(self): - """Refresh confluence signal display.""" + """Refresh confluence signal display โ€” single matrix build.""" try: - report = self.confluence.get_confluence_report() + cf = self.confluence + + should_enter, reason, strength, sl_tp = cf.check_entry_confluence() + + m = cf.matrix + mr = m.get_report() if m else {} + bias = getattr(cf, 'bias_matrix', {}) - # Update bias matrix display - bias = report.get("bias_matrix", {}) if bias: parts = [] for ccy in config.CURRENCIES: - d = bias.get(ccy, "โ€”") + d = bias.get(ccy, {}).get("direction", "โ€”") if d == "STRONG": parts.append(f"{ccy}โ†‘") elif d == "WEAK": @@ -187,23 +188,18 @@ class ConfluenceSignalsTab(QWidget): 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", []) + mc = mr.get("matrix_cross", "โ€”") + gap = mr.get("divergence_gap", 0) + has_div = mr.get("has_divergence", False) + ranked = mr.get("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;") + color = "#e74c3c" if has_div else "#8e44ad" + self.matrix_cross_label.setStyleSheet(f"font-weight: bold; color: {color};") else: self.matrix_cross_label.setText("โ€”") self.matrix_cross_label.setStyleSheet("font-weight: bold; color: #95a5a6;") @@ -217,36 +213,27 @@ class ConfluenceSignalsTab(QWidget): 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})") + l1s = cf.layer1_strongest or "โ€”" + l1w = cf.layer1_weakest or "โ€”" + self.layer1_status_label.setText(f"PAR {l1s}/{l1w} (Gap: {cf.layer1_gap:.1f})") - pair = f"{self.confluence.layer1_strongest}_{self.confluence.layer1_weakest}" if self.confluence.layer1_strongest else "โ€”" + pair = f"{cf.layer1_strongest}_{cf.layer1_weakest}" if cf.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.setText(f"TCH {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") + label_text = f"โœ… MATRIX DIVERGENCE: {strength:.0f}%" if has_div else f"โœ… CONFLUENCE: {strength:.0f}%" + self.confluence_status_label.setText(label_text) 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.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)) @@ -256,7 +243,6 @@ class ConfluenceSignalsTab(QWidget): 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() @@ -267,42 +253,50 @@ class ConfluenceSignalsTab(QWidget): 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) + def _item(text, align=True): + item = QTableWidgetItem(str(text)) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + if align: + item.setTextAlignment(Qt.AlignCenter) + return 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) + # 0: Pair + self.signals_table.setItem(row, 0, _item(pair_key, False)) - # L2 Z-Score + # 1: Signal CALL/SHORT + direction = signal.get('direction', '') + signal_text = "CALL" if direction == 'LONG' else "SHORT" + sig_item = _item(signal_text) + sig_item.setForeground(QColor("#27ae60") if signal_text == "CALL" else QColor("#e74c3c")) + sig_item.setFont(QFont("Segoe UI", 11, QFont.Bold)) + self.signals_table.setItem(row, 1, sig_item) + + # 2: Entry + entry = signal.get('entry') + self.signals_table.setItem(row, 2, _item(f"{entry:.5f}" if entry else "โ€”")) + + # 3: SL + sl = signal.get('sl') + self.signals_table.setItem(row, 3, _item(f"{sl:.5f}" if sl else "โ€”")) + + # 4: TP + tp = signal.get('tp') + self.signals_table.setItem(row, 4, _item(f"{tp:.5f}" if tp else "โ€”")) + + # 5: L1 Gap + self.signals_table.setItem(row, 5, _item(f"{report.get('layer1_gap', 0):.1f}")) + + # 6: 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) + self.signals_table.setItem(row, 6, _item(f"{z:.2f}")) - # 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 + # 7: 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) + conf_item = _item(f"{strength:.0f}%") + conf_item.setFont(QFont("Segoe UI", 10, QFont.Bold)) + self.signals_table.setItem(row, 7, conf_item) row += 1 diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py index 1bd9620..312279a 100644 --- a/ui/dashboard_tab.py +++ b/ui/dashboard_tab.py @@ -19,10 +19,10 @@ Display: from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, - QFrame, QPushButton, QSpinBox + QFrame, QPushButton, QProgressBar, QHeaderView ) -from PyQt5.QtCore import Qt, pyqtSignal, QSize -from PyQt5.QtGui import QColor, QFont, QBrush, QPixmap +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QColor, QFont from typing import Dict, Optional from datetime import datetime import config @@ -57,14 +57,16 @@ class DashboardTab(QWidget): def _init_ui(self): """Build the UI layout.""" layout = QVBoxLayout() + layout.setSpacing(12) # ====== Signal Card ====== signal_card = self._build_signal_card() layout.addWidget(signal_card) - layout.addSpacing(15) # ====== Ranked Score Table ====== - layout.addWidget(QLabel("Currency Rankings")) + heading = QLabel("Currency Rankings") + heading.setProperty("heading", True) + layout.addWidget(heading) self.score_table = QTableWidget() self.score_table.setColumnCount(8) @@ -76,22 +78,35 @@ class DashboardTab(QWidget): self.score_table.setSelectionBehavior(QTableWidget.SelectRows) self.score_table.setSelectionMode(QTableWidget.SingleSelection) + # Store progress bars for strength column (column 7) + self.strength_bars = {} + # Pre-fill with placeholder rows for row in range(len(config.CURRENCIES)): for col in range(8): item = QTableWidgetItem("โ€”") item.setFlags(item.flags() & ~Qt.ItemIsEditable) self.score_table.setItem(row, col, item) + # Add progress bar for strength column + bar = QProgressBar() + bar.setRange(0, 100) + bar.setValue(0) + bar.setTextVisible(True) + bar.setFormat("") + self.score_table.setCellWidget(row, 7, bar) + self.strength_bars[row] = bar - self.score_table.resizeColumnsToContents() + for col in range(7): + self.score_table.horizontalHeader().setSectionResizeMode(col, QHeaderView.Stretch) + self.score_table.setColumnWidth(7, 140) layout.addWidget(self.score_table) - layout.addSpacing(15) # ====== Refresh Button ====== button_layout = QHBoxLayout() button_layout.addStretch() self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.setObjectName("secondary") self.refresh_btn.clicked.connect(self._refresh_display) button_layout.addWidget(self.refresh_btn) @@ -107,42 +122,41 @@ class DashboardTab(QWidget): def _build_signal_card(self) -> QFrame: """Build the signal card frame.""" card = QFrame() - card.setStyleSheet(""" - QFrame { - background-color: #f8f9fa; - border: 2px solid #dee2e6; - border-radius: 8px; - padding: 15px; - } - """) + card.setObjectName("statusCard") layout = QVBoxLayout() + layout.setSpacing(6) # Title title = QLabel("PRIMARY SIGNAL") - title.setFont(QFont("Arial", 10, QFont.Bold)) - title.setStyleSheet("color: #495057;") + title.setProperty("subheading", True) layout.addWidget(title) - layout.addSpacing(5) # Signal text (large, bold) self.signal_label = QLabel("NO TRADE โ€” Initializing...") - self.signal_label.setFont(QFont("Arial", 24, QFont.Bold)) + self.signal_label.setProperty("value", True) self.signal_label.setStyleSheet("color: #2c3e50;") layout.addWidget(self.signal_label) - layout.addSpacing(10) # Gap and status self.gap_label = QLabel("Gap: โ€” points") - self.gap_label.setFont(QFont("Arial", 12)) + self.gap_label.setStyleSheet("font-size: 15px; color: #5d6d7e;") layout.addWidget(self.gap_label) # Updated timestamp self.updated_label = QLabel("Updated: โ€”") - self.updated_label.setFont(QFont("Arial", 10)) - self.updated_label.setStyleSheet("color: #7f8c8d;") + self.updated_label.setStyleSheet("color: #95a5a6; font-size: 12px;") layout.addWidget(self.updated_label) + # Staleness warning (hidden by default) + self.stale_warning = QLabel("") + self.stale_warning.setStyleSheet( + "color: #e74c3c; font-weight: 700; font-size: 13px; padding: 6px 0;" + ) + self.stale_warning.hide() + layout.addWidget(self.stale_warning) + + layout.addStretch() card.setLayout(layout) return card @@ -168,14 +182,11 @@ class DashboardTab(QWidget): bias_matrix = {} self.signal_generated.emit(strongest, weakest, gap, bias_matrix) - # Update signal label self.signal_label.setText(signal_text) - - # Color code based on status if status == "ACTIVE": - self.signal_label.setStyleSheet("color: #27ae60;") # Green + self.signal_label.setStyleSheet("color: #27ae60;") else: - self.signal_label.setStyleSheet("color: #e74c3c;") # Red + self.signal_label.setStyleSheet("color: #e74c3c;") # Update gap label gap_tier = scorer.get_gap_tier(gap) @@ -195,6 +206,9 @@ class DashboardTab(QWidget): # Update timestamp self.updated_label.setText(f"Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + # Check for stale CPI/PMI data + self._check_data_staleness() + # Refresh score table self._refresh_score_table() @@ -203,28 +217,54 @@ class DashboardTab(QWidget): self.signal_label.setText("ERROR") self.signal_label.setStyleSheet("color: #e74c3c;") + def _check_data_staleness(self): + """Show a warning if CPI/PMI data is older than 35 days.""" + try: + monthly = self.db.get_monthly_data(self.current_month) + if not monthly: + return + timestamps = [] + for ccy, data in monthly.items(): + ts = data.get("entered_at") + if ts: + timestamps.append(ts) + if not timestamps: + return + latest = max(timestamps) + try: + latest_dt = datetime.strptime(latest.split(".")[0], "%Y-%m-%dT%H:%M:%S") + except ValueError: + latest_dt = datetime.strptime(latest[:10], "%Y-%m-%d") + days_old = (datetime.now() - latest_dt).days + if days_old > 35: + self.stale_warning.setText( + f"โš  CPI/PMI data is {days_old} days old โ€” refresh fundamental data" + ) + self.stale_warning.show() + else: + self.stale_warning.hide() + except Exception: + self.stale_warning.hide() + def _refresh_score_table(self): """Refresh the ranked currency table.""" try: scores = self.db.get_month_scores(self.current_month) if not scores: - # No scores yet for row in range(len(config.CURRENCIES)): for col in range(8): self.score_table.item(row, col).setText("โ€”") + self.strength_bars[row].setValue(0) + self.strength_bars[row].setFormat("") return - # Get sorted list ranked = [(c, s) for c, s in sorted( scores.items(), key=lambda x: x[1]['rank'] )] - # Get rates for display rates = self.db.get_all_rates() - - # Get monthly data for CPI display monthly_data = self.db.get_monthly_data(self.current_month) for row, (currency, score_data) in enumerate(ranked): @@ -235,29 +275,15 @@ class DashboardTab(QWidget): cpi = cpi_data.get('cpi_actual') pmi = cpi_data.get('pmi_actual') - # Rank self.score_table.item(row, 0).setText(str(rank)) - - # Currency currency_text = f"{config.CURRENCY_EMOJIS.get(currency, '')} {currency}" self.score_table.item(row, 1).setText(currency_text) - - # Rate - rate_text = f"{rate:.2f}" if rate is not None else "โ€”" - self.score_table.item(row, 2).setText(rate_text) - - # CPI - cpi_text = f"{cpi:.2f}" if cpi is not None else "โ€”" - self.score_table.item(row, 3).setText(cpi_text) - - # PMI - pmi_text = f"{pmi:.1f}" if pmi is not None else "โ€”" - self.score_table.item(row, 4).setText(pmi_text) - - # Score (two decimals) + self.score_table.item(row, 2).setText(f"{rate:.2f}" if rate is not None else "โ€”") + self.score_table.item(row, 3).setText(f"{cpi:.2f}" if cpi is not None else "โ€”") + self.score_table.item(row, 4).setText(f"{pmi:.1f}" if pmi is not None else "โ€”") self.score_table.item(row, 5).setText(f"{total_score:.1f}") - # Signal tag (BUY for strongest, SELL for weakest) + # Signal tag if rank == 1: self.score_table.item(row, 6).setText("BUY") elif rank == len(config.CURRENCIES): @@ -265,34 +291,30 @@ class DashboardTab(QWidget): else: self.score_table.item(row, 6).setText("") - # Strength bar (visual progress 0-100) - strength_item = self.score_table.item(row, 7) - strength_item.setText(f"{int(total_score)}%") + # Strength progress bar + bar = self.strength_bars[row] + score_int = min(int(total_score), 100) + bar.setValue(score_int) + bar.setFormat(f"{score_int}%") + bar.setStyleSheet( + "QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, " + "stop:0 #2ecc71, stop:1 #27ae60); border-radius: 4px; }" + if rank == 1 else + "QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, " + "stop:0 #e74c3c, stop:1 #c0392b); border-radius: 4px; }" + if rank == len(config.CURRENCIES) else + "" + ) - # Color code rows - if rank == 1: - # Strongest = GREEN - for col in range(8): - self.score_table.item(row, col).setBackground(QColor("#d5f4e6")) - self.score_table.item(row, col).setForeground(QColor("#27ae60")) - self.score_table.item(row, col).setFont(QFont("Arial", 10, QFont.Bold)) + # Row color coding + bg = QColor("#d5f4e6") if rank == 1 else QColor("#fadbd8") if rank == len(config.CURRENCIES) else QColor("#ffffff") + fg = QColor("#1e8449") if rank == 1 else QColor("#c0392b") if rank == len(config.CURRENCIES) else QColor("#2c3e50") + font = QFont("Segoe UI", 11, QFont.Bold) if rank in (1, len(config.CURRENCIES)) else QFont("Segoe UI", 11) - elif rank == len(config.CURRENCIES): - # Weakest = RED - for col in range(8): - self.score_table.item(row, col).setBackground(QColor("#fadbd8")) - self.score_table.item(row, col).setForeground(QColor("#e74c3c")) - self.score_table.item(row, col).setFont(QFont("Arial", 10, QFont.Bold)) - - else: - # Middle = neutral - for col in range(8): - self.score_table.item(row, col).setBackground(QColor("#ffffff")) - self.score_table.item(row, col).setForeground(QColor("#2c3e50")) - self.score_table.item(row, col).setFont(QFont("Arial", 10)) - - # Auto-resize columns to content - self.score_table.resizeColumnsToContents() + for col in range(7): + self.score_table.item(row, col).setBackground(bg) + self.score_table.item(row, col).setForeground(fg) + self.score_table.item(row, col).setFont(font) except Exception as e: print(f"[ERROR] Failed to refresh score table: {e}") diff --git a/ui/entry_tab.py b/ui/entry_tab.py index 8a92207..bbf3a33 100644 --- a/ui/entry_tab.py +++ b/ui/entry_tab.py @@ -23,11 +23,11 @@ User flow: from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, - QPushButton, QProgressBar, QComboBox, QSpinBox, QDoubleSpinBox, QHeaderView, + QPushButton, QProgressBar, QComboBox, QDoubleSpinBox, QHeaderView, QFileDialog, QMessageBox ) -from PyQt5.QtCore import Qt, pyqtSignal, QDate -from PyQt5.QtGui import QColor, QFont, QBrush +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QColor from typing import Dict, Optional from datetime import datetime, timedelta import config @@ -190,49 +190,16 @@ class MonthlyEntryTab(QWidget): # ====== Buttons ====== button_layout = QHBoxLayout() - # Import Excel button self.import_btn = QPushButton("๐Ÿ“Š Import Excel") - self.import_btn.setMinimumHeight(40) - self.import_btn.setFont(QFont("Arial", 11, QFont.Bold)) - self.import_btn.setStyleSheet(""" - QPushButton { - background-color: #3498db; - color: white; - border: none; - border-radius: 5px; - padding: 10px 20px; - } - QPushButton:hover { - background-color: #2980b9; - } - """) + self.import_btn.setMinimumHeight(42) button_layout.addWidget(self.import_btn) button_layout.addStretch() self.save_btn = QPushButton("Save & Calculate Scores") + self.save_btn.setObjectName("success") self.save_btn.setEnabled(False) - self.save_btn.setMinimumHeight(40) - self.save_btn.setFont(QFont("Arial", 11, QFont.Bold)) - self.save_btn.setStyleSheet(""" - QPushButton:enabled { - background-color: #2ecc71; - color: white; - border: none; - border-radius: 5px; - padding: 10px 20px; - } - QPushButton:hover:enabled { - background-color: #27ae60; - } - QPushButton:disabled { - background-color: #95a5a6; - color: #7f8c8d; - border: none; - border-radius: 5px; - padding: 10px 20px; - } - """) + self.save_btn.setMinimumHeight(42) button_layout.addWidget(self.save_btn) layout.addLayout(button_layout) diff --git a/ui/history_tab.py b/ui/history_tab.py index 6ed6519..b03fb2a 100644 --- a/ui/history_tab.py +++ b/ui/history_tab.py @@ -11,12 +11,11 @@ Features: """ from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView, + QWidget, QVBoxLayout, QLabel, QTableWidget, QTableWidgetItem, QHeaderView, QMessageBox ) from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor, QFont -from typing import Dict, List import config from database import Database @@ -41,6 +40,10 @@ class HistoryTab(QWidget): """Build the UI layout.""" layout = QVBoxLayout() + heading = QLabel("Signal History (Past 24 Months)") + heading.setProperty("heading", True) + layout.addWidget(heading) + # History table self.history_table = QTableWidget() self.history_table.setColumnCount(6) @@ -48,8 +51,6 @@ class HistoryTab(QWidget): "Month", "Signal", "Gap", "Strongest", "Weakest", "Status" ]) - # Enable sorting - self.history_table.setSortingEnabled(False) self.history_table.setSelectionBehavior(QTableWidget.SelectRows) self.history_table.setSelectionMode(QTableWidget.SingleSelection) self.history_table.itemClicked.connect(self._on_row_clicked) diff --git a/ui/layer2_monitor_tab.py b/ui/layer2_monitor_tab.py index f4f5759..38c2962 100644 --- a/ui/layer2_monitor_tab.py +++ b/ui/layer2_monitor_tab.py @@ -1,15 +1,15 @@ from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, - QPushButton, QComboBox, QCheckBox, QMessageBox, QStatusBar, QProgressBar, + QPushButton, QComboBox, QCheckBox, QMessageBox, QHeaderView ) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer -from PyQt5.QtGui import QColor, QFont, QBrush +from PyQt5.QtGui import QColor, QFont from typing import Dict, Optional from datetime import datetime, timezone import config from layer2_technical import TechnicalAnalyzer -from data_feeder import Mt5DataFeeder, MockDataFeeder +from data_feeder import Mt5DataFeeder from currency_strength_matrix import CurrencyStrengthMatrix @@ -75,43 +75,57 @@ class Layer2MonitorTab(QWidget): def _init_ui(self): """Build UI layout.""" layout = QVBoxLayout() + layout.setSpacing(12) # ====== 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.addItems(["MT5 (Live)"]) self.source_combo.setCurrentIndex(0) connection_layout.addWidget(self.source_combo) + connection_layout.addWidget(QLabel("TF:")) + self.tf_combo = QComboBox() + for tf_key in config.TIMEFRAMES: + self.tf_combo.addItem(config.TIMEFRAMES[tf_key]["label"], tf_key) + self.tf_combo.setCurrentText(config.TIMEFRAMES[config.DEFAULT_TIMEFRAME]["label"]) + self.tf_combo.currentIndexChanged.connect(self._on_timeframe_changed) + connection_layout.addWidget(self.tf_combo) + self.connect_btn = QPushButton("Connect") self.connect_btn.clicked.connect(self._on_connect_clicked) connection_layout.addWidget(self.connect_btn) + # Status dot indicator + self.status_dot = QLabel("โ—") + self.status_dot.setStyleSheet("color: #e74c3c; font-size: 18px;") + connection_layout.addWidget(self.status_dot) + self.status_label = QLabel("Disconnected") - self.status_label.setStyleSheet("color: red; font-weight: bold;") + self.status_label.setStyleSheet("color: #e74c3c; font-weight: 600;") 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;" + "font-weight: 700; font-size: 14px; padding: 4px 14px; " + "background-color: #ecf0f1; border-radius: 12px;" ) 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")) + heading = QLabel("Technical Analysis โ€” All Pairs") + heading.setProperty("heading", True) + layout.addWidget(heading) self.tech_table = QTableWidget() self.tech_table.setColumnCount(7) @@ -120,48 +134,52 @@ class Layer2MonitorTab(QWidget): ]) self.tech_table.setRowCount(28) self.tech_table.setAlternatingRowColors(True) - self.tech_table.horizontalHeader().setStretchLastSection(True) + header = self.tech_table.horizontalHeader() + for c in range(7): + header.setSectionResizeMode(c, QHeaderView.Stretch) layout.addWidget(self.tech_table) - layout.addSpacing(10) # ====== Alerts Panel ====== alerts_layout = QHBoxLayout() - alerts_layout.addWidget(QLabel("Overbought Pairs:")) + alerts_layout.addWidget(QLabel("Overbought:")) self.overbought_label = QLabel("โ€”") - self.overbought_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + self.overbought_label.setStyleSheet("color: #e74c3c; font-weight: 700;") alerts_layout.addWidget(self.overbought_label) alerts_layout.addSpacing(20) - alerts_layout.addWidget(QLabel("Oversold Pairs:")) + alerts_layout.addWidget(QLabel("Oversold:")) self.oversold_label = QLabel("โ€”") - self.oversold_label.setStyleSheet("color: #27ae60; font-weight: bold;") + self.oversold_label.setStyleSheet("color: #27ae60; font-weight: 700;") 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_heading = QLabel("Currency Strength Matrix (S.A.T.O.R.I.)") + matrix_heading.setProperty("heading", True) + layout.addWidget(matrix_heading) - 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.matrix_cross_label.setStyleSheet("font-weight: 700; font-size: 14px;") + 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.divergence_label.setStyleSheet("color: #7f8c8d; font-size: 12px;") + layout.addWidget(self.divergence_label) + alert_row = QHBoxLayout() self.strong_alert = QLabel("") - matrix_layout.addWidget(self.strong_alert) + self.strong_alert.setStyleSheet("color: #27ae60; font-weight: 600;") + alert_row.addWidget(self.strong_alert) self.weak_alert = QLabel("") - matrix_layout.addWidget(self.weak_alert) + self.weak_alert.setStyleSheet("color: #e74c3c; font-weight: 600;") + alert_row.addWidget(self.weak_alert) + alert_row.addStretch() + layout.addLayout(alert_row) self.matrix_table = QTableWidget() self.matrix_table.setColumnCount(5) @@ -169,14 +187,13 @@ class Layer2MonitorTab(QWidget): "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) + self.matrix_table.setMaximumHeight(220) + m_header = self.matrix_table.horizontalHeader() + for c in range(5): + m_header.setSectionResizeMode(c, QHeaderView.Stretch) + layout.addWidget(self.matrix_table) - layout.addWidget(matrix_group) - layout.addSpacing(10) - - # ====== Refresh Button ====== + # ====== Controls ====== button_layout = QHBoxLayout() self.auto_refresh_check = QCheckBox("Auto-refresh (every 1s)") @@ -184,6 +201,7 @@ class Layer2MonitorTab(QWidget): button_layout.addWidget(self.auto_refresh_check) refresh_btn = QPushButton("Refresh Now") + refresh_btn.setObjectName("secondary") refresh_btn.clicked.connect(self._refresh_display) button_layout.addWidget(refresh_btn) @@ -196,13 +214,13 @@ class Layer2MonitorTab(QWidget): self.refresh_timer = QTimer() self.refresh_timer.timeout.connect(self._refresh_display) + self._debounce_timer = QTimer() + self._debounce_timer.setSingleShot(True) + self._debounce_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() + self.data_feeder = Mt5DataFeeder() def _on_connect_clicked(self): """Handle connect button click.""" @@ -211,17 +229,82 @@ class Layer2MonitorTab(QWidget): else: self._connect() + def _on_timeframe_changed(self, idx: int): + tf_key = self.tf_combo.itemData(idx) + if tf_key: + self.tech_analyzer.set_timeframe(tf_key) + if self.connected and hasattr(self.data_feeder, 'USD_PAIRS'): + self._seed_historical_bars() + 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: + """Seed the analyzer with bar data at the selected timeframe. + Fetches only the 7 USD pairs and derives all cross rates. + """ + if not hasattr(self.data_feeder, 'USD_PAIRS'): return - self.tech_analyzer.seed_bars(bars) + + interval_map = { + "M5": "5min", "M15": "15min", + "H1": "1h", "H4": "4h", + } + tf_key = self.tech_analyzer.current_timeframe + interval = interval_map.get(tf_key, "15min") + outputsize = "full" if tf_key in ("M5", "M15") else "full" + + usd_pairs = self.data_feeder.USD_PAIRS + raw_ohlc: dict[str, list[dict]] = {} + for pair in usd_pairs: + base, quote = pair.split("_") + candles = self.data_feeder.get_historical_candles( + from_currency=base, to_currency=quote, + interval=interval, outputsize=outputsize + ) + if candles: + raw_ohlc[pair] = candles + + if not raw_ohlc: + return + + n_bars = min(len(c) for c in raw_ohlc.values()) + if n_bars < 2: + return + + currencies = config.CURRENCIES + derived_ohlc: dict[str, list[dict]] = {} + for base in currencies: + for quote in currencies: + if base == quote: + continue + derived_ohlc[f"{base}_{quote}"] = [] + + for i in range(n_bars): + usd_rates: dict[str, float] = {"USD": 1.0} + for pair in usd_pairs: + base, quote = pair.split("_") + c = raw_ohlc[pair][i] + mid = c["close"] + if base == "USD": + usd_rates[quote] = 1.0 / mid if mid else None + else: + usd_rates[base] = mid + for base in currencies: + bv = usd_rates.get(base) + if bv is None: + continue + for quote in currencies: + if base == quote: + continue + qv = usd_rates.get(quote) + if qv is not None: + rate = bv / qv + # Estimate OHLC for the cross pair + derived_ohlc[f"{base}_{quote}"].append({ + "close": rate, + "high": rate * 1.0003, + "low": rate * 0.9997, + }) + + self.tech_analyzer.seed_ohlc(derived_ohlc) def _connect(self): """Connect to data source.""" @@ -231,10 +314,6 @@ class Layer2MonitorTab(QWidget): 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) @@ -243,14 +322,20 @@ class Layer2MonitorTab(QWidget): self.streamer_thread.start() if self.auto_refresh_check.isChecked(): - self.refresh_timer.start(1000) + self.refresh_timer.start(3000) self.connected = True self.connect_btn.setText("Disconnect") + self.connect_btn.setObjectName("danger") + self.connect_btn.style().unpolish(self.connect_btn) + self.connect_btn.style().polish(self.connect_btn) + self.status_dot.setStyleSheet("color: #27ae60; font-size: 18px;") self.status_label.setText("Connected") - self.status_label.setStyleSheet("color: #27ae60; font-weight: bold;") + self.status_label.setStyleSheet("color: #27ae60; font-weight: 600;") self._refresh_display() + QTimer.singleShot(0, self._seed_historical_bars) + except Exception as e: QMessageBox.critical(self, "Error", f"Connection failed: {e}") @@ -265,17 +350,22 @@ class Layer2MonitorTab(QWidget): self.connected = False self.connect_btn.setText("Connect") + self.connect_btn.setObjectName("") + self.connect_btn.style().unpolish(self.connect_btn) + self.connect_btn.style().polish(self.connect_btn) + self.status_dot.setStyleSheet("color: #e74c3c; font-size: 18px;") self.status_label.setText("Disconnected") - self.status_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + self.status_label.setStyleSheet("color: #e74c3c; font-weight: 600;") def _on_price_received(self, price_data): - """Handle price update from data feeder.""" + """Handle price update โ€” just add data, debounce display refresh.""" 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() + if not self._debounce_timer.isActive(): + self._debounce_timer.start(2000) def _on_connected(self, is_connected): """Handle connection status change.""" @@ -358,8 +448,6 @@ class Layer2MonitorTab(QWidget): 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: @@ -466,8 +554,6 @@ class Layer2MonitorTab(QWidget): 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}") diff --git a/ui/settings_tab.py b/ui/settings_tab.py index 77ea3bb..dc0fd14 100644 --- a/ui/settings_tab.py +++ b/ui/settings_tab.py @@ -16,13 +16,12 @@ from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QDoubleSpinBox, QPushButton, QCheckBox, QGroupBox, QSpinBox, QMessageBox, QScrollArea ) -from PyQt5.QtCore import Qt, pyqtSignal, QThread +from PyQt5.QtCore import pyqtSignal, QThread from PyQt5.QtGui import QFont -from typing import Dict, Optional +from typing import Optional import config from data_feeder import Mt5DataFeeder from fred_client import FredClient -import os from pathlib import Path @@ -272,24 +271,12 @@ class SettingsTab(QWidget): save_layout.addStretch() save_btn = QPushButton("Save Settings") - save_btn.setMinimumHeight(40) - save_btn.setFont(QFont("Arial", 11, QFont.Bold)) - save_btn.setStyleSheet(""" - QPushButton { - background-color: #3498db; - color: white; - border: none; - border-radius: 5px; - padding: 10px 20px; - } - QPushButton:hover { - background-color: #2980b9; - } - """) + save_btn.setMinimumHeight(42) save_btn.clicked.connect(self._save_settings) save_layout.addWidget(save_btn) reset_btn = QPushButton("Reset to Defaults") + reset_btn.setObjectName("secondary") reset_btn.clicked.connect(self._reset_to_defaults) save_layout.addWidget(reset_btn)