v1 first layer

This commit is contained in:
saber
2026-06-16 12:27:37 +01:00
commit 6bfcc67088
19 changed files with 3769 additions and 0 deletions
+69
View File
@@ -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
+88
View File
@@ -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
+178
View File
@@ -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
+100
View File
@@ -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.
+71
View File
@@ -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.
+14
View File
@@ -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"
+204
View File
@@ -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
+98
View File
@@ -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")
+563
View File
@@ -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
+237
View File
@@ -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}")
+93
View File
@@ -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())
+208
View File
@@ -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()
+6
View File
@@ -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
+354
View File
@@ -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}")
+5
View File
@@ -0,0 +1,5 @@
"""
APEX Layer 1 — User Interface Modules
This package contains all PyQt5 UI tabs and components.
"""
+307
View File
@@ -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()
+593
View File
@@ -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}")
+182
View File
@@ -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()
+399
View File
@@ -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)