commit 6bfcc67088df1b419779fad47fbbb06f5ee85c4c Author: saber Date: Tue Jun 16 12:27:37 2026 +0100 v1 first layer diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b005b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,69 @@ +# APEX Layer 1 — Environment Configuration Example +# +# Copy this file to .env and fill in your values +# cp .env.example .env + +# ============================================================================ +# FRED API Configuration (Required) +# ============================================================================ +# Get a free API key from: https://fred.stlouisfed.org +# 1. Register for an account +# 2. Go to Account → API Keys +# 3. Copy your key and paste below +FRED_API_KEY=paste_your_fred_api_key_here + +# ============================================================================ +# Database Configuration +# ============================================================================ +# Path to SQLite database file +DB_PATH=apex.db + +# Database connection timeout (seconds) +DB_TIMEOUT=10 + +# Auto-create schema on first run +DB_AUTO_CREATE=true + +# ============================================================================ +# Debugging & Logging +# ============================================================================ +# Enable debug output to console (true/false) +DEBUG=true + +# ============================================================================ +# Scoring Weights (must sum to 1.0) +# ============================================================================ +# Interest rate differential weight (50% default) +WEIGHT_RATE=0.50 + +# CPI deviation weight (30% default) +WEIGHT_CPI=0.30 + +# PMI composite weight (20% default) +WEIGHT_PMI=0.20 + +# ============================================================================ +# Trading Rules +# ============================================================================ +# Minimum gap in points to generate trade signal (default 20) +# Gap < 20: NO TRADE +# Gap 20-40: Weak signal +# Gap 40-60: Standard signal +# Gap > 60: Strong signal +MIN_GAP=20.0 + +# ============================================================================ +# Auto-Fetch Configuration +# ============================================================================ +# Automatically fetch rates from FRED on app startup (true/false) +AUTO_FETCH_RATES_ON_STARTUP=true + +# ============================================================================ +# Application UI Settings +# ============================================================================ +# Window title +APP_TITLE=APEX Layer 1 — Currency Strength Engine + +# Default window size (width x height) +WINDOW_WIDTH=1200 +WINDOW_HEIGHT=800 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ef2d6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,88 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# Database files +*.db +*.sqlite +*.sqlite3 +apex.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +*.sublime-project +*.sublime-workspace + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# macOS +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# Excel / CSV imports (keep examples) +example_*.xlsx +example_*.csv + +# Temporary files +*.tmp +*.bak +*.backup +~$* + +# OS specific +Thumbs.db +.DS_Store +.Thumbs.db diff --git a/EXCEL_IMPORT_GUIDE.md b/EXCEL_IMPORT_GUIDE.md new file mode 100644 index 0000000..eedc819 --- /dev/null +++ b/EXCEL_IMPORT_GUIDE.md @@ -0,0 +1,178 @@ +# 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 new file mode 100644 index 0000000..8ffaf9e --- /dev/null +++ b/EXCEL_IMPORT_PROMPT.md @@ -0,0 +1,100 @@ +# 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 new file mode 100644 index 0000000..afdd12d --- /dev/null +++ b/GITHUB_QUICK_START.md @@ -0,0 +1,71 @@ +# 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/__init__.py b/__init__.py new file mode 100644 index 0000000..59c60b9 --- /dev/null +++ b/__init__.py @@ -0,0 +1,14 @@ +""" +APEX Trading System — Layer 1: Currency Strength Engine + +A PyQt5 desktop application that: +- Scores 8 major currencies on a 0-100 scale +- Ranks them from strongest to weakest +- Generates a primary trade signal (strongest vs weakest) +- Validates the signal with a minimum 20-point gap rule + +This module is the root package for Layer 1 standalone. +""" + +__version__ = "1.0.0" +__author__ = "APEX Trading" diff --git a/config.py b/config.py new file mode 100644 index 0000000..5747f75 --- /dev/null +++ b/config.py @@ -0,0 +1,204 @@ +""" +APEX Layer 1 — Configuration and Constants + +This module loads all configuration from the .env file and defines +all hardcoded constants for the Currency Strength Engine. + +Responsibilities: +- Load API keys and settings from .env +- Define the 8 major currencies tracked +- Define CB inflation targets (hardcoded — only change if CB mandate changes) +- Define FRED series IDs for interest rates +- Define scoring weights +- Define minimum gap threshold for trading +- Validate configuration on startup +""" + +import os +from dotenv import load_dotenv +from pathlib import Path + +# Load .env file from project root +env_path = Path(__file__).parent.parent / ".env" +load_dotenv(dotenv_path=env_path) + +# ============================================================================ +# FRED API Configuration +# ============================================================================ +FRED_API_KEY = os.getenv("FRED_API_KEY", "") +FRED_BASE_URL = "https://api.stlouisfed.org/fred" + +# ============================================================================ +# Database Configuration +# ============================================================================ +DB_PATH = os.getenv("DB_PATH", "apex.db") + +# ============================================================================ +# The 8 Major Currencies +# ============================================================================ +CURRENCIES = ["USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "NZD"] +NUM_CURRENCIES = len(CURRENCIES) + +# ============================================================================ +# Central Bank Inflation Targets (%) +# ============================================================================ +# These are hardcoded constants. They almost never change. +# If a central bank officially revises its mandate, update it here manually. +CB_TARGETS = { + "USD": 2.0, # Federal Reserve + "EUR": 2.0, # ECB + "GBP": 2.0, # Bank of England + "JPY": 2.0, # Bank of Japan + "AUD": 2.5, # RBA + "CAD": 2.0, # Bank of Canada + "CHF": 1.5, # SNB + "NZD": 2.0, # RBNZ +} + +# ============================================================================ +# FRED Series IDs for Interest Rates +# ============================================================================ +# These map each currency to its FRED series ID. +# If FRED returns an error, the app will fall back to manual entry (see settings). +FRED_SERIES = { + "USD": "FEDFUNDS", # US Federal Funds Rate + "EUR": "ECBDFR", # ECB Deposit Rate + "GBP": "BOEBR", # Bank of England Base Rate + "JPY": "IRSTJPN", # Japan Policy Rate (or manual from BOJ website) + "AUD": "RBATCTR", # RBA Cash Target Rate + "CAD": "BOCCRT", # BOC Policy Interest Rate + "CHF": "SNBPOL", # SNB Policy Rate + "NZD": "RBNZOCR", # RBNZ Official Cash Rate +} + +# ============================================================================ +# Scoring Configuration +# ============================================================================ +WEIGHT_RATE = float(os.getenv("WEIGHT_RATE", 0.50)) # Interest rate diff: 50% +WEIGHT_CPI = float(os.getenv("WEIGHT_CPI", 0.30)) # CPI deviation: 30% +WEIGHT_PMI = float(os.getenv("WEIGHT_PMI", 0.20)) # PMI composite: 20% + +# Verify weights sum to 1.0 (with tolerance for floating point precision) +TOTAL_WEIGHT = WEIGHT_RATE + WEIGHT_CPI + WEIGHT_PMI +if not (0.99 <= TOTAL_WEIGHT <= 1.01): + raise ValueError( + f"Weights must sum to 1.0. " + f"Current: RATE={WEIGHT_RATE}, CPI={WEIGHT_CPI}, PMI={WEIGHT_PMI} " + f"(total={TOTAL_WEIGHT})" + ) + +# ============================================================================ +# Trading Rules +# ============================================================================ +MIN_GAP_TO_TRADE = float(os.getenv("MIN_GAP", 20)) # Minimum 20-point gap + +# Gap threshold tiers (used for UI display and Layer 2+ position sizing) +GAP_THRESHOLDS = { + "no_trade": 20, # Gap < 20: NO TRADE + "weak": 40, # Gap 20-40: Weak signal, max 0.5% + "standard": 60, # Gap 40-60: Standard signal, max 1.0% + "strong": float("inf") # Gap > 60: Strong signal (Layer 5+ for full sizing) +} + +# ============================================================================ +# Auto-fetch Settings +# ============================================================================ +AUTO_FETCH_RATES_ON_STARTUP = os.getenv("AUTO_FETCH_RATES_ON_STARTUP", "true").lower() == "true" +FRED_FETCH_TIMEOUT = 10 # seconds + +# ============================================================================ +# UI Settings +# ============================================================================ +APP_TITLE = "APEX Layer 1 — Currency Strength Engine" +WINDOW_WIDTH = 1200 +WINDOW_HEIGHT = 800 +TAB_NAMES = { + "dashboard": "Dashboard", + "entry": "Monthly Entry", + "history": "History", + "settings": "Settings", +} + +# Currency display format (with flags for nice UI) +CURRENCY_EMOJIS = { + "USD": "🇺🇸", + "EUR": "🇪🇺", + "GBP": "🇬🇧", + "JPY": "🇯🇵", + "AUD": "🇦🇺", + "CAD": "🇨🇦", + "CHF": "🇨🇭", + "NZD": "🇳🇿", +} + +# ============================================================================ +# Data Validation Rules +# ============================================================================ +# For CPI entry +CPI_MIN = -10.0 # Reasonable lower bound for inflation +CPI_MAX = 50.0 # Reasonable upper bound (hyperinflation) + +# For PMI entry +PMI_MIN = 0.0 # PMI is 0-100 +PMI_MAX = 100.0 + +# For interest rates +RATE_MIN = -5.0 # Some CBs have negative rates +RATE_MAX = 20.0 # Reasonable upper bound + +# ============================================================================ +# Database Settings +# ============================================================================ +DB_AUTO_CREATE = True # Automatically create schema if DB doesn't exist +DB_TIMEOUT = 5 # Connection timeout in seconds + +# ============================================================================ +# Validation Function +# ============================================================================ +def validate_config(): + """ + Validate configuration on startup. + Raises ValueError if critical settings are missing or invalid. + """ + errors = [] + + if not FRED_API_KEY: + errors.append( + "FRED_API_KEY not set in .env file. " + "Get a free key from fred.stlouisfed.org and add to .env" + ) + + if not DB_PATH: + errors.append("DB_PATH not configured in .env or config.py") + + for currency in CURRENCIES: + if currency not in CB_TARGETS: + errors.append(f"Missing CB target for {currency}") + if currency not in FRED_SERIES: + errors.append(f"Missing FRED series ID for {currency}") + + if errors: + raise ValueError( + "Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors) + ) + + +# ============================================================================ +# Debug Mode +# ============================================================================ +DEBUG = os.getenv("DEBUG", "false").lower() == "true" + +if DEBUG: + print("[CONFIG] Debug mode enabled") + print(f"[CONFIG] FRED API Key: {FRED_API_KEY[:10]}..." if FRED_API_KEY else "[CONFIG] FRED API Key: NOT SET") + print(f"[CONFIG] Database: {DB_PATH}") + print(f"[CONFIG] Weights: Rate={WEIGHT_RATE}, CPI={WEIGHT_CPI}, PMI={WEIGHT_PMI}") + print(f"[CONFIG] Min gap to trade: {MIN_GAP_TO_TRADE}") + + +# Call validation on import (fail early if config is broken) +try: + validate_config() +except ValueError as e: + print(f"[ERROR] Configuration validation failed:\n{e}") + raise diff --git a/create_excel_template.py b/create_excel_template.py new file mode 100644 index 0000000..1b73d5e --- /dev/null +++ b/create_excel_template.py @@ -0,0 +1,98 @@ +""" +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 +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment +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/database.py b/database.py new file mode 100644 index 0000000..c9c3870 --- /dev/null +++ b/database.py @@ -0,0 +1,563 @@ +""" +APEX Layer 1 — Database Layer + +Manages all SQLite database operations: +- Auto-create schema on first run +- Insert/update/select rates (from FRED API) +- Insert/update monthly CPI and PMI data +- Calculate and store scores +- Log trading signals +- Query history by month + +All operations use parameterized queries to prevent SQL injection. +""" + +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Optional, Dict, List, Tuple +import config + + +class Database: + """SQLite database manager for APEX Layer 1.""" + + def __init__(self, db_path: str = None): + """ + Initialize database connection. + + Args: + db_path: Path to SQLite database file. If None, uses config.DB_PATH. + + Raises: + RuntimeError: If database cannot be created or connected. + """ + self.db_path = db_path or config.DB_PATH + + # Ensure parent directory exists + Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) + + try: + self.conn = sqlite3.connect( + self.db_path, + timeout=config.DB_TIMEOUT, + check_same_thread=False # Allow access from multiple threads + ) + self.conn.row_factory = sqlite3.Row # Return rows as dicts + + # Enable foreign keys + self.conn.execute("PRAGMA foreign_keys = ON") + + if config.DB_AUTO_CREATE: + self._create_schema() + + except sqlite3.Error as e: + raise RuntimeError(f"Failed to initialize database at {self.db_path}: {e}") + + def _create_schema(self): + """Create database schema if it doesn't exist.""" + cursor = self.conn.cursor() + + try: + # Table 1: Interest rates (updated via FRED API) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS rates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + currency TEXT NOT NULL UNIQUE, + rate REAL NOT NULL, + updated_at TEXT NOT NULL, + source TEXT DEFAULT 'FRED', + CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')) + ); + """) + + # Table 2: Monthly manual entries (CPI + PMI) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS monthly_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + currency TEXT NOT NULL, + cpi_actual REAL, + pmi_actual REAL, + entered_at TEXT NOT NULL, + UNIQUE(month, currency), + CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')), + CONSTRAINT valid_month CHECK (month LIKE '____-__') + ); + """) + + # Table 3: Calculated scores (generated after each data entry) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + month TEXT NOT NULL, + currency TEXT NOT NULL, + score_rate REAL, + score_cpi REAL, + score_pmi REAL, + total_score REAL NOT NULL, + rank INTEGER NOT NULL, + calculated_at TEXT NOT NULL, + UNIQUE(month, currency), + CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')), + CONSTRAINT valid_month CHECK (month LIKE '____-__') + ); + """) + + # Table 4: Signal log (one per month) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + generated_at TEXT NOT NULL, + month TEXT NOT NULL UNIQUE, + strongest TEXT NOT NULL, + weakest TEXT NOT NULL, + gap REAL NOT NULL, + signal TEXT NOT NULL, + status TEXT NOT NULL, + CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED')), + CONSTRAINT valid_month CHECK (month LIKE '____-__') + ); + """) + + self.conn.commit() + + if config.DEBUG: + print("[DB] Schema created successfully") + + except sqlite3.Error as e: + self.conn.rollback() + raise RuntimeError(f"Failed to create schema: {e}") + + # ======================================================================== + # RATES Table Operations + # ======================================================================== + + def upsert_rate(self, currency: str, rate: float, source: str = "FRED") -> None: + """ + Insert or update an interest rate. + + Args: + currency: Currency code (USD, EUR, etc.) + rate: Interest rate as percentage (e.g., 5.25) + source: Data source (default "FRED") + """ + if currency not in config.CURRENCIES: + raise ValueError(f"Invalid currency: {currency}") + + cursor = self.conn.cursor() + try: + cursor.execute(""" + INSERT INTO rates (currency, rate, updated_at, source) + VALUES (?, ?, ?, ?) + ON CONFLICT(currency) DO UPDATE SET + rate = excluded.rate, + updated_at = excluded.updated_at, + source = excluded.source + """, (currency, rate, datetime.utcnow().isoformat(), source)) + self.conn.commit() + + if config.DEBUG: + print(f"[DB] Rate updated: {currency} = {rate}% (from {source})") + + except sqlite3.Error as e: + self.conn.rollback() + raise RuntimeError(f"Failed to upsert rate for {currency}: {e}") + + def get_rate(self, currency: str) -> Optional[float]: + """ + Get the latest interest rate for a currency. + + Args: + currency: Currency code + + Returns: + Rate as float, or None if not found + """ + cursor = self.conn.cursor() + try: + cursor.execute("SELECT rate FROM rates WHERE currency = ?", (currency,)) + row = cursor.fetchone() + return row["rate"] if row else None + except sqlite3.Error as e: + raise RuntimeError(f"Failed to fetch rate for {currency}: {e}") + + def get_all_rates(self) -> Dict[str, Optional[float]]: + """ + Get all interest rates as a dict. + + Returns: + Dict mapping currency code to rate (or None if missing) + """ + cursor = self.conn.cursor() + try: + cursor.execute("SELECT currency, rate FROM rates") + rows = cursor.fetchall() + rates = {row["currency"]: row["rate"] for row in rows} + + # Fill missing currencies with None + for currency in config.CURRENCIES: + if currency not in rates: + rates[currency] = None + + return rates + except sqlite3.Error as e: + raise RuntimeError(f"Failed to fetch all rates: {e}") + + # ======================================================================== + # MONTHLY_DATA Table Operations + # ======================================================================== + + def update_monthly_cpi(self, month: str, currency: str, cpi: float) -> None: + """ + Insert or update CPI data for a currency in a given month. + + Args: + month: Month in format "YYYY-MM" + currency: Currency code + cpi: CPI value as percentage (e.g., 3.2) + """ + if currency not in config.CURRENCIES: + raise ValueError(f"Invalid currency: {currency}") + + cursor = self.conn.cursor() + try: + # First, get existing PMI if any + cursor.execute( + "SELECT pmi_actual FROM monthly_data WHERE month = ? AND currency = ?", + (month, currency) + ) + row = cursor.fetchone() + pmi = row["pmi_actual"] if row else None + + # Upsert with CPI + cursor.execute(""" + INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(month, currency) DO UPDATE SET + cpi_actual = excluded.cpi_actual, + entered_at = excluded.entered_at + """, (month, currency, cpi, pmi, datetime.utcnow().isoformat())) + + self.conn.commit() + + if config.DEBUG: + print(f"[DB] CPI saved: {month} {currency} = {cpi}%") + + except sqlite3.Error as e: + self.conn.rollback() + raise RuntimeError(f"Failed to update CPI for {currency} in {month}: {e}") + + def update_monthly_pmi(self, month: str, currency: str, pmi: float) -> None: + """ + Insert or update PMI data for a currency in a given month. + + Args: + month: Month in format "YYYY-MM" + currency: Currency code + pmi: PMI value (e.g., 51.4) + """ + if currency not in config.CURRENCIES: + raise ValueError(f"Invalid currency: {currency}") + + cursor = self.conn.cursor() + try: + # First, get existing CPI if any + cursor.execute( + "SELECT cpi_actual FROM monthly_data WHERE month = ? AND currency = ?", + (month, currency) + ) + row = cursor.fetchone() + cpi = row["cpi_actual"] if row else None + + # Upsert with PMI + cursor.execute(""" + INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(month, currency) DO UPDATE SET + pmi_actual = excluded.pmi_actual, + entered_at = excluded.entered_at + """, (month, currency, cpi, pmi, datetime.utcnow().isoformat())) + + self.conn.commit() + + if config.DEBUG: + print(f"[DB] PMI saved: {month} {currency} = {pmi}") + + except sqlite3.Error as e: + self.conn.rollback() + raise RuntimeError(f"Failed to update PMI for {currency} in {month}: {e}") + + def get_monthly_data(self, month: str) -> Dict[str, Dict]: + """ + Get all CPI and PMI data for a given month. + + Args: + month: Month in format "YYYY-MM" + + Returns: + Dict mapping currency to {cpi_actual, pmi_actual, entered_at} + """ + cursor = self.conn.cursor() + try: + cursor.execute( + "SELECT currency, cpi_actual, pmi_actual, entered_at FROM monthly_data WHERE month = ?", + (month,) + ) + rows = cursor.fetchall() + + data = {} + for row in rows: + data[row["currency"]] = { + "cpi_actual": row["cpi_actual"], + "pmi_actual": row["pmi_actual"], + "entered_at": row["entered_at"] + } + + return data + except sqlite3.Error as e: + raise RuntimeError(f"Failed to fetch monthly data for {month}: {e}") + + def get_month_completeness(self, month: str) -> Tuple[int, int]: + """ + Check how many of the 16 required fields (8 CPI + 8 PMI) are filled. + + Args: + month: Month in format "YYYY-MM" + + Returns: + Tuple of (filled_count, total_required_16) + """ + cursor = self.conn.cursor() + try: + cursor.execute(""" + SELECT COUNT(CASE WHEN cpi_actual IS NOT NULL THEN 1 END) as cpi_filled, + COUNT(CASE WHEN pmi_actual IS NOT NULL THEN 1 END) as pmi_filled + FROM monthly_data + WHERE month = ? + """, (month,)) + + row = cursor.fetchone() + filled = (row["cpi_filled"] or 0) + (row["pmi_filled"] or 0) + + return (filled, 16) + except sqlite3.Error as e: + raise RuntimeError(f"Failed to check month completeness for {month}: {e}") + + # ======================================================================== + # SCORES Table Operations + # ======================================================================== + + def save_scores(self, month: str, scores: Dict[str, Dict]) -> None: + """ + Save calculated scores for all currencies in a month. + + Args: + month: Month in format "YYYY-MM" + scores: Dict mapping currency to {score_rate, score_cpi, score_pmi, total_score, rank} + """ + cursor = self.conn.cursor() + try: + for currency, score_data in scores.items(): + cursor.execute(""" + INSERT INTO scores (month, currency, score_rate, score_cpi, score_pmi, total_score, rank, calculated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(month, currency) DO UPDATE SET + score_rate = excluded.score_rate, + score_cpi = excluded.score_cpi, + score_pmi = excluded.score_pmi, + total_score = excluded.total_score, + rank = excluded.rank, + calculated_at = excluded.calculated_at + """, ( + month, + currency, + score_data.get("score_rate"), + score_data.get("score_cpi"), + score_data.get("score_pmi"), + score_data["total_score"], + score_data["rank"], + datetime.utcnow().isoformat() + )) + + self.conn.commit() + + if config.DEBUG: + print(f"[DB] {len(scores)} scores saved for {month}") + + except sqlite3.Error as e: + self.conn.rollback() + raise RuntimeError(f"Failed to save scores for {month}: {e}") + + def get_month_scores(self, month: str) -> Dict[str, Dict]: + """ + Get all scores for a given month, ranked by total_score descending. + + Args: + month: Month in format "YYYY-MM" + + Returns: + Dict mapping currency to score data, ordered by rank + """ + cursor = self.conn.cursor() + try: + cursor.execute(""" + SELECT currency, score_rate, score_cpi, score_pmi, total_score, rank + FROM scores + WHERE month = ? + ORDER BY rank ASC + """, (month,)) + + rows = cursor.fetchall() + scores = {} + for row in rows: + scores[row["currency"]] = { + "score_rate": row["score_rate"], + "score_cpi": row["score_cpi"], + "score_pmi": row["score_pmi"], + "total_score": row["total_score"], + "rank": row["rank"] + } + + return scores + except sqlite3.Error as e: + raise RuntimeError(f"Failed to fetch scores for {month}: {e}") + + # ======================================================================== + # SIGNALS Table Operations + # ======================================================================== + + def save_signal(self, month: str, strongest: str, weakest: str, gap: float, + signal: str, status: str) -> None: + """ + Save a trading signal for a month. + + Args: + month: Month in format "YYYY-MM" + strongest: Currency code with highest score + weakest: Currency code with lowest score + gap: Score difference (strongest - weakest) + signal: Signal string (e.g., "SHORT AUD/JPY" or "NO TRADE") + status: "ACTIVE", "NO_TRADE", or "CLOSED" + """ + if status not in ("ACTIVE", "NO_TRADE", "CLOSED"): + raise ValueError(f"Invalid status: {status}") + + cursor = self.conn.cursor() + try: + cursor.execute(""" + INSERT INTO signals (generated_at, month, strongest, weakest, gap, signal, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(month) DO UPDATE SET + generated_at = excluded.generated_at, + strongest = excluded.strongest, + weakest = excluded.weakest, + gap = excluded.gap, + signal = excluded.signal, + status = excluded.status + """, (datetime.utcnow().isoformat(), month, strongest, weakest, gap, signal, status)) + + self.conn.commit() + + if config.DEBUG: + print(f"[DB] Signal saved for {month}: {signal} (status={status})") + + except sqlite3.Error as e: + self.conn.rollback() + raise RuntimeError(f"Failed to save signal for {month}: {e}") + + def get_signal(self, month: str) -> Optional[Dict]: + """ + Get the signal for a given month. + + Args: + month: Month in format "YYYY-MM" + + Returns: + Dict with signal data, or None if not found + """ + cursor = self.conn.cursor() + try: + cursor.execute( + "SELECT strongest, weakest, gap, signal, status FROM signals WHERE month = ?", + (month,) + ) + row = cursor.fetchone() + + if row: + return { + "strongest": row["strongest"], + "weakest": row["weakest"], + "gap": row["gap"], + "signal": row["signal"], + "status": row["status"] + } + return None + + except sqlite3.Error as e: + raise RuntimeError(f"Failed to fetch signal for {month}: {e}") + + def get_all_signals(self, limit: int = 24) -> List[Dict]: + """ + Get most recent signals (for history tab). + + Args: + limit: Maximum number of signals to return (default 24 months) + + Returns: + List of signal dicts, ordered by month descending + """ + cursor = self.conn.cursor() + try: + cursor.execute(""" + SELECT month, strongest, weakest, gap, signal, status + FROM signals + ORDER BY month DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + signals = [] + for row in rows: + signals.append({ + "month": row["month"], + "strongest": row["strongest"], + "weakest": row["weakest"], + "gap": row["gap"], + "signal": row["signal"], + "status": row["status"] + }) + + return signals + except sqlite3.Error as e: + raise RuntimeError(f"Failed to fetch signals: {e}") + + # ======================================================================== + # Utility Methods + # ======================================================================== + + def close(self): + """Close database connection.""" + if self.conn: + self.conn.close() + if config.DEBUG: + print("[DB] Connection closed") + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + +# Singleton instance (optional convenience) +_db_instance = None + +def get_database() -> Database: + """Get or create the global database instance.""" + global _db_instance + if _db_instance is None: + _db_instance = Database() + return _db_instance diff --git a/fred_client.py b/fred_client.py new file mode 100644 index 0000000..2be16d1 --- /dev/null +++ b/fred_client.py @@ -0,0 +1,237 @@ +""" +APEX Layer 1 — FRED API Client + +Fetches interest rates from the Federal Reserve Economic Data (FRED) API. + +API Endpoint: https://api.stlouisfed.org/fred/series/observations + +Features: +- Caches the most recent rate per currency +- Handles API errors gracefully +- Returns None for unavailable series +- Implements exponential backoff for retries +- Non-blocking when called from QThread + +Setup: +1. Go to fred.stlouisfed.org +2. Register for a free account +3. Generate an API key +4. Store in .env as FRED_API_KEY +""" + +import requests +from typing import Dict, Optional +import config +import time + + +class FredClient: + """FRED API client for fetching interest rates.""" + + def __init__(self, api_key: str = None, timeout: int = 10): + """ + Initialize FRED client. + + Args: + api_key: FRED API key. If None, uses config.FRED_API_KEY + timeout: Request timeout in seconds + """ + self.api_key = api_key or config.FRED_API_KEY + self.timeout = timeout + self.base_url = config.FRED_BASE_URL + self.cache = {} # Cache: {currency: {rate, timestamp}} + self.last_error = None + + if not self.api_key: + raise ValueError( + "FRED_API_KEY not configured. " + "Set it in .env or pass as argument." + ) + + def fetch_rate(self, currency: str, max_retries: int = 2) -> Optional[float]: + """ + Fetch the latest interest rate for a single currency. + + Args: + currency: Currency code (USD, EUR, etc.) + max_retries: Number of retry attempts on failure + + Returns: + Interest rate as float (%), or None if fetch fails + """ + if currency not in config.FRED_SERIES: + self.last_error = f"Unknown currency: {currency}" + return None + + series_id = config.FRED_SERIES[currency] + + for attempt in range(max_retries): + try: + rate = self._fetch_series_last_value(series_id) + + # Cache successful fetch + self.cache[currency] = { + 'rate': rate, + 'source': 'FRED', + 'timestamp': time.time() + } + + if config.DEBUG: + print(f"[FRED] {currency}: {rate}% (from series {series_id})") + + return rate + + except requests.Timeout: + self.last_error = f"{currency}: API timeout (attempt {attempt + 1}/{max_retries})" + if config.DEBUG: + print(f"[FRED] {self.last_error}") + time.sleep(0.5 ** attempt) # Exponential backoff + + except requests.ConnectionError: + self.last_error = f"{currency}: Connection error (attempt {attempt + 1}/{max_retries})" + if config.DEBUG: + print(f"[FRED] {self.last_error}") + time.sleep(0.5 ** attempt) + + except ValueError as e: + self.last_error = f"{currency}: {str(e)}" + if config.DEBUG: + print(f"[FRED] {self.last_error}") + break # Don't retry on parsing errors + + except Exception as e: + self.last_error = f"{currency}: Unexpected error: {str(e)}" + if config.DEBUG: + print(f"[FRED] {self.last_error}") + break + + # Return cached value if available + if currency in self.cache: + if config.DEBUG: + print(f"[FRED] {currency}: Using cached value {self.cache[currency]['rate']}%") + return self.cache[currency]['rate'] + + return None + + def fetch_all_rates(self, max_retries: int = 2) -> Dict[str, Optional[float]]: + """ + Fetch interest rates for all 8 currencies. + + Args: + max_retries: Number of retry attempts per currency + + Returns: + Dict mapping currency to rate (float or None) + """ + rates = {} + for currency in config.CURRENCIES: + rates[currency] = self.fetch_rate(currency, max_retries) + + return rates + + def _fetch_series_last_value(self, series_id: str) -> float: + """ + Fetch the last observation of a FRED series. + + Args: + series_id: FRED series ID (e.g., "FEDFUNDS") + + Returns: + Latest numeric value from the series + + Raises: + requests.RequestException: On network error + ValueError: If series not found or parsing fails + """ + url = f"{self.base_url}/series/observations" + + params = { + 'series_id': series_id, + 'api_key': self.api_key, + 'sort_order': 'desc', + 'limit': 1, + 'file_type': 'json' + } + + response = requests.get(url, params=params, timeout=self.timeout) + response.raise_for_status() # Raise exception for bad status codes + + data = response.json() + + # Check for FRED API error + if 'error_code' in data: + raise ValueError( + f"FRED API error ({data['error_code']}): {data.get('error_message', 'Unknown error')}" + ) + + # Extract last observation + observations = data.get('observations', []) + if not observations: + raise ValueError(f"No data available for series {series_id}") + + last_obs = observations[0] + value = last_obs.get('value') + + if value is None or value == '.': + raise ValueError(f"No numeric value in latest observation for {series_id}") + + try: + return float(value) + except (ValueError, TypeError): + raise ValueError(f"Cannot parse value as float: {value}") + + def get_cached_rate(self, currency: str) -> Optional[float]: + """ + Get a cached rate without making a new API call. + + Args: + currency: Currency code + + Returns: + Cached rate, or None if not in cache + """ + return self.cache.get(currency, {}).get('rate') + + def clear_cache(self): + """Clear the rate cache.""" + self.cache.clear() + if config.DEBUG: + print("[FRED] Cache cleared") + + +# Global singleton instance (optional convenience) +_client_instance = None + +def get_fred_client() -> FredClient: + """Get or create the global FRED client instance.""" + global _client_instance + if _client_instance is None: + _client_instance = FredClient() + return _client_instance + + +# Example usage (for testing) +if __name__ == "__main__": + import os + + # For testing, you need a FRED API key + api_key = os.getenv("FRED_API_KEY") + if not api_key: + print("ERROR: FRED_API_KEY not set in environment") + print("Get a key from: https://fred.stlouisfed.org") + exit(1) + + client = FredClient(api_key) + + print("Fetching all rates from FRED...") + rates = client.fetch_all_rates() + + print("\nResults:") + for currency, rate in rates.items(): + if rate is not None: + print(f" {currency}: {rate}%") + else: + print(f" {currency}: ERROR or no data") + + if client.last_error: + print(f"\nLast error: {client.last_error}") diff --git a/main.py b/main.py new file mode 100644 index 0000000..4c77d24 --- /dev/null +++ b/main.py @@ -0,0 +1,93 @@ +""" +APEX Layer 1 — Application Entry Point + +Launches the PyQt5 application. + +Usage: + python main.py + +Requirements: + - Python 3.10+ + - PyQt5 5.15+ + - requests 2.31+ + - pandas 2.0+ (optional, for data) + - python-dotenv 1.0+ + +Installation: + pip install PyQt5 requests python-dotenv + +First run: + 1. Ensure .env file exists with FRED_API_KEY set + 2. Run: python main.py + 3. App initializes database with schema + 4. Auto-fetches rates from FRED if AUTO_FETCH_RATES_ON_STARTUP=true + 5. Ready for manual CPI/PMI entry +""" + +import sys +import traceback +from PyQt5.QtWidgets import QApplication, QMessageBox +from PyQt5.QtCore import Qt +import config +from main_window import MainWindow + + +def main(): + """Application entry point.""" + try: + # Check critical configuration + if not config.FRED_API_KEY: + print("[ERROR] FRED_API_KEY not configured in .env file") + print("Please:") + print(" 1. Go to https://fred.stlouisfed.org") + print(" 2. Register and get a free API key") + print(" 3. Add to .env: FRED_API_KEY=your_key_here") + return 1 + + if config.DEBUG: + print(f"[Main] {config.APP_TITLE}") + print(f"[Main] Debug: ON") + print(f"[Main] Database: {config.DB_PATH}") + + # Create QApplication + app = QApplication(sys.argv) + + # Set application-wide stylesheet (optional) + app.setStyle('Fusion') + + # Create and show main window + window = MainWindow() + window.show() + + # Run application + exit_code = app.exec_() + + if config.DEBUG: + print("[Main] Application closed normally") + + return exit_code + + except Exception as e: + # Show error dialog + print(f"[CRITICAL ERROR] {str(e)}") + traceback.print_exc() + + # Try to show Qt error dialog + try: + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + + QMessageBox.critical( + None, + "Critical Error", + f"Application failed to start:\n\n{str(e)}\n\nCheck the console for details." + ) + except: + pass + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..7b13380 --- /dev/null +++ b/main_window.py @@ -0,0 +1,208 @@ +""" +APEX Layer 1 — Main Application Window + +Assembles all 4 tabs: +- Tab 1: Dashboard (main signal + ranking table) +- Tab 2: Monthly Entry (CPI + PMI input form) +- Tab 3: History (past signals) +- Tab 4: Settings (configuration) + +Responsibilities: +- Create QMainWindow with QTabWidget +- Instantiate all UI tabs +- Manage database connection +- Run FRED API fetch in background thread (QThread) +- Connect inter-tab signals (e.g., entry tab saves → dashboard tab refreshes) +- Handle window events and cleanup +""" + +from PyQt5.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout, QWidget, QMessageBox +from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtGui import QFont +from typing import Dict, Optional +import config +from database import Database +from fred_client import FredClient +from ui.dashboard_tab import DashboardTab +from ui.entry_tab import MonthlyEntryTab +from ui.history_tab import HistoryTab +from ui.settings_tab import SettingsTab + + +class FredFetchWorker(QThread): + """Background worker thread for fetching rates from FRED API.""" + + # Signals + rates_fetched = pyqtSignal(dict) # Emitted with {currency: rate} dict + error_occurred = pyqtSignal(str) # Emitted on error + + def __init__(self, db: Database): + """ + Initialize FRED fetch worker. + + Args: + db: Database instance to save rates + """ + super().__init__() + self.db = db + self.client = FredClient() + + def run(self): + """ + Fetch rates from FRED API and save to database. + """ + try: + if config.DEBUG: + print("[Worker] Starting FRED rate fetch...") + + rates = self.client.fetch_all_rates() + + # Save to database + for currency, rate in rates.items(): + if rate is not None: + self.db.upsert_rate(currency, rate, source="FRED") + + if config.DEBUG: + print("[Worker] FRED fetch complete") + + self.rates_fetched.emit(rates) + + except Exception as e: + error_msg = f"FRED fetch error: {str(e)}" + if config.DEBUG: + print(f"[Worker] {error_msg}") + self.error_occurred.emit(error_msg) + + +class MainWindow(QMainWindow): + """Main application window.""" + + def __init__(self): + """Initialize main window.""" + super().__init__() + + # Initialize database + try: + self.db = Database() + except Exception as e: + QMessageBox.critical( + self, + "Database Error", + f"Failed to initialize database: {e}\n\nPlease check your configuration." + ) + raise + + # UI components + self.dashboard_tab = None + self.entry_tab = None + self.history_tab = None + self.settings_tab = None + + # Worker thread + self.fred_worker = None + + self._init_ui() + self._connect_signals() + self._setup_auto_fetch() + + def _init_ui(self): + """Build the main window UI.""" + self.setWindowTitle(config.APP_TITLE) + self.setGeometry(100, 100, config.WINDOW_WIDTH, config.WINDOW_HEIGHT) + + # Tab widget + tabs = QTabWidget() + + # Tab 1: Dashboard + self.dashboard_tab = DashboardTab(self.db) + tabs.addTab(self.dashboard_tab, config.TAB_NAMES["dashboard"]) + + # Tab 2: Monthly Entry + self.entry_tab = MonthlyEntryTab(self.db) + tabs.addTab(self.entry_tab, config.TAB_NAMES["entry"]) + + # Tab 3: History + self.history_tab = HistoryTab(self.db) + tabs.addTab(self.history_tab, config.TAB_NAMES["history"]) + + # Tab 4: Settings + self.settings_tab = SettingsTab() + tabs.addTab(self.settings_tab, config.TAB_NAMES["settings"]) + + # 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.""" + # Entry tab saves data → Dashboard tab refreshes + self.entry_tab.data_saved.connect(self.dashboard_tab.on_data_saved) + + # Entry tab saves data → History tab refreshes + self.entry_tab.data_saved.connect(self.history_tab.refresh_history) + + # Dashboard requests FRED fetch → Start worker thread + self.dashboard_tab.fetch_rates_requested.connect(self._fetch_rates) + + def _setup_auto_fetch(self): + """Auto-fetch rates on startup if enabled.""" + if config.AUTO_FETCH_RATES_ON_STARTUP: + if config.DEBUG: + print("[Main] Auto-fetch enabled, fetching rates on startup...") + self._fetch_rates() + + def _fetch_rates(self): + """ + Trigger background FRED rate fetch. + Emits results to dashboard when complete. + """ + if self.fred_worker is not None and self.fred_worker.isRunning(): + # Already fetching + return + + self.fred_worker = FredFetchWorker(self.db) + self.fred_worker.rates_fetched.connect(self._on_rates_fetched) + self.fred_worker.error_occurred.connect(self._on_fetch_error) + self.fred_worker.start() + + def _on_rates_fetched(self, rates: Dict[str, Optional[float]]): + """ + Handle successful FRED fetch. + + Args: + rates: Dict mapping currency to rate + """ + if config.DEBUG: + print("[Main] Rates fetched successfully, updating dashboard...") + + # Update dashboard display + self.dashboard_tab.on_rates_updated(rates) + + def _on_fetch_error(self, error_msg: str): + """ + Handle FRED fetch error. + + Args: + error_msg: Error message string + """ + print(f"[ERROR] {error_msg}") + # Don't show error message to user; display gracefully in dashboard + + def closeEvent(self, event): + """Handle window close event.""" + try: + # Stop any running threads + if self.fred_worker is not None and self.fred_worker.isRunning(): + self.fred_worker.quit() + self.fred_worker.wait() + + # Close database + self.db.close() + + event.accept() + except Exception as e: + print(f"[ERROR] Error during shutdown: {e}") + event.accept() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..16dc95f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyQt5==5.15.9 +requests==2.31.0 +python-dotenv==1.0.0 +pandas==2.1.3 +openpyxl==3.1.2 +pyinstaller==6.1.0 diff --git a/scorer.py b/scorer.py new file mode 100644 index 0000000..08455ae --- /dev/null +++ b/scorer.py @@ -0,0 +1,354 @@ +""" +APEX Layer 1 — Scoring Engine + +Implements the exact scoring formula from the README: +1. Collect raw values (rates, CPI deviations, PMI) +2. Calculate derived values +3. Normalize each input to 0-100 using min-max scaling +4. Apply weights (Rate 50%, CPI 30%, PMI 20%) +5. Sum to get final score +6. Rank currencies and pair strongest vs weakest +7. Validate gap >= MIN_GAP to trade + +All scores are 0-100. Gap must be >= 20 to generate a trade signal. +""" + +from typing import Dict, List, Tuple, Optional +import config + + +def normalise(values: List[float]) -> List[float]: + """ + Normalize a list of values to 0-100 using min-max scaling. + + If all values are equal (no variation), return [50.0] * len(values) + to represent perfect neutrality. + + Args: + values: List of numeric values + + Returns: + List of normalized values (0.0 to 100.0) + """ + if not values: + return [] + + min_v = min(values) + max_v = max(values) + + # Handle edge case: all values identical + if max_v == min_v: + return [50.0] * len(values) + + # Min-max scaling to [0, 100] + return [(v - min_v) / (max_v - min_v) * 100.0 for v in values] + + +def calculate_rate_differentials(rates: Dict[str, Optional[float]]) -> Dict[str, Optional[float]]: + """ + Calculate interest rate differential for each currency vs G8 average. + + rate_diff[i] = rate[i] - mean(rate) + + Args: + rates: Dict mapping currency to interest rate (% or None) + + Returns: + Dict mapping currency to rate differential + """ + # Filter out None values for average calculation + valid_rates = [r for r in rates.values() if r is not None] + + if not valid_rates: + # All rates missing — return zeros + return {currency: 0.0 for currency in config.CURRENCIES} + + avg_rate = sum(valid_rates) / len(valid_rates) + + # Calculate differentials (None becomes 0.0) + differentials = {} + for currency in config.CURRENCIES: + rate = rates.get(currency) + differentials[currency] = (rate - avg_rate) if rate is not None else 0.0 + + return differentials + + +def calculate_cpi_deviations(cpi_values: Dict[str, Optional[float]]) -> Dict[str, Optional[float]]: + """ + Calculate CPI deviation for each currency vs its CB target. + + cpi_dev[i] = actual_cpi[i] - target[i] + + - Positive deviation (above target) → hawkish pressure (stronger score) + - Negative deviation (below target) → dovish pressure (weaker score) + + Args: + cpi_values: Dict mapping currency to actual CPI % (or None) + + Returns: + Dict mapping currency to CPI deviation + """ + deviations = {} + for currency in config.CURRENCIES: + cpi = cpi_values.get(currency) + target = config.CB_TARGETS.get(currency, 2.0) + + # None becomes 0.0 deviation (neutral) + deviations[currency] = (cpi - target) if cpi is not None else 0.0 + + return deviations + + +def score_all_currencies( + rates: Dict[str, Optional[float]], + cpi_values: Dict[str, Optional[float]], + pmi_values: Dict[str, Optional[float]] +) -> Dict[str, Dict]: + """ + Calculate the complete score for all 8 currencies. + + Steps: + 1. Calculate rate differentials + 2. Calculate CPI deviations + 3. Normalize each input to 0-100 + 4. Apply weights + 5. Sum to get total score + 6. Rank by score + + Args: + rates: Dict currency -> interest rate % (or None) + cpi_values: Dict currency -> actual CPI % (or None) + pmi_values: Dict currency -> PMI reading (or None) + + Returns: + Dict mapping currency to: + { + 'score_rate': float (0-100), + 'score_cpi': float (0-100), + 'score_pmi': float (0-100), + 'total_score': float (0-100), + 'rank': int (1-8) + } + """ + # Step 1 & 2: Calculate derived values + rate_diffs = calculate_rate_differentials(rates) + cpi_devs = calculate_cpi_deviations(cpi_values) + pmi_raws = pmi_values.copy() # Use PMI values as-is + + # Extract numeric lists for normalization (skip None values) + rate_diff_list = [rate_diffs[c] for c in config.CURRENCIES] + cpi_dev_list = [cpi_devs[c] for c in config.CURRENCIES] + + # For PMI, treat None as 50 (neutral) for normalization purposes + pmi_list = [pmi_raws.get(c) if pmi_raws.get(c) is not None else 50.0 for c in config.CURRENCIES] + + # Step 3: Normalize each input to 0-100 + norm_rate = normalise(rate_diff_list) + norm_cpi = normalise(cpi_dev_list) + norm_pmi = normalise(pmi_list) + + # Step 4 & 5: Apply weights and calculate total scores + scores_raw = {} + for i, currency in enumerate(config.CURRENCIES): + total = ( + norm_rate[i] * config.WEIGHT_RATE + + norm_cpi[i] * config.WEIGHT_CPI + + norm_pmi[i] * config.WEIGHT_PMI + ) + + scores_raw[currency] = { + 'score_rate': norm_rate[i], + 'score_cpi': norm_cpi[i], + 'score_pmi': norm_pmi[i], + 'total_score': total + } + + # Step 6: Rank by total score (descending) + sorted_currencies = sorted( + scores_raw.items(), + key=lambda x: x[1]['total_score'], + reverse=True + ) + + # Add rank to each score + final_scores = {} + for rank, (currency, score_data) in enumerate(sorted_currencies, start=1): + score_data['rank'] = rank + final_scores[currency] = score_data + + return final_scores + + +def get_ranked_list(scores: Dict[str, Dict]) -> List[Tuple[str, float, int]]: + """ + Get currencies sorted by score (highest first). + + Args: + scores: Dict from score_all_currencies() + + Returns: + List of (currency, total_score, rank) tuples + """ + return sorted( + [(c, s['total_score'], s['rank']) for c, s in scores.items()], + key=lambda x: x[1], + reverse=True + ) + + +def pair_currencies(scores: Dict[str, Dict]) -> Tuple[str, str, float]: + """ + Get the strongest and weakest currencies (for pairing). + + Returns: + Tuple of (strongest_currency, weakest_currency, gap) + """ + ranked = get_ranked_list(scores) + + if not ranked or len(ranked) < 2: + raise ValueError("Cannot pair: insufficient scored currencies") + + strongest_currency, strongest_score, _ = ranked[0] + weakest_currency, weakest_score, _ = ranked[-1] + gap = strongest_score - weakest_score + + return strongest_currency, weakest_currency, gap + + +def generate_signal(scores: Dict[str, Dict]) -> Tuple[str, str, str]: + """ + Generate the primary trade signal. + + Returns: + Tuple of (signal_text, status, gap_description) + + signal_text: "SHORT {weakest}/{strongest}" or "NO TRADE" + status: "ACTIVE" or "NO_TRADE" + gap_description: e.g. "Gap: 74 points · Strong signal" + """ + strongest, weakest, gap = pair_currencies(scores) + + if gap >= config.MIN_GAP_TO_TRADE: + signal_text = f"SHORT {weakest}/{strongest}" + status = "ACTIVE" + + # Classify gap tier + if gap >= config.GAP_THRESHOLDS["strong"]: + tier = "Strong signal" + elif gap >= config.GAP_THRESHOLDS["standard"]: + tier = "Standard signal" + elif gap >= config.GAP_THRESHOLDS["weak"]: + tier = "Weak signal" + else: + tier = "Marginal signal" + + gap_desc = f"Gap: {gap:.1f} points · {tier}" + else: + signal_text = "NO TRADE" + status = "NO_TRADE" + gap_desc = f"Gap: {gap:.1f} points · Too narrow (< {config.MIN_GAP_TO_TRADE})" + + return signal_text, status, gap_desc + + +def get_gap_tier(gap: float) -> str: + """ + Classify a gap size into trading tiers. + + Returns: + One of: "no_trade", "weak", "standard", "strong" + """ + if gap < config.GAP_THRESHOLDS["weak"]: + return "no_trade" + elif gap < config.GAP_THRESHOLDS["standard"]: + return "weak" + elif gap < config.GAP_THRESHOLDS["strong"]: + return "standard" + else: + return "strong" + + +def validate_scores(scores: Dict[str, Dict]) -> bool: + """ + Validate that scores dict has all required fields. + + Args: + scores: Dict from score_all_currencies() + + Returns: + True if valid, raises ValueError if invalid + """ + required_fields = {'score_rate', 'score_cpi', 'score_pmi', 'total_score', 'rank'} + + for currency in config.CURRENCIES: + if currency not in scores: + raise ValueError(f"Missing scores for {currency}") + + score_data = scores[currency] + missing = required_fields - set(score_data.keys()) + + if missing: + raise ValueError( + f"Missing fields for {currency}: {missing}" + ) + + # Check value ranges + for field in ['score_rate', 'score_cpi', 'score_pmi', 'total_score']: + value = score_data[field] + if not (0 <= value <= 100): + raise ValueError( + f"{currency}.{field} out of range [0-100]: {value}" + ) + + 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/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..968dae7 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,5 @@ +""" +APEX Layer 1 — User Interface Modules + +This package contains all PyQt5 UI tabs and components. +""" diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py new file mode 100644 index 0000000..5bc864c --- /dev/null +++ b/ui/dashboard_tab.py @@ -0,0 +1,307 @@ +""" +APEX Layer 1 — Tab 1: Dashboard + +This is the main screen the user sees every day. + +Features: +- Signal card at top (shows PRIMARY SIGNAL, gap, status, updated date) +- Ranked score table below with all 8 currencies +- Strongest row highlighted GREEN (BUY) +- Weakest row highlighted RED (SELL) +- Score bar charts per row (visual progress) +- Auto-refresh when data updated from Entry tab or FRED API + +Display: +- Rank, Currency, Rate, CPI, PMI, Score columns +- Color-coded rows, "BUY" and "SELL" tags +- Last updated timestamp +""" + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, + QFrame, QPushButton, QSpinBox +) +from PyQt5.QtCore import Qt, pyqtSignal, QSize +from PyQt5.QtGui import QColor, QFont, QBrush, QPixmap +from typing import Dict, Optional +from datetime import datetime +import config +from database import Database +import scorer + + +class DashboardTab(QWidget): + """Main dashboard showing current signal and currency rankings.""" + + # Signal to request FRED fetch + fetch_rates_requested = pyqtSignal() + + def __init__(self, db: Database): + """ + Initialize Dashboard tab. + + Args: + db: Database instance + """ + super().__init__() + self.db = db + self.current_month = datetime.now().strftime("%Y-%m") + + self._init_ui() + self._refresh_display() + + def _init_ui(self): + """Build the UI layout.""" + layout = QVBoxLayout() + + # ====== Signal Card ====== + signal_card = self._build_signal_card() + layout.addWidget(signal_card) + layout.addSpacing(15) + + # ====== Ranked Score Table ====== + layout.addWidget(QLabel("Currency Rankings")) + + self.score_table = QTableWidget() + self.score_table.setColumnCount(8) + self.score_table.setHorizontalHeaderLabels([ + "Rank", "Currency", "Rate (%)", "CPI (%)", "PMI", "Score", "Signal", "Strength" + ]) + self.score_table.setRowCount(len(config.CURRENCIES)) + self.score_table.setAlternatingRowColors(True) + self.score_table.setSelectionBehavior(QTableWidget.SelectRows) + self.score_table.setSelectionMode(QTableWidget.SingleSelection) + + # 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) + + self.score_table.resizeColumnsToContents() + layout.addWidget(self.score_table) + layout.addSpacing(15) + + # ====== Refresh Button ====== + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.clicked.connect(self._refresh_display) + button_layout.addWidget(self.refresh_btn) + + fetch_btn = QPushButton("Fetch Rates (FRED)") + fetch_btn.clicked.connect(self._on_fetch_rates) + button_layout.addWidget(fetch_btn) + + layout.addLayout(button_layout) + layout.addStretch() + + self.setLayout(layout) + + 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; + } + """) + + layout = QVBoxLayout() + + # Title + title = QLabel("PRIMARY SIGNAL") + title.setFont(QFont("Arial", 10, QFont.Bold)) + title.setStyleSheet("color: #495057;") + 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.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)) + 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;") + layout.addWidget(self.updated_label) + + card.setLayout(layout) + return card + + def _refresh_display(self): + """Refresh dashboard with latest data.""" + try: + # Get signal for current month + signal_data = self.db.get_signal(self.current_month) + + if signal_data: + signal_text = signal_data["signal"] + gap = signal_data["gap"] + status = signal_data["status"] + + # Update signal label + self.signal_label.setText(signal_text) + + # Color code based on status + if status == "ACTIVE": + self.signal_label.setStyleSheet("color: #27ae60;") # Green + else: + self.signal_label.setStyleSheet("color: #e74c3c;") # Red + + # Update gap label + gap_tier = scorer.get_gap_tier(gap) + tier_name = { + "no_trade": "Too narrow", + "weak": "Weak signal", + "standard": "Standard signal", + "strong": "Strong signal" + }.get(gap_tier, "Unknown") + + self.gap_label.setText(f"Gap: {gap:.1f} points · {tier_name}") + else: + self.signal_label.setText("NO TRADE — No data yet") + self.signal_label.setStyleSheet("color: #e74c3c;") + self.gap_label.setText("Gap: — points") + + # Update timestamp + self.updated_label.setText(f"Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # Refresh score table + self._refresh_score_table() + + except Exception as e: + print(f"[ERROR] Failed to refresh dashboard: {e}") + self.signal_label.setText("ERROR") + self.signal_label.setStyleSheet("color: #e74c3c;") + + 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("—") + 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): + rank = score_data['rank'] + total_score = score_data['total_score'] + rate = rates.get(currency) + cpi_data = monthly_data.get(currency, {}) + 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, 5).setText(f"{total_score:.1f}") + + # Signal tag (BUY for strongest, SELL for weakest) + if rank == 1: + self.score_table.item(row, 6).setText("BUY") + elif rank == len(config.CURRENCIES): + self.score_table.item(row, 6).setText("SELL") + 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)}%") + + # 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)) + + 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() + + except Exception as e: + print(f"[ERROR] Failed to refresh score table: {e}") + + def _on_fetch_rates(self): + """Handle fetch rates button click.""" + self.fetch_rates_requested.emit() + + def on_data_saved(self, month: str): + """ + Called when entry tab saves new data. + + Args: + month: Month string (YYYY-MM) + """ + self.current_month = month + self._refresh_display() + + def on_rates_updated(self, rates: Dict[str, float]): + """ + Called when FRED rates fetched successfully. + + Args: + rates: Dict mapping currency to rate + """ + # Rates are saved to DB by the thread, just refresh display + self._refresh_display() diff --git a/ui/entry_tab.py b/ui/entry_tab.py new file mode 100644 index 0000000..8a92207 --- /dev/null +++ b/ui/entry_tab.py @@ -0,0 +1,593 @@ +""" +APEX Layer 1 — Tab 2: Monthly Data Entry + +This tab allows users to manually enter CPI and PMI data for all 8 currencies +for the current month. + +Features: +- Two tables: CPI entry and PMI entry +- Live delta calculation (actual CPI - target) +- Progress bar tracking (X of 16 fields filled) +- Save button disabled until all 16 fields complete +- Month selector dropdown +- Color coding: green for above target, red for below (CPI only) + +User flow: +1. Select current month from dropdown +2. Enter 8 CPI values from official releases +3. Enter 8 PMI values from S&P Global +4. Progress bar shows 16/16 when complete +5. Click "Save & Calculate Scores" +6. Triggers scorer.py → updates Dashboard tab +""" + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, + QPushButton, QProgressBar, QComboBox, QSpinBox, QDoubleSpinBox, QHeaderView, + QFileDialog, QMessageBox +) +from PyQt5.QtCore import Qt, pyqtSignal, QDate +from PyQt5.QtGui import QColor, QFont, QBrush +from typing import Dict, Optional +from datetime import datetime, timedelta +import config +from database import Database +import scorer +import pandas as pd +import openpyxl + + +class MonthlyEntryTab(QWidget): + """Monthly CPI + PMI data entry form.""" + + # Signal emitted when data saved successfully + data_saved = pyqtSignal(str) # month string + + def __init__(self, db: Database): + """ + Initialize Monthly Entry tab. + + Args: + db: Database instance + """ + super().__init__() + self.db = db + self.current_month = None + self.cpi_fields = {} # currency -> QDoubleSpinBox + self.pmi_fields = {} # currency -> QDoubleSpinBox + self.delta_labels = {} # currency -> QLabel + self.pmi_signal_labels = {} # currency -> QLabel + + self._init_ui() + self._connect_signals() + self._load_current_month() + + def _init_ui(self): + """Build the UI layout.""" + layout = QVBoxLayout() + + # ====== Month selector ====== + month_layout = QHBoxLayout() + month_layout.addWidget(QLabel("Month:")) + + self.month_combo = QComboBox() + self._populate_month_combo() + month_layout.addWidget(self.month_combo) + month_layout.addStretch() + + layout.addLayout(month_layout) + layout.addSpacing(10) + + # ====== CPI Entry Table ====== + layout.addWidget(QLabel("CPI Entry (Actual YoY % - Enter after each country releases)")) + + self.cpi_table = QTableWidget() + self.cpi_table.setColumnCount(5) + self.cpi_table.setHorizontalHeaderLabels( + ["Currency", "Target %", "Actual CPI %", "Delta", "Done"] + ) + self.cpi_table.setRowCount(len(config.CURRENCIES)) + + for row, currency in enumerate(config.CURRENCIES): + # Currency label + currency_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS[currency]} {currency}") + currency_item.setFlags(currency_item.flags() & ~Qt.ItemIsEditable) + self.cpi_table.setItem(row, 0, currency_item) + + # Target + target = config.CB_TARGETS[currency] + target_item = QTableWidgetItem(f"{target}%") + target_item.setFlags(target_item.flags() & ~Qt.ItemIsEditable) + self.cpi_table.setItem(row, 1, target_item) + + # Actual CPI input + spin = QDoubleSpinBox() + spin.setRange(config.CPI_MIN, config.CPI_MAX) + spin.setDecimals(2) + spin.setValue(0.0) + spin.setStyleSheet("background-color: white; padding: 2px;") + self.cpi_fields[currency] = spin + self.cpi_table.setCellWidget(row, 2, spin) + + # Delta label + delta_label = QLabel("—") + delta_label.setAlignment(Qt.AlignCenter) + self.delta_labels[currency] = delta_label + self.cpi_table.setItem(row, 3, QTableWidgetItem("")) + self.cpi_table.setCellWidget(row, 3, delta_label) + + # Done indicator + done_item = QTableWidgetItem("○") + done_item.setTextAlignment(Qt.AlignCenter) + done_item.setFlags(done_item.flags() & ~Qt.ItemIsEditable) + self.cpi_table.setItem(row, 4, done_item) + + # Auto-resize columns + self.cpi_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + layout.addWidget(self.cpi_table) + layout.addSpacing(10) + + # ====== PMI Entry Table ====== + layout.addWidget(QLabel("PMI Entry (Composite PMI - Enter after S&P Global release)")) + + self.pmi_table = QTableWidget() + self.pmi_table.setColumnCount(5) + self.pmi_table.setHorizontalHeaderLabels( + ["Currency", "Neutral", "PMI Reading", "Signal", "Done"] + ) + self.pmi_table.setRowCount(len(config.CURRENCIES)) + + for row, currency in enumerate(config.CURRENCIES): + # Currency label + currency_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS[currency]} {currency}") + currency_item.setFlags(currency_item.flags() & ~Qt.ItemIsEditable) + self.pmi_table.setItem(row, 0, currency_item) + + # Neutral reference + neutral_item = QTableWidgetItem("50.0") + neutral_item.setFlags(neutral_item.flags() & ~Qt.ItemIsEditable) + self.pmi_table.setItem(row, 1, neutral_item) + + # PMI input + spin = QDoubleSpinBox() + spin.setRange(config.PMI_MIN, config.PMI_MAX) + spin.setDecimals(1) + spin.setValue(50.0) # Default to neutral + spin.setStyleSheet("background-color: white; padding: 2px;") + self.pmi_fields[currency] = spin + self.pmi_table.setCellWidget(row, 2, spin) + + # Signal label + signal_label = QLabel("Neutral") + signal_label.setAlignment(Qt.AlignCenter) + self.pmi_signal_labels[currency] = signal_label + self.pmi_table.setItem(row, 3, QTableWidgetItem("")) + self.pmi_table.setCellWidget(row, 3, signal_label) + + # Done indicator + done_item = QTableWidgetItem("○") + done_item.setTextAlignment(Qt.AlignCenter) + done_item.setFlags(done_item.flags() & ~Qt.ItemIsEditable) + self.pmi_table.setItem(row, 4, done_item) + + self.pmi_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + layout.addWidget(self.pmi_table) + layout.addSpacing(15) + + # ====== Progress Bar ====== + progress_layout = QHBoxLayout() + progress_layout.addWidget(QLabel("Data entry progress:")) + + self.progress_bar = QProgressBar() + self.progress_bar.setMaximum(16) + self.progress_bar.setValue(0) + self.progress_bar.setFormat("%v / 16 fields filled") + progress_layout.addWidget(self.progress_bar) + + layout.addLayout(progress_layout) + layout.addSpacing(10) + + # ====== 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; + } + """) + button_layout.addWidget(self.import_btn) + + button_layout.addStretch() + + self.save_btn = QPushButton("Save & Calculate Scores") + 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; + } + """) + button_layout.addWidget(self.save_btn) + + layout.addLayout(button_layout) + layout.addStretch() + + self.setLayout(layout) + + def _connect_signals(self): + """Connect UI signals to slots.""" + # Month selector + self.month_combo.currentTextChanged.connect(self._on_month_changed) + + # CPI field changes + for currency, spin in self.cpi_fields.items(): + spin.valueChanged.connect(self._on_cpi_changed) + + # PMI field changes + for currency, spin in self.pmi_fields.items(): + spin.valueChanged.connect(self._on_pmi_changed) + + # Import button + self.import_btn.clicked.connect(self._on_import_excel) + + # Save button + self.save_btn.clicked.connect(self._on_save_clicked) + + def _populate_month_combo(self): + """Populate month dropdown with past 24 months + current month.""" + months = [] + today = datetime.now() + + # Add current month and past 23 months + for i in range(24): + month_date = today - timedelta(days=30 * i) + month_str = month_date.strftime("%Y-%m") + months.append(month_str) + + self.month_combo.addItems(months) + + def _load_current_month(self): + """Load current month data from database.""" + self.current_month = datetime.now().strftime("%Y-%m") + + # Set combo to current month + current_index = self.month_combo.findText(self.current_month) + if current_index >= 0: + self.month_combo.setCurrentIndex(current_index) + + self._load_month_data(self.current_month) + + def _on_month_changed(self, month_str: str): + """Handle month selection change.""" + self.current_month = month_str + self._load_month_data(month_str) + + def _load_month_data(self, month: str): + """Load saved CPI/PMI data from database for a month.""" + try: + monthly_data = self.db.get_monthly_data(month) + + # Clear fields + for spin in self.cpi_fields.values(): + spin.blockSignals(True) + spin.setValue(0.0) + spin.blockSignals(False) + + for spin in self.pmi_fields.values(): + spin.blockSignals(True) + spin.setValue(50.0) + spin.blockSignals(False) + + # Load saved values + for currency, data in monthly_data.items(): + if data["cpi_actual"] is not None: + self.cpi_fields[currency].blockSignals(True) + self.cpi_fields[currency].setValue(data["cpi_actual"]) + self.cpi_fields[currency].blockSignals(False) + + if data["pmi_actual"] is not None: + self.pmi_fields[currency].blockSignals(True) + self.pmi_fields[currency].setValue(data["pmi_actual"]) + self.pmi_fields[currency].blockSignals(False) + + # Refresh UI + self._update_delta_labels() + self._update_pmi_signals() + self._update_progress() + + except Exception as e: + print(f"[ERROR] Failed to load month data: {e}") + + def _on_cpi_changed(self): + """Handle CPI value change.""" + self._update_delta_labels() + self._update_progress() + + def _update_delta_labels(self): + """Update delta (CPI - target) labels with color coding.""" + for currency, spin in self.cpi_fields.items(): + cpi = spin.value() + target = config.CB_TARGETS[currency] + delta = cpi - target + + label = self.delta_labels[currency] + + if cpi == 0: + # Not filled + label.setText("—") + label.setStyleSheet("") + else: + # Show delta with sign + delta_str = f"{delta:+.2f}%" + label.setText(delta_str) + + # Color code + if delta > 0: + label.setStyleSheet("color: #27ae60; font-weight: bold;") # Green (hawkish) + elif delta < 0: + label.setStyleSheet("color: #e74c3c; font-weight: bold;") # Red (dovish) + else: + label.setStyleSheet("color: #95a5a6;") # Gray (neutral) + + def _on_pmi_changed(self): + """Handle PMI value change.""" + self._update_pmi_signals() + self._update_progress() + + def _update_pmi_signals(self): + """Update PMI signal labels based on value.""" + for currency, spin in self.pmi_fields.items(): + pmi = spin.value() + label = self.pmi_signal_labels[currency] + + if pmi > 52: + label.setText("Expanding") + label.setStyleSheet("color: #27ae60; font-weight: bold;") + elif pmi >= 50: + label.setText("Neutral +") + label.setStyleSheet("color: #f39c12; font-weight: bold;") + elif pmi > 48: + label.setText("Neutral −") + label.setStyleSheet("color: #f39c12; font-weight: bold;") + else: + label.setText("Contracting") + label.setStyleSheet("color: #e74c3c; font-weight: bold;") + + def _update_progress(self): + """Update progress bar and save button state.""" + filled = 0 + + # Count filled CPI fields + for currency, spin in self.cpi_fields.items(): + if spin.value() != 0: + filled += 1 + # Update done indicator + row = config.CURRENCIES.index(currency) + self.cpi_table.item(row, 4).setText("✓") + else: + row = config.CURRENCIES.index(currency) + self.cpi_table.item(row, 4).setText("○") + + # Count filled PMI fields + for currency, spin in self.pmi_fields.items(): + if spin.value() != 50.0: # PMI default is 50 (neutral) + filled += 1 + # Update done indicator + row = config.CURRENCIES.index(currency) + self.pmi_table.item(row, 4).setText("✓") + else: + row = config.CURRENCIES.index(currency) + self.pmi_table.item(row, 4).setText("○") + + self.progress_bar.setValue(filled) + + # Enable save button only if all 16 fields filled + self.save_btn.setEnabled(filled == 16) + + def _on_import_excel(self): + """Handle Import Excel button click.""" + # Open file dialog + file_path, _ = QFileDialog.getOpenFileName( + self, + "Import Monthly Data from Excel", + "", + "Excel Files (*.xlsx *.xls);;CSV Files (*.csv);;All Files (*)" + ) + + if not file_path: + return # User cancelled + + try: + self._load_excel_data(file_path) + QMessageBox.information( + self, + "Success", + "✓ Data imported successfully!\n\nClick 'Save & Calculate Scores' to process." + ) + except Exception as e: + QMessageBox.critical( + self, + "Import Error", + f"Failed to import Excel file:\n\n{str(e)}\n\n" + + "Please check the file format. See EXCEL_IMPORT_PROMPT.md for details." + ) + + def _load_excel_data(self, file_path: str): + """ + Load CPI and PMI data from Excel file. + + Expected structure: + - Sheet 'CPI': Columns [Currency, Target %, Actual CPI %] + - Sheet 'PMI': Columns [Currency, Composite PMI] + + Or single sheet with structure: + - Columns [Currency, Target_CPI, Actual_CPI, Composite_PMI] + + Args: + file_path: Path to Excel or CSV file + """ + if file_path.endswith('.csv'): + # Load from CSV + df = pd.read_csv(file_path) + self._parse_csv_data(df) + else: + # Load from Excel (try multi-sheet format first, then single-sheet) + try: + self._load_excel_multi_sheet(file_path) + except: + self._load_excel_single_sheet(file_path) + + def _load_excel_multi_sheet(self, file_path: str): + """Load Excel with separate CPI and PMI sheets.""" + # Load CPI sheet + cpi_df = pd.read_excel(file_path, sheet_name='CPI') + pmi_df = pd.read_excel(file_path, sheet_name='PMI') + + # Map CPI data + for _, row in cpi_df.iterrows(): + currency = str(row.iloc[0]).strip().upper() + if currency in config.CURRENCIES: + actual_cpi = float(row.iloc[2]) + if actual_cpi != 0: + self.cpi_fields[currency].blockSignals(True) + self.cpi_fields[currency].setValue(actual_cpi) + self.cpi_fields[currency].blockSignals(False) + + # Map PMI data + for _, row in pmi_df.iterrows(): + currency = str(row.iloc[0]).strip().upper() + if currency in config.CURRENCIES: + pmi_value = float(row.iloc[1]) + if pmi_value != 0: + self.pmi_fields[currency].blockSignals(True) + self.pmi_fields[currency].setValue(pmi_value) + self.pmi_fields[currency].blockSignals(False) + + # Refresh UI + self._update_delta_labels() + self._update_pmi_signals() + self._update_progress() + + def _load_excel_single_sheet(self, file_path: str): + """Load Excel with single sheet containing all data.""" + df = pd.read_excel(file_path) + self._parse_csv_data(df) + + def _parse_csv_data(self, df): + """Parse DataFrame and populate tables.""" + # Try to detect column names (case-insensitive) + columns = [str(col).lower().strip() for col in df.columns] + + # Map CPI and PMI from dataframe + for _, row in df.iterrows(): + # Get currency (assume first column or named column) + currency = str(row.iloc[0]).strip().upper() + if not currency or currency not in config.CURRENCIES: + continue + + # Try to find CPI column + cpi_cols = [i for i, c in enumerate(columns) if 'cpi' in c and 'actual' in c] + if cpi_cols: + try: + actual_cpi = float(row.iloc[cpi_cols[0]]) + if actual_cpi != 0: + self.cpi_fields[currency].blockSignals(True) + self.cpi_fields[currency].setValue(actual_cpi) + self.cpi_fields[currency].blockSignals(False) + except (ValueError, IndexError): + pass + + # Try to find PMI column + pmi_cols = [i for i, c in enumerate(columns) if 'pmi' in c] + if pmi_cols: + try: + pmi_value = float(row.iloc[pmi_cols[0]]) + if pmi_value != 0: + self.pmi_fields[currency].blockSignals(True) + self.pmi_fields[currency].setValue(pmi_value) + self.pmi_fields[currency].blockSignals(False) + except (ValueError, IndexError): + pass + + # Refresh UI + self._update_delta_labels() + self._update_pmi_signals() + self._update_progress() + + def _on_save_clicked(self): + """Handle Save & Calculate Scores button click.""" + try: + # Collect CPI values + cpi_values = { + currency: self.cpi_fields[currency].value() + for currency in config.CURRENCIES + } + + # Collect PMI values + pmi_values = { + currency: self.pmi_fields[currency].value() + for currency in config.CURRENCIES + } + + # Save to database + for currency in config.CURRENCIES: + self.db.update_monthly_cpi(self.current_month, currency, cpi_values[currency]) + self.db.update_monthly_pmi(self.current_month, currency, pmi_values[currency]) + + # Fetch rates from database + rates = self.db.get_all_rates() + + # Score all currencies + scores = scorer.score_all_currencies(rates, cpi_values, pmi_values) + + # Save scores to database + self.db.save_scores(self.current_month, scores) + + # Generate signal + strongest, weakest, gap = scorer.pair_currencies(scores) + signal_text, status, gap_desc = scorer.generate_signal(scores) + + # Save signal + self.db.save_signal( + self.current_month, + strongest, + weakest, + gap, + signal_text, + status + ) + + # Emit signal so Dashboard tab can refresh + self.data_saved.emit(self.current_month) + + # Show confirmation + print(f"[Entry] Data saved and scores calculated for {self.current_month}") + + except Exception as e: + print(f"[ERROR] Failed to save data: {e}") diff --git a/ui/history_tab.py b/ui/history_tab.py new file mode 100644 index 0000000..6ed6519 --- /dev/null +++ b/ui/history_tab.py @@ -0,0 +1,182 @@ +""" +APEX Layer 1 — Tab 3: History + +Displays past trading signals and monthly scores. + +Features: +- Table with past months (newest first) +- Columns: Month, Signal, Gap, Strongest, Weakest, Status +- Click any row to expand and see full score breakdown for all 8 currencies +- Sort by month/gap/status +""" + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, 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 + + +class HistoryTab(QWidget): + """History tab showing past signals and scores.""" + + def __init__(self, db: Database): + """ + Initialize History tab. + + Args: + db: Database instance + """ + super().__init__() + self.db = db + + self._init_ui() + self._load_history() + + def _init_ui(self): + """Build the UI layout.""" + layout = QVBoxLayout() + + # History table + self.history_table = QTableWidget() + self.history_table.setColumnCount(6) + self.history_table.setHorizontalHeaderLabels([ + "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) + + layout.addWidget(self.history_table) + self.setLayout(layout) + + def _load_history(self): + """Load signal history from database.""" + try: + signals = self.db.get_all_signals(limit=24) + + if not signals: + self.history_table.setRowCount(0) + return + + self.history_table.setRowCount(len(signals)) + + for row, signal_data in enumerate(signals): + month = signal_data["month"] + signal_text = signal_data["signal"] + gap = signal_data["gap"] + strongest = signal_data["strongest"] + weakest = signal_data["weakest"] + status = signal_data["status"] + + # Month + month_item = QTableWidgetItem(month) + month_item.setFlags(month_item.flags() & ~Qt.ItemIsEditable) + self.history_table.setItem(row, 0, month_item) + + # Signal + signal_item = QTableWidgetItem(signal_text) + signal_item.setFlags(signal_item.flags() & ~Qt.ItemIsEditable) + self.history_table.setItem(row, 1, signal_item) + + # Gap + gap_item = QTableWidgetItem(f"{gap:.1f}") + gap_item.setFlags(gap_item.flags() & ~Qt.ItemIsEditable) + gap_item.setTextAlignment(Qt.AlignCenter) + self.history_table.setItem(row, 2, gap_item) + + # Strongest + strongest_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS.get(strongest, '')} {strongest}") + strongest_item.setFlags(strongest_item.flags() & ~Qt.ItemIsEditable) + self.history_table.setItem(row, 3, strongest_item) + + # Weakest + weakest_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS.get(weakest, '')} {weakest}") + weakest_item.setFlags(weakest_item.flags() & ~Qt.ItemIsEditable) + self.history_table.setItem(row, 4, weakest_item) + + # Status + status_item = QTableWidgetItem(status) + status_item.setFlags(status_item.flags() & ~Qt.ItemIsEditable) + status_item.setTextAlignment(Qt.AlignCenter) + + # Color code status + if status == "ACTIVE": + status_item.setForeground(QColor("#27ae60")) + status_item.setFont(QFont("Arial", 10, QFont.Bold)) + elif status == "NO_TRADE": + status_item.setForeground(QColor("#e74c3c")) + else: + status_item.setForeground(QColor("#95a5a6")) + + self.history_table.setItem(row, 5, status_item) + + self.history_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + + except Exception as e: + print(f"[ERROR] Failed to load history: {e}") + + def _on_row_clicked(self, item: QTableWidgetItem): + """Handle row click to show detailed score breakdown.""" + row = item.row() + month = self.history_table.item(row, 0).text() + + try: + # Get scores for this month + scores = self.db.get_month_scores(month) + + if not scores: + QMessageBox.information( + self, + "No Scores", + f"No score data found for {month}" + ) + return + + # Build detailed breakdown + breakdown_lines = [f"Score Breakdown for {month}:", ""] + + # Get ranked list + ranked = sorted( + [(c, s) for c, s in scores.items()], + key=lambda x: x[1]['rank'] + ) + + for currency, score_data in ranked: + rank = score_data['rank'] + score_rate = score_data.get('score_rate', 0) + score_cpi = score_data.get('score_cpi', 0) + score_pmi = score_data.get('score_pmi', 0) + total = score_data['total_score'] + + breakdown_lines.append( + f"{rank}. {config.CURRENCY_EMOJIS.get(currency, '')} {currency:>3} | " + f"Total: {total:>5.1f} | " + f"Rate: {score_rate:>5.1f} CPI: {score_cpi:>5.1f} PMI: {score_pmi:>5.1f}" + ) + + breakdown_text = "\n".join(breakdown_lines) + + QMessageBox.information( + self, + f"Score Details — {month}", + breakdown_text + ) + + except Exception as e: + QMessageBox.critical( + self, + "Error", + f"Failed to load score details: {e}" + ) + + def refresh_history(self): + """Refresh history display (called when new data saved).""" + self._load_history() diff --git a/ui/settings_tab.py b/ui/settings_tab.py new file mode 100644 index 0000000..454d2ea --- /dev/null +++ b/ui/settings_tab.py @@ -0,0 +1,399 @@ +""" +APEX Layer 1 — Tab 4: Settings + +Configuration editor for: +- FRED API key (with test connection) +- Central Bank inflation targets (read-only display, edit only if CB changes mandate) +- Scoring weights (Rate %, CPI %, PMI %) +- Minimum gap to trade +- Auto-fetch rates on startup toggle +- Application info + +Settings are stored in the .env file. +""" + +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.QtGui import QFont +from typing import Dict, Optional +import config +from fred_client import FredClient +import os +from pathlib import Path + + +class FredTestWorker(QThread): + """Background thread for testing FRED API connection.""" + + test_complete = pyqtSignal(bool, str) # (success, message) + + def __init__(self, api_key: str): + super().__init__() + self.api_key = api_key + + def run(self): + """Test FRED connectivity.""" + try: + client = FredClient(self.api_key, timeout=5) + rate = client.fetch_rate("USD") + + if rate is not None: + self.test_complete.emit(True, f"✓ Connection successful! USD rate: {rate}%") + else: + self.test_complete.emit(False, "✗ No data returned for USD") + except Exception as e: + self.test_complete.emit(False, f"✗ Connection failed: {str(e)}") + + +class SettingsTab(QWidget): + """Settings and configuration tab.""" + + # Signal triggered when settings change + settings_changed = pyqtSignal() + + def __init__(self): + """Initialize Settings tab.""" + super().__init__() + self.env_path = Path(__file__).parent.parent.parent / ".env" + + self._init_ui() + self._load_settings() + + def _init_ui(self): + """Build the UI layout.""" + scroll = QScrollArea() + scroll.setWidgetResizable(True) + + main_widget = QWidget() + layout = QVBoxLayout() + + # ====== FRED API Configuration ====== + api_group = QGroupBox("FRED API Configuration") + api_layout = QVBoxLayout() + + api_layout.addWidget(QLabel( + "Enter your FRED API key for automatic interest rate fetching.\n" + "Get a free key from https://fred.stlouisfed.org" + )) + + key_layout = QHBoxLayout() + key_layout.addWidget(QLabel("API Key:")) + self.api_key_input = QLineEdit() + self.api_key_input.setEchoMode(QLineEdit.Password) + self.api_key_input.setPlaceholderText("Paste your FRED API key here...") + key_layout.addWidget(self.api_key_input) + + test_btn = QPushButton("Test Connection") + test_btn.clicked.connect(self._test_fred_connection) + key_layout.addWidget(test_btn) + + api_layout.addLayout(key_layout) + + self.test_status = QLabel("") + self.test_status.setStyleSheet("color: #95a5a6; font-style: italic;") + api_layout.addWidget(self.test_status) + + api_group.setLayout(api_layout) + layout.addWidget(api_group) + layout.addSpacing(10) + + # ====== Central Bank Targets ====== + cb_group = QGroupBox("Central Bank Inflation Targets (%)") + cb_layout = QVBoxLayout() + + cb_layout.addWidget(QLabel( + "These are hardcoded constants. Edit only if a central bank officially changes its mandate.\n" + "Most central banks maintain these targets for years." + )) + + # Display in a grid-like format + targets_text = " ".join([f"{c}: {config.CB_TARGETS[c]}%" for c in config.CURRENCIES]) + targets_label = QLabel(targets_text) + targets_label.setFont(QFont("Courier", 10)) + targets_label.setStyleSheet("background-color: #ecf0f1; padding: 10px; border-radius: 4px;") + cb_layout.addWidget(targets_label) + + cb_layout.addWidget(QLabel("To edit: Manually update the CB_TARGETS dict in config.py")) + cb_group.setLayout(cb_layout) + layout.addWidget(cb_group) + layout.addSpacing(10) + + # ====== Scoring Weights ====== + weights_group = QGroupBox("Scoring Weights") + weights_layout = QVBoxLayout() + + weights_layout.addWidget(QLabel( + "Adjust the influence of each input. Must sum to 100%.\n" + "Default: Rate 50%, CPI 30%, PMI 20%" + )) + + # Rate weight + rate_layout = QHBoxLayout() + rate_layout.addWidget(QLabel("Rate Differential:")) + self.weight_rate_spin = QDoubleSpinBox() + self.weight_rate_spin.setRange(0, 100) + self.weight_rate_spin.setValue(config.WEIGHT_RATE * 100) + self.weight_rate_spin.setSuffix("%") + self.weight_rate_spin.setDecimals(1) + rate_layout.addWidget(self.weight_rate_spin) + rate_layout.addStretch() + weights_layout.addLayout(rate_layout) + + # CPI weight + cpi_layout = QHBoxLayout() + cpi_layout.addWidget(QLabel("CPI Deviation:")) + self.weight_cpi_spin = QDoubleSpinBox() + self.weight_cpi_spin.setRange(0, 100) + self.weight_cpi_spin.setValue(config.WEIGHT_CPI * 100) + self.weight_cpi_spin.setSuffix("%") + self.weight_cpi_spin.setDecimals(1) + cpi_layout.addWidget(self.weight_cpi_spin) + cpi_layout.addStretch() + weights_layout.addLayout(cpi_layout) + + # PMI weight + pmi_layout = QHBoxLayout() + pmi_layout.addWidget(QLabel("PMI Composite:")) + self.weight_pmi_spin = QDoubleSpinBox() + self.weight_pmi_spin.setRange(0, 100) + self.weight_pmi_spin.setValue(config.WEIGHT_PMI * 100) + self.weight_pmi_spin.setSuffix("%") + self.weight_pmi_spin.setDecimals(1) + pmi_layout.addWidget(self.weight_pmi_spin) + pmi_layout.addStretch() + weights_layout.addLayout(pmi_layout) + + # Total validation label + self.weights_total_label = QLabel("Total: 0%") + self.weights_total_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + weights_layout.addWidget(self.weights_total_label) + + # Connect to update total + self.weight_rate_spin.valueChanged.connect(self._update_weights_total) + self.weight_cpi_spin.valueChanged.connect(self._update_weights_total) + self.weight_pmi_spin.valueChanged.connect(self._update_weights_total) + + weights_group.setLayout(weights_layout) + layout.addWidget(weights_group) + layout.addSpacing(10) + + # ====== Trading Rules ====== + rules_group = QGroupBox("Trading Rules") + rules_layout = QVBoxLayout() + + rules_layout.addWidget(QLabel( + "Minimum gap between strongest and weakest currency to generate a trade signal.\n" + "If gap < minimum, output 'NO TRADE'. Default: 20 points." + )) + + min_gap_layout = QHBoxLayout() + min_gap_layout.addWidget(QLabel("Minimum gap to trade:")) + self.min_gap_spin = QSpinBox() + self.min_gap_spin.setRange(5, 100) + self.min_gap_spin.setValue(int(config.MIN_GAP_TO_TRADE)) + self.min_gap_spin.setSuffix(" points") + min_gap_layout.addWidget(self.min_gap_spin) + min_gap_layout.addStretch() + rules_layout.addLayout(min_gap_layout) + + rules_group.setLayout(rules_layout) + layout.addWidget(rules_group) + layout.addSpacing(10) + + # ====== Application Settings ====== + app_group = QGroupBox("Application Settings") + app_layout = QVBoxLayout() + + self.auto_fetch_check = QCheckBox("Auto-fetch interest rates on startup") + self.auto_fetch_check.setChecked(config.AUTO_FETCH_RATES_ON_STARTUP) + app_layout.addWidget(self.auto_fetch_check) + + app_group.setLayout(app_layout) + layout.addWidget(app_group) + layout.addSpacing(15) + + # ====== Save Button ====== + save_layout = QHBoxLayout() + 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.clicked.connect(self._save_settings) + save_layout.addWidget(save_btn) + + reset_btn = QPushButton("Reset to Defaults") + reset_btn.clicked.connect(self._reset_to_defaults) + save_layout.addWidget(reset_btn) + + layout.addLayout(save_layout) + layout.addStretch() + + main_widget.setLayout(layout) + scroll.setWidget(main_widget) + + main_layout = QVBoxLayout() + main_layout.addWidget(scroll) + self.setLayout(main_layout) + + def _load_settings(self): + """Load settings from .env file.""" + try: + env_vars = {} + if self.env_path.exists(): + with open(self.env_path, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key.strip()] = value.strip() + + # Load API key + api_key = env_vars.get('FRED_API_KEY', '') + self.api_key_input.setText(api_key) + + # Load weights (convert from decimal to percentage) + weight_rate = float(env_vars.get('WEIGHT_RATE', config.WEIGHT_RATE)) * 100 + weight_cpi = float(env_vars.get('WEIGHT_CPI', config.WEIGHT_CPI)) * 100 + weight_pmi = float(env_vars.get('WEIGHT_PMI', config.WEIGHT_PMI)) * 100 + + self.weight_rate_spin.blockSignals(True) + self.weight_cpi_spin.blockSignals(True) + self.weight_pmi_spin.blockSignals(True) + + self.weight_rate_spin.setValue(weight_rate) + self.weight_cpi_spin.setValue(weight_cpi) + self.weight_pmi_spin.setValue(weight_pmi) + + self.weight_rate_spin.blockSignals(False) + self.weight_cpi_spin.blockSignals(False) + self.weight_pmi_spin.blockSignals(False) + + # Load min gap + min_gap = float(env_vars.get('MIN_GAP', config.MIN_GAP_TO_TRADE)) + self.min_gap_spin.setValue(int(min_gap)) + + # Load auto-fetch setting + auto_fetch = env_vars.get('AUTO_FETCH_RATES_ON_STARTUP', 'true').lower() == 'true' + self.auto_fetch_check.setChecked(auto_fetch) + + self._update_weights_total() + + except Exception as e: + print(f"[ERROR] Failed to load settings: {e}") + + def _update_weights_total(self): + """Update weights total display and color.""" + total = (self.weight_rate_spin.value() + + self.weight_cpi_spin.value() + + self.weight_pmi_spin.value()) + + self.weights_total_label.setText(f"Total: {total:.1f}%") + + if abs(total - 100) < 0.1: + self.weights_total_label.setStyleSheet("color: #27ae60; font-weight: bold;") + else: + self.weights_total_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + + def _test_fred_connection(self): + """Test FRED API connection in background.""" + api_key = self.api_key_input.text().strip() + + if not api_key: + QMessageBox.warning(self, "Missing API Key", "Please enter a FRED API key first.") + return + + self.test_status.setText("Testing connection...") + + self.test_worker = FredTestWorker(api_key) + self.test_worker.test_complete.connect(self._on_test_complete) + self.test_worker.start() + + def _on_test_complete(self, success: bool, message: str): + """Handle FRED test completion.""" + self.test_status.setText(message) + + if success: + self.test_status.setStyleSheet("color: #27ae60; font-weight: bold;") + else: + self.test_status.setStyleSheet("color: #e74c3c; font-weight: bold;") + + def _save_settings(self): + """Save settings to .env file.""" + try: + # Validate weights sum to 100% + total = (self.weight_rate_spin.value() + + self.weight_cpi_spin.value() + + self.weight_pmi_spin.value()) + + if abs(total - 100) > 0.1: + QMessageBox.warning( + self, + "Invalid Weights", + f"Weights must sum to 100%. Current total: {total:.1f}%" + ) + return + + # Prepare new .env content + api_key = self.api_key_input.text().strip() + weight_rate = self.weight_rate_spin.value() / 100 + weight_cpi = self.weight_cpi_spin.value() / 100 + weight_pmi = self.weight_pmi_spin.value() / 100 + min_gap = self.min_gap_spin.value() + auto_fetch = "true" if self.auto_fetch_check.isChecked() else "false" + + env_content = f"""FRED_API_KEY={api_key} +DB_PATH=apex.db +MIN_GAP={min_gap} +WEIGHT_RATE={weight_rate:.2f} +WEIGHT_CPI={weight_cpi:.2f} +WEIGHT_PMI={weight_pmi:.2f} +AUTO_FETCH_RATES_ON_STARTUP={auto_fetch} +DEBUG=false +""" + + # Write to .env + with open(self.env_path, 'w') as f: + f.write(env_content) + + QMessageBox.information( + self, + "Settings Saved", + "Settings have been saved to .env\nPlease restart the application for changes to take effect." + ) + + self.settings_changed.emit() + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save settings: {e}") + + def _reset_to_defaults(self): + """Reset all settings to defaults.""" + reply = QMessageBox.question( + self, + "Reset to Defaults", + "Are you sure? This will reset all settings to factory defaults.", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.weight_rate_spin.setValue(50) + self.weight_cpi_spin.setValue(30) + self.weight_pmi_spin.setValue(20) + self.min_gap_spin.setValue(20) + self.auto_fetch_check.setChecked(True)