mirror of
https://github.com/Sabermrddz/QuantCore-FX.git
synced 2026-07-27 18:47:51 +00:00
v1 first layer
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
APEX Layer 1 — User Interface Modules
|
||||
|
||||
This package contains all PyQt5 UI tabs and components.
|
||||
"""
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user