v1 first layer

This commit is contained in:
saber
2026-06-16 12:27:37 +01:00
commit 6bfcc67088
19 changed files with 3769 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""
APEX Layer 1 — User Interface Modules
This package contains all PyQt5 UI tabs and components.
"""
+307
View File
@@ -0,0 +1,307 @@
"""
APEX Layer 1 — Tab 1: Dashboard
This is the main screen the user sees every day.
Features:
- Signal card at top (shows PRIMARY SIGNAL, gap, status, updated date)
- Ranked score table below with all 8 currencies
- Strongest row highlighted GREEN (BUY)
- Weakest row highlighted RED (SELL)
- Score bar charts per row (visual progress)
- Auto-refresh when data updated from Entry tab or FRED API
Display:
- Rank, Currency, Rate, CPI, PMI, Score columns
- Color-coded rows, "BUY" and "SELL" tags
- Last updated timestamp
"""
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem,
QFrame, QPushButton, QSpinBox
)
from PyQt5.QtCore import Qt, pyqtSignal, QSize
from PyQt5.QtGui import QColor, QFont, QBrush, QPixmap
from typing import Dict, Optional
from datetime import datetime
import config
from database import Database
import scorer
class DashboardTab(QWidget):
"""Main dashboard showing current signal and currency rankings."""
# Signal to request FRED fetch
fetch_rates_requested = pyqtSignal()
def __init__(self, db: Database):
"""
Initialize Dashboard tab.
Args:
db: Database instance
"""
super().__init__()
self.db = db
self.current_month = datetime.now().strftime("%Y-%m")
self._init_ui()
self._refresh_display()
def _init_ui(self):
"""Build the UI layout."""
layout = QVBoxLayout()
# ====== Signal Card ======
signal_card = self._build_signal_card()
layout.addWidget(signal_card)
layout.addSpacing(15)
# ====== Ranked Score Table ======
layout.addWidget(QLabel("Currency Rankings"))
self.score_table = QTableWidget()
self.score_table.setColumnCount(8)
self.score_table.setHorizontalHeaderLabels([
"Rank", "Currency", "Rate (%)", "CPI (%)", "PMI", "Score", "Signal", "Strength"
])
self.score_table.setRowCount(len(config.CURRENCIES))
self.score_table.setAlternatingRowColors(True)
self.score_table.setSelectionBehavior(QTableWidget.SelectRows)
self.score_table.setSelectionMode(QTableWidget.SingleSelection)
# Pre-fill with placeholder rows
for row in range(len(config.CURRENCIES)):
for col in range(8):
item = QTableWidgetItem("")
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
self.score_table.setItem(row, col, item)
self.score_table.resizeColumnsToContents()
layout.addWidget(self.score_table)
layout.addSpacing(15)
# ====== Refresh Button ======
button_layout = QHBoxLayout()
button_layout.addStretch()
self.refresh_btn = QPushButton("Refresh")
self.refresh_btn.clicked.connect(self._refresh_display)
button_layout.addWidget(self.refresh_btn)
fetch_btn = QPushButton("Fetch Rates (FRED)")
fetch_btn.clicked.connect(self._on_fetch_rates)
button_layout.addWidget(fetch_btn)
layout.addLayout(button_layout)
layout.addStretch()
self.setLayout(layout)
def _build_signal_card(self) -> QFrame:
"""Build the signal card frame."""
card = QFrame()
card.setStyleSheet("""
QFrame {
background-color: #f8f9fa;
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 15px;
}
""")
layout = QVBoxLayout()
# Title
title = QLabel("PRIMARY SIGNAL")
title.setFont(QFont("Arial", 10, QFont.Bold))
title.setStyleSheet("color: #495057;")
layout.addWidget(title)
layout.addSpacing(5)
# Signal text (large, bold)
self.signal_label = QLabel("NO TRADE — Initializing...")
self.signal_label.setFont(QFont("Arial", 24, QFont.Bold))
self.signal_label.setStyleSheet("color: #2c3e50;")
layout.addWidget(self.signal_label)
layout.addSpacing(10)
# Gap and status
self.gap_label = QLabel("Gap: — points")
self.gap_label.setFont(QFont("Arial", 12))
layout.addWidget(self.gap_label)
# Updated timestamp
self.updated_label = QLabel("Updated: —")
self.updated_label.setFont(QFont("Arial", 10))
self.updated_label.setStyleSheet("color: #7f8c8d;")
layout.addWidget(self.updated_label)
card.setLayout(layout)
return card
def _refresh_display(self):
"""Refresh dashboard with latest data."""
try:
# Get signal for current month
signal_data = self.db.get_signal(self.current_month)
if signal_data:
signal_text = signal_data["signal"]
gap = signal_data["gap"]
status = signal_data["status"]
# Update signal label
self.signal_label.setText(signal_text)
# Color code based on status
if status == "ACTIVE":
self.signal_label.setStyleSheet("color: #27ae60;") # Green
else:
self.signal_label.setStyleSheet("color: #e74c3c;") # Red
# Update gap label
gap_tier = scorer.get_gap_tier(gap)
tier_name = {
"no_trade": "Too narrow",
"weak": "Weak signal",
"standard": "Standard signal",
"strong": "Strong signal"
}.get(gap_tier, "Unknown")
self.gap_label.setText(f"Gap: {gap:.1f} points · {tier_name}")
else:
self.signal_label.setText("NO TRADE — No data yet")
self.signal_label.setStyleSheet("color: #e74c3c;")
self.gap_label.setText("Gap: — points")
# Update timestamp
self.updated_label.setText(f"Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
# Refresh score table
self._refresh_score_table()
except Exception as e:
print(f"[ERROR] Failed to refresh dashboard: {e}")
self.signal_label.setText("ERROR")
self.signal_label.setStyleSheet("color: #e74c3c;")
def _refresh_score_table(self):
"""Refresh the ranked currency table."""
try:
scores = self.db.get_month_scores(self.current_month)
if not scores:
# No scores yet
for row in range(len(config.CURRENCIES)):
for col in range(8):
self.score_table.item(row, col).setText("")
return
# Get sorted list
ranked = [(c, s) for c, s in sorted(
scores.items(),
key=lambda x: x[1]['rank']
)]
# Get rates for display
rates = self.db.get_all_rates()
# Get monthly data for CPI display
monthly_data = self.db.get_monthly_data(self.current_month)
for row, (currency, score_data) in enumerate(ranked):
rank = score_data['rank']
total_score = score_data['total_score']
rate = rates.get(currency)
cpi_data = monthly_data.get(currency, {})
cpi = cpi_data.get('cpi_actual')
pmi = cpi_data.get('pmi_actual')
# Rank
self.score_table.item(row, 0).setText(str(rank))
# Currency
currency_text = f"{config.CURRENCY_EMOJIS.get(currency, '')} {currency}"
self.score_table.item(row, 1).setText(currency_text)
# Rate
rate_text = f"{rate:.2f}" if rate is not None else ""
self.score_table.item(row, 2).setText(rate_text)
# CPI
cpi_text = f"{cpi:.2f}" if cpi is not None else ""
self.score_table.item(row, 3).setText(cpi_text)
# PMI
pmi_text = f"{pmi:.1f}" if pmi is not None else ""
self.score_table.item(row, 4).setText(pmi_text)
# Score (two decimals)
self.score_table.item(row, 5).setText(f"{total_score:.1f}")
# Signal tag (BUY for strongest, SELL for weakest)
if rank == 1:
self.score_table.item(row, 6).setText("BUY")
elif rank == len(config.CURRENCIES):
self.score_table.item(row, 6).setText("SELL")
else:
self.score_table.item(row, 6).setText("")
# Strength bar (visual progress 0-100)
strength_item = self.score_table.item(row, 7)
strength_item.setText(f"{int(total_score)}%")
# Color code rows
if rank == 1:
# Strongest = GREEN
for col in range(8):
self.score_table.item(row, col).setBackground(QColor("#d5f4e6"))
self.score_table.item(row, col).setForeground(QColor("#27ae60"))
self.score_table.item(row, col).setFont(QFont("Arial", 10, QFont.Bold))
elif rank == len(config.CURRENCIES):
# Weakest = RED
for col in range(8):
self.score_table.item(row, col).setBackground(QColor("#fadbd8"))
self.score_table.item(row, col).setForeground(QColor("#e74c3c"))
self.score_table.item(row, col).setFont(QFont("Arial", 10, QFont.Bold))
else:
# Middle = neutral
for col in range(8):
self.score_table.item(row, col).setBackground(QColor("#ffffff"))
self.score_table.item(row, col).setForeground(QColor("#2c3e50"))
self.score_table.item(row, col).setFont(QFont("Arial", 10))
# Auto-resize columns to content
self.score_table.resizeColumnsToContents()
except Exception as e:
print(f"[ERROR] Failed to refresh score table: {e}")
def _on_fetch_rates(self):
"""Handle fetch rates button click."""
self.fetch_rates_requested.emit()
def on_data_saved(self, month: str):
"""
Called when entry tab saves new data.
Args:
month: Month string (YYYY-MM)
"""
self.current_month = month
self._refresh_display()
def on_rates_updated(self, rates: Dict[str, float]):
"""
Called when FRED rates fetched successfully.
Args:
rates: Dict mapping currency to rate
"""
# Rates are saved to DB by the thread, just refresh display
self._refresh_display()
+593
View File
@@ -0,0 +1,593 @@
"""
APEX Layer 1 — Tab 2: Monthly Data Entry
This tab allows users to manually enter CPI and PMI data for all 8 currencies
for the current month.
Features:
- Two tables: CPI entry and PMI entry
- Live delta calculation (actual CPI - target)
- Progress bar tracking (X of 16 fields filled)
- Save button disabled until all 16 fields complete
- Month selector dropdown
- Color coding: green for above target, red for below (CPI only)
User flow:
1. Select current month from dropdown
2. Enter 8 CPI values from official releases
3. Enter 8 PMI values from S&P Global
4. Progress bar shows 16/16 when complete
5. Click "Save & Calculate Scores"
6. Triggers scorer.py → updates Dashboard tab
"""
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem,
QPushButton, QProgressBar, QComboBox, QSpinBox, QDoubleSpinBox, QHeaderView,
QFileDialog, QMessageBox
)
from PyQt5.QtCore import Qt, pyqtSignal, QDate
from PyQt5.QtGui import QColor, QFont, QBrush
from typing import Dict, Optional
from datetime import datetime, timedelta
import config
from database import Database
import scorer
import pandas as pd
import openpyxl
class MonthlyEntryTab(QWidget):
"""Monthly CPI + PMI data entry form."""
# Signal emitted when data saved successfully
data_saved = pyqtSignal(str) # month string
def __init__(self, db: Database):
"""
Initialize Monthly Entry tab.
Args:
db: Database instance
"""
super().__init__()
self.db = db
self.current_month = None
self.cpi_fields = {} # currency -> QDoubleSpinBox
self.pmi_fields = {} # currency -> QDoubleSpinBox
self.delta_labels = {} # currency -> QLabel
self.pmi_signal_labels = {} # currency -> QLabel
self._init_ui()
self._connect_signals()
self._load_current_month()
def _init_ui(self):
"""Build the UI layout."""
layout = QVBoxLayout()
# ====== Month selector ======
month_layout = QHBoxLayout()
month_layout.addWidget(QLabel("Month:"))
self.month_combo = QComboBox()
self._populate_month_combo()
month_layout.addWidget(self.month_combo)
month_layout.addStretch()
layout.addLayout(month_layout)
layout.addSpacing(10)
# ====== CPI Entry Table ======
layout.addWidget(QLabel("CPI Entry (Actual YoY % - Enter after each country releases)"))
self.cpi_table = QTableWidget()
self.cpi_table.setColumnCount(5)
self.cpi_table.setHorizontalHeaderLabels(
["Currency", "Target %", "Actual CPI %", "Delta", "Done"]
)
self.cpi_table.setRowCount(len(config.CURRENCIES))
for row, currency in enumerate(config.CURRENCIES):
# Currency label
currency_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS[currency]} {currency}")
currency_item.setFlags(currency_item.flags() & ~Qt.ItemIsEditable)
self.cpi_table.setItem(row, 0, currency_item)
# Target
target = config.CB_TARGETS[currency]
target_item = QTableWidgetItem(f"{target}%")
target_item.setFlags(target_item.flags() & ~Qt.ItemIsEditable)
self.cpi_table.setItem(row, 1, target_item)
# Actual CPI input
spin = QDoubleSpinBox()
spin.setRange(config.CPI_MIN, config.CPI_MAX)
spin.setDecimals(2)
spin.setValue(0.0)
spin.setStyleSheet("background-color: white; padding: 2px;")
self.cpi_fields[currency] = spin
self.cpi_table.setCellWidget(row, 2, spin)
# Delta label
delta_label = QLabel("")
delta_label.setAlignment(Qt.AlignCenter)
self.delta_labels[currency] = delta_label
self.cpi_table.setItem(row, 3, QTableWidgetItem(""))
self.cpi_table.setCellWidget(row, 3, delta_label)
# Done indicator
done_item = QTableWidgetItem("")
done_item.setTextAlignment(Qt.AlignCenter)
done_item.setFlags(done_item.flags() & ~Qt.ItemIsEditable)
self.cpi_table.setItem(row, 4, done_item)
# Auto-resize columns
self.cpi_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
layout.addWidget(self.cpi_table)
layout.addSpacing(10)
# ====== PMI Entry Table ======
layout.addWidget(QLabel("PMI Entry (Composite PMI - Enter after S&P Global release)"))
self.pmi_table = QTableWidget()
self.pmi_table.setColumnCount(5)
self.pmi_table.setHorizontalHeaderLabels(
["Currency", "Neutral", "PMI Reading", "Signal", "Done"]
)
self.pmi_table.setRowCount(len(config.CURRENCIES))
for row, currency in enumerate(config.CURRENCIES):
# Currency label
currency_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS[currency]} {currency}")
currency_item.setFlags(currency_item.flags() & ~Qt.ItemIsEditable)
self.pmi_table.setItem(row, 0, currency_item)
# Neutral reference
neutral_item = QTableWidgetItem("50.0")
neutral_item.setFlags(neutral_item.flags() & ~Qt.ItemIsEditable)
self.pmi_table.setItem(row, 1, neutral_item)
# PMI input
spin = QDoubleSpinBox()
spin.setRange(config.PMI_MIN, config.PMI_MAX)
spin.setDecimals(1)
spin.setValue(50.0) # Default to neutral
spin.setStyleSheet("background-color: white; padding: 2px;")
self.pmi_fields[currency] = spin
self.pmi_table.setCellWidget(row, 2, spin)
# Signal label
signal_label = QLabel("Neutral")
signal_label.setAlignment(Qt.AlignCenter)
self.pmi_signal_labels[currency] = signal_label
self.pmi_table.setItem(row, 3, QTableWidgetItem(""))
self.pmi_table.setCellWidget(row, 3, signal_label)
# Done indicator
done_item = QTableWidgetItem("")
done_item.setTextAlignment(Qt.AlignCenter)
done_item.setFlags(done_item.flags() & ~Qt.ItemIsEditable)
self.pmi_table.setItem(row, 4, done_item)
self.pmi_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
layout.addWidget(self.pmi_table)
layout.addSpacing(15)
# ====== Progress Bar ======
progress_layout = QHBoxLayout()
progress_layout.addWidget(QLabel("Data entry progress:"))
self.progress_bar = QProgressBar()
self.progress_bar.setMaximum(16)
self.progress_bar.setValue(0)
self.progress_bar.setFormat("%v / 16 fields filled")
progress_layout.addWidget(self.progress_bar)
layout.addLayout(progress_layout)
layout.addSpacing(10)
# ====== Buttons ======
button_layout = QHBoxLayout()
# Import Excel button
self.import_btn = QPushButton("📊 Import Excel")
self.import_btn.setMinimumHeight(40)
self.import_btn.setFont(QFont("Arial", 11, QFont.Bold))
self.import_btn.setStyleSheet("""
QPushButton {
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
padding: 10px 20px;
}
QPushButton:hover {
background-color: #2980b9;
}
""")
button_layout.addWidget(self.import_btn)
button_layout.addStretch()
self.save_btn = QPushButton("Save & Calculate Scores")
self.save_btn.setEnabled(False)
self.save_btn.setMinimumHeight(40)
self.save_btn.setFont(QFont("Arial", 11, QFont.Bold))
self.save_btn.setStyleSheet("""
QPushButton:enabled {
background-color: #2ecc71;
color: white;
border: none;
border-radius: 5px;
padding: 10px 20px;
}
QPushButton:hover:enabled {
background-color: #27ae60;
}
QPushButton:disabled {
background-color: #95a5a6;
color: #7f8c8d;
border: none;
border-radius: 5px;
padding: 10px 20px;
}
""")
button_layout.addWidget(self.save_btn)
layout.addLayout(button_layout)
layout.addStretch()
self.setLayout(layout)
def _connect_signals(self):
"""Connect UI signals to slots."""
# Month selector
self.month_combo.currentTextChanged.connect(self._on_month_changed)
# CPI field changes
for currency, spin in self.cpi_fields.items():
spin.valueChanged.connect(self._on_cpi_changed)
# PMI field changes
for currency, spin in self.pmi_fields.items():
spin.valueChanged.connect(self._on_pmi_changed)
# Import button
self.import_btn.clicked.connect(self._on_import_excel)
# Save button
self.save_btn.clicked.connect(self._on_save_clicked)
def _populate_month_combo(self):
"""Populate month dropdown with past 24 months + current month."""
months = []
today = datetime.now()
# Add current month and past 23 months
for i in range(24):
month_date = today - timedelta(days=30 * i)
month_str = month_date.strftime("%Y-%m")
months.append(month_str)
self.month_combo.addItems(months)
def _load_current_month(self):
"""Load current month data from database."""
self.current_month = datetime.now().strftime("%Y-%m")
# Set combo to current month
current_index = self.month_combo.findText(self.current_month)
if current_index >= 0:
self.month_combo.setCurrentIndex(current_index)
self._load_month_data(self.current_month)
def _on_month_changed(self, month_str: str):
"""Handle month selection change."""
self.current_month = month_str
self._load_month_data(month_str)
def _load_month_data(self, month: str):
"""Load saved CPI/PMI data from database for a month."""
try:
monthly_data = self.db.get_monthly_data(month)
# Clear fields
for spin in self.cpi_fields.values():
spin.blockSignals(True)
spin.setValue(0.0)
spin.blockSignals(False)
for spin in self.pmi_fields.values():
spin.blockSignals(True)
spin.setValue(50.0)
spin.blockSignals(False)
# Load saved values
for currency, data in monthly_data.items():
if data["cpi_actual"] is not None:
self.cpi_fields[currency].blockSignals(True)
self.cpi_fields[currency].setValue(data["cpi_actual"])
self.cpi_fields[currency].blockSignals(False)
if data["pmi_actual"] is not None:
self.pmi_fields[currency].blockSignals(True)
self.pmi_fields[currency].setValue(data["pmi_actual"])
self.pmi_fields[currency].blockSignals(False)
# Refresh UI
self._update_delta_labels()
self._update_pmi_signals()
self._update_progress()
except Exception as e:
print(f"[ERROR] Failed to load month data: {e}")
def _on_cpi_changed(self):
"""Handle CPI value change."""
self._update_delta_labels()
self._update_progress()
def _update_delta_labels(self):
"""Update delta (CPI - target) labels with color coding."""
for currency, spin in self.cpi_fields.items():
cpi = spin.value()
target = config.CB_TARGETS[currency]
delta = cpi - target
label = self.delta_labels[currency]
if cpi == 0:
# Not filled
label.setText("")
label.setStyleSheet("")
else:
# Show delta with sign
delta_str = f"{delta:+.2f}%"
label.setText(delta_str)
# Color code
if delta > 0:
label.setStyleSheet("color: #27ae60; font-weight: bold;") # Green (hawkish)
elif delta < 0:
label.setStyleSheet("color: #e74c3c; font-weight: bold;") # Red (dovish)
else:
label.setStyleSheet("color: #95a5a6;") # Gray (neutral)
def _on_pmi_changed(self):
"""Handle PMI value change."""
self._update_pmi_signals()
self._update_progress()
def _update_pmi_signals(self):
"""Update PMI signal labels based on value."""
for currency, spin in self.pmi_fields.items():
pmi = spin.value()
label = self.pmi_signal_labels[currency]
if pmi > 52:
label.setText("Expanding")
label.setStyleSheet("color: #27ae60; font-weight: bold;")
elif pmi >= 50:
label.setText("Neutral +")
label.setStyleSheet("color: #f39c12; font-weight: bold;")
elif pmi > 48:
label.setText("Neutral ")
label.setStyleSheet("color: #f39c12; font-weight: bold;")
else:
label.setText("Contracting")
label.setStyleSheet("color: #e74c3c; font-weight: bold;")
def _update_progress(self):
"""Update progress bar and save button state."""
filled = 0
# Count filled CPI fields
for currency, spin in self.cpi_fields.items():
if spin.value() != 0:
filled += 1
# Update done indicator
row = config.CURRENCIES.index(currency)
self.cpi_table.item(row, 4).setText("")
else:
row = config.CURRENCIES.index(currency)
self.cpi_table.item(row, 4).setText("")
# Count filled PMI fields
for currency, spin in self.pmi_fields.items():
if spin.value() != 50.0: # PMI default is 50 (neutral)
filled += 1
# Update done indicator
row = config.CURRENCIES.index(currency)
self.pmi_table.item(row, 4).setText("")
else:
row = config.CURRENCIES.index(currency)
self.pmi_table.item(row, 4).setText("")
self.progress_bar.setValue(filled)
# Enable save button only if all 16 fields filled
self.save_btn.setEnabled(filled == 16)
def _on_import_excel(self):
"""Handle Import Excel button click."""
# Open file dialog
file_path, _ = QFileDialog.getOpenFileName(
self,
"Import Monthly Data from Excel",
"",
"Excel Files (*.xlsx *.xls);;CSV Files (*.csv);;All Files (*)"
)
if not file_path:
return # User cancelled
try:
self._load_excel_data(file_path)
QMessageBox.information(
self,
"Success",
"✓ Data imported successfully!\n\nClick 'Save & Calculate Scores' to process."
)
except Exception as e:
QMessageBox.critical(
self,
"Import Error",
f"Failed to import Excel file:\n\n{str(e)}\n\n" +
"Please check the file format. See EXCEL_IMPORT_PROMPT.md for details."
)
def _load_excel_data(self, file_path: str):
"""
Load CPI and PMI data from Excel file.
Expected structure:
- Sheet 'CPI': Columns [Currency, Target %, Actual CPI %]
- Sheet 'PMI': Columns [Currency, Composite PMI]
Or single sheet with structure:
- Columns [Currency, Target_CPI, Actual_CPI, Composite_PMI]
Args:
file_path: Path to Excel or CSV file
"""
if file_path.endswith('.csv'):
# Load from CSV
df = pd.read_csv(file_path)
self._parse_csv_data(df)
else:
# Load from Excel (try multi-sheet format first, then single-sheet)
try:
self._load_excel_multi_sheet(file_path)
except:
self._load_excel_single_sheet(file_path)
def _load_excel_multi_sheet(self, file_path: str):
"""Load Excel with separate CPI and PMI sheets."""
# Load CPI sheet
cpi_df = pd.read_excel(file_path, sheet_name='CPI')
pmi_df = pd.read_excel(file_path, sheet_name='PMI')
# Map CPI data
for _, row in cpi_df.iterrows():
currency = str(row.iloc[0]).strip().upper()
if currency in config.CURRENCIES:
actual_cpi = float(row.iloc[2])
if actual_cpi != 0:
self.cpi_fields[currency].blockSignals(True)
self.cpi_fields[currency].setValue(actual_cpi)
self.cpi_fields[currency].blockSignals(False)
# Map PMI data
for _, row in pmi_df.iterrows():
currency = str(row.iloc[0]).strip().upper()
if currency in config.CURRENCIES:
pmi_value = float(row.iloc[1])
if pmi_value != 0:
self.pmi_fields[currency].blockSignals(True)
self.pmi_fields[currency].setValue(pmi_value)
self.pmi_fields[currency].blockSignals(False)
# Refresh UI
self._update_delta_labels()
self._update_pmi_signals()
self._update_progress()
def _load_excel_single_sheet(self, file_path: str):
"""Load Excel with single sheet containing all data."""
df = pd.read_excel(file_path)
self._parse_csv_data(df)
def _parse_csv_data(self, df):
"""Parse DataFrame and populate tables."""
# Try to detect column names (case-insensitive)
columns = [str(col).lower().strip() for col in df.columns]
# Map CPI and PMI from dataframe
for _, row in df.iterrows():
# Get currency (assume first column or named column)
currency = str(row.iloc[0]).strip().upper()
if not currency or currency not in config.CURRENCIES:
continue
# Try to find CPI column
cpi_cols = [i for i, c in enumerate(columns) if 'cpi' in c and 'actual' in c]
if cpi_cols:
try:
actual_cpi = float(row.iloc[cpi_cols[0]])
if actual_cpi != 0:
self.cpi_fields[currency].blockSignals(True)
self.cpi_fields[currency].setValue(actual_cpi)
self.cpi_fields[currency].blockSignals(False)
except (ValueError, IndexError):
pass
# Try to find PMI column
pmi_cols = [i for i, c in enumerate(columns) if 'pmi' in c]
if pmi_cols:
try:
pmi_value = float(row.iloc[pmi_cols[0]])
if pmi_value != 0:
self.pmi_fields[currency].blockSignals(True)
self.pmi_fields[currency].setValue(pmi_value)
self.pmi_fields[currency].blockSignals(False)
except (ValueError, IndexError):
pass
# Refresh UI
self._update_delta_labels()
self._update_pmi_signals()
self._update_progress()
def _on_save_clicked(self):
"""Handle Save & Calculate Scores button click."""
try:
# Collect CPI values
cpi_values = {
currency: self.cpi_fields[currency].value()
for currency in config.CURRENCIES
}
# Collect PMI values
pmi_values = {
currency: self.pmi_fields[currency].value()
for currency in config.CURRENCIES
}
# Save to database
for currency in config.CURRENCIES:
self.db.update_monthly_cpi(self.current_month, currency, cpi_values[currency])
self.db.update_monthly_pmi(self.current_month, currency, pmi_values[currency])
# Fetch rates from database
rates = self.db.get_all_rates()
# Score all currencies
scores = scorer.score_all_currencies(rates, cpi_values, pmi_values)
# Save scores to database
self.db.save_scores(self.current_month, scores)
# Generate signal
strongest, weakest, gap = scorer.pair_currencies(scores)
signal_text, status, gap_desc = scorer.generate_signal(scores)
# Save signal
self.db.save_signal(
self.current_month,
strongest,
weakest,
gap,
signal_text,
status
)
# Emit signal so Dashboard tab can refresh
self.data_saved.emit(self.current_month)
# Show confirmation
print(f"[Entry] Data saved and scores calculated for {self.current_month}")
except Exception as e:
print(f"[ERROR] Failed to save data: {e}")
+182
View File
@@ -0,0 +1,182 @@
"""
APEX Layer 1 — Tab 3: History
Displays past trading signals and monthly scores.
Features:
- Table with past months (newest first)
- Columns: Month, Signal, Gap, Strongest, Weakest, Status
- Click any row to expand and see full score breakdown for all 8 currencies
- Sort by month/gap/status
"""
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView,
QMessageBox
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor, QFont
from typing import Dict, List
import config
from database import Database
class HistoryTab(QWidget):
"""History tab showing past signals and scores."""
def __init__(self, db: Database):
"""
Initialize History tab.
Args:
db: Database instance
"""
super().__init__()
self.db = db
self._init_ui()
self._load_history()
def _init_ui(self):
"""Build the UI layout."""
layout = QVBoxLayout()
# History table
self.history_table = QTableWidget()
self.history_table.setColumnCount(6)
self.history_table.setHorizontalHeaderLabels([
"Month", "Signal", "Gap", "Strongest", "Weakest", "Status"
])
# Enable sorting
self.history_table.setSortingEnabled(False)
self.history_table.setSelectionBehavior(QTableWidget.SelectRows)
self.history_table.setSelectionMode(QTableWidget.SingleSelection)
self.history_table.itemClicked.connect(self._on_row_clicked)
layout.addWidget(self.history_table)
self.setLayout(layout)
def _load_history(self):
"""Load signal history from database."""
try:
signals = self.db.get_all_signals(limit=24)
if not signals:
self.history_table.setRowCount(0)
return
self.history_table.setRowCount(len(signals))
for row, signal_data in enumerate(signals):
month = signal_data["month"]
signal_text = signal_data["signal"]
gap = signal_data["gap"]
strongest = signal_data["strongest"]
weakest = signal_data["weakest"]
status = signal_data["status"]
# Month
month_item = QTableWidgetItem(month)
month_item.setFlags(month_item.flags() & ~Qt.ItemIsEditable)
self.history_table.setItem(row, 0, month_item)
# Signal
signal_item = QTableWidgetItem(signal_text)
signal_item.setFlags(signal_item.flags() & ~Qt.ItemIsEditable)
self.history_table.setItem(row, 1, signal_item)
# Gap
gap_item = QTableWidgetItem(f"{gap:.1f}")
gap_item.setFlags(gap_item.flags() & ~Qt.ItemIsEditable)
gap_item.setTextAlignment(Qt.AlignCenter)
self.history_table.setItem(row, 2, gap_item)
# Strongest
strongest_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS.get(strongest, '')} {strongest}")
strongest_item.setFlags(strongest_item.flags() & ~Qt.ItemIsEditable)
self.history_table.setItem(row, 3, strongest_item)
# Weakest
weakest_item = QTableWidgetItem(f"{config.CURRENCY_EMOJIS.get(weakest, '')} {weakest}")
weakest_item.setFlags(weakest_item.flags() & ~Qt.ItemIsEditable)
self.history_table.setItem(row, 4, weakest_item)
# Status
status_item = QTableWidgetItem(status)
status_item.setFlags(status_item.flags() & ~Qt.ItemIsEditable)
status_item.setTextAlignment(Qt.AlignCenter)
# Color code status
if status == "ACTIVE":
status_item.setForeground(QColor("#27ae60"))
status_item.setFont(QFont("Arial", 10, QFont.Bold))
elif status == "NO_TRADE":
status_item.setForeground(QColor("#e74c3c"))
else:
status_item.setForeground(QColor("#95a5a6"))
self.history_table.setItem(row, 5, status_item)
self.history_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
except Exception as e:
print(f"[ERROR] Failed to load history: {e}")
def _on_row_clicked(self, item: QTableWidgetItem):
"""Handle row click to show detailed score breakdown."""
row = item.row()
month = self.history_table.item(row, 0).text()
try:
# Get scores for this month
scores = self.db.get_month_scores(month)
if not scores:
QMessageBox.information(
self,
"No Scores",
f"No score data found for {month}"
)
return
# Build detailed breakdown
breakdown_lines = [f"Score Breakdown for {month}:", ""]
# Get ranked list
ranked = sorted(
[(c, s) for c, s in scores.items()],
key=lambda x: x[1]['rank']
)
for currency, score_data in ranked:
rank = score_data['rank']
score_rate = score_data.get('score_rate', 0)
score_cpi = score_data.get('score_cpi', 0)
score_pmi = score_data.get('score_pmi', 0)
total = score_data['total_score']
breakdown_lines.append(
f"{rank}. {config.CURRENCY_EMOJIS.get(currency, '')} {currency:>3} | "
f"Total: {total:>5.1f} | "
f"Rate: {score_rate:>5.1f} CPI: {score_cpi:>5.1f} PMI: {score_pmi:>5.1f}"
)
breakdown_text = "\n".join(breakdown_lines)
QMessageBox.information(
self,
f"Score Details — {month}",
breakdown_text
)
except Exception as e:
QMessageBox.critical(
self,
"Error",
f"Failed to load score details: {e}"
)
def refresh_history(self):
"""Refresh history display (called when new data saved)."""
self._load_history()
+399
View File
@@ -0,0 +1,399 @@
"""
APEX Layer 1 — Tab 4: Settings
Configuration editor for:
- FRED API key (with test connection)
- Central Bank inflation targets (read-only display, edit only if CB changes mandate)
- Scoring weights (Rate %, CPI %, PMI %)
- Minimum gap to trade
- Auto-fetch rates on startup toggle
- Application info
Settings are stored in the .env file.
"""
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QDoubleSpinBox,
QPushButton, QCheckBox, QGroupBox, QSpinBox, QMessageBox, QScrollArea
)
from PyQt5.QtCore import Qt, pyqtSignal, QThread
from PyQt5.QtGui import QFont
from typing import Dict, Optional
import config
from fred_client import FredClient
import os
from pathlib import Path
class FredTestWorker(QThread):
"""Background thread for testing FRED API connection."""
test_complete = pyqtSignal(bool, str) # (success, message)
def __init__(self, api_key: str):
super().__init__()
self.api_key = api_key
def run(self):
"""Test FRED connectivity."""
try:
client = FredClient(self.api_key, timeout=5)
rate = client.fetch_rate("USD")
if rate is not None:
self.test_complete.emit(True, f"✓ Connection successful! USD rate: {rate}%")
else:
self.test_complete.emit(False, "✗ No data returned for USD")
except Exception as e:
self.test_complete.emit(False, f"✗ Connection failed: {str(e)}")
class SettingsTab(QWidget):
"""Settings and configuration tab."""
# Signal triggered when settings change
settings_changed = pyqtSignal()
def __init__(self):
"""Initialize Settings tab."""
super().__init__()
self.env_path = Path(__file__).parent.parent.parent / ".env"
self._init_ui()
self._load_settings()
def _init_ui(self):
"""Build the UI layout."""
scroll = QScrollArea()
scroll.setWidgetResizable(True)
main_widget = QWidget()
layout = QVBoxLayout()
# ====== FRED API Configuration ======
api_group = QGroupBox("FRED API Configuration")
api_layout = QVBoxLayout()
api_layout.addWidget(QLabel(
"Enter your FRED API key for automatic interest rate fetching.\n"
"Get a free key from https://fred.stlouisfed.org"
))
key_layout = QHBoxLayout()
key_layout.addWidget(QLabel("API Key:"))
self.api_key_input = QLineEdit()
self.api_key_input.setEchoMode(QLineEdit.Password)
self.api_key_input.setPlaceholderText("Paste your FRED API key here...")
key_layout.addWidget(self.api_key_input)
test_btn = QPushButton("Test Connection")
test_btn.clicked.connect(self._test_fred_connection)
key_layout.addWidget(test_btn)
api_layout.addLayout(key_layout)
self.test_status = QLabel("")
self.test_status.setStyleSheet("color: #95a5a6; font-style: italic;")
api_layout.addWidget(self.test_status)
api_group.setLayout(api_layout)
layout.addWidget(api_group)
layout.addSpacing(10)
# ====== Central Bank Targets ======
cb_group = QGroupBox("Central Bank Inflation Targets (%)")
cb_layout = QVBoxLayout()
cb_layout.addWidget(QLabel(
"These are hardcoded constants. Edit only if a central bank officially changes its mandate.\n"
"Most central banks maintain these targets for years."
))
# Display in a grid-like format
targets_text = " ".join([f"{c}: {config.CB_TARGETS[c]}%" for c in config.CURRENCIES])
targets_label = QLabel(targets_text)
targets_label.setFont(QFont("Courier", 10))
targets_label.setStyleSheet("background-color: #ecf0f1; padding: 10px; border-radius: 4px;")
cb_layout.addWidget(targets_label)
cb_layout.addWidget(QLabel("To edit: Manually update the CB_TARGETS dict in config.py"))
cb_group.setLayout(cb_layout)
layout.addWidget(cb_group)
layout.addSpacing(10)
# ====== Scoring Weights ======
weights_group = QGroupBox("Scoring Weights")
weights_layout = QVBoxLayout()
weights_layout.addWidget(QLabel(
"Adjust the influence of each input. Must sum to 100%.\n"
"Default: Rate 50%, CPI 30%, PMI 20%"
))
# Rate weight
rate_layout = QHBoxLayout()
rate_layout.addWidget(QLabel("Rate Differential:"))
self.weight_rate_spin = QDoubleSpinBox()
self.weight_rate_spin.setRange(0, 100)
self.weight_rate_spin.setValue(config.WEIGHT_RATE * 100)
self.weight_rate_spin.setSuffix("%")
self.weight_rate_spin.setDecimals(1)
rate_layout.addWidget(self.weight_rate_spin)
rate_layout.addStretch()
weights_layout.addLayout(rate_layout)
# CPI weight
cpi_layout = QHBoxLayout()
cpi_layout.addWidget(QLabel("CPI Deviation:"))
self.weight_cpi_spin = QDoubleSpinBox()
self.weight_cpi_spin.setRange(0, 100)
self.weight_cpi_spin.setValue(config.WEIGHT_CPI * 100)
self.weight_cpi_spin.setSuffix("%")
self.weight_cpi_spin.setDecimals(1)
cpi_layout.addWidget(self.weight_cpi_spin)
cpi_layout.addStretch()
weights_layout.addLayout(cpi_layout)
# PMI weight
pmi_layout = QHBoxLayout()
pmi_layout.addWidget(QLabel("PMI Composite:"))
self.weight_pmi_spin = QDoubleSpinBox()
self.weight_pmi_spin.setRange(0, 100)
self.weight_pmi_spin.setValue(config.WEIGHT_PMI * 100)
self.weight_pmi_spin.setSuffix("%")
self.weight_pmi_spin.setDecimals(1)
pmi_layout.addWidget(self.weight_pmi_spin)
pmi_layout.addStretch()
weights_layout.addLayout(pmi_layout)
# Total validation label
self.weights_total_label = QLabel("Total: 0%")
self.weights_total_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
weights_layout.addWidget(self.weights_total_label)
# Connect to update total
self.weight_rate_spin.valueChanged.connect(self._update_weights_total)
self.weight_cpi_spin.valueChanged.connect(self._update_weights_total)
self.weight_pmi_spin.valueChanged.connect(self._update_weights_total)
weights_group.setLayout(weights_layout)
layout.addWidget(weights_group)
layout.addSpacing(10)
# ====== Trading Rules ======
rules_group = QGroupBox("Trading Rules")
rules_layout = QVBoxLayout()
rules_layout.addWidget(QLabel(
"Minimum gap between strongest and weakest currency to generate a trade signal.\n"
"If gap < minimum, output 'NO TRADE'. Default: 20 points."
))
min_gap_layout = QHBoxLayout()
min_gap_layout.addWidget(QLabel("Minimum gap to trade:"))
self.min_gap_spin = QSpinBox()
self.min_gap_spin.setRange(5, 100)
self.min_gap_spin.setValue(int(config.MIN_GAP_TO_TRADE))
self.min_gap_spin.setSuffix(" points")
min_gap_layout.addWidget(self.min_gap_spin)
min_gap_layout.addStretch()
rules_layout.addLayout(min_gap_layout)
rules_group.setLayout(rules_layout)
layout.addWidget(rules_group)
layout.addSpacing(10)
# ====== Application Settings ======
app_group = QGroupBox("Application Settings")
app_layout = QVBoxLayout()
self.auto_fetch_check = QCheckBox("Auto-fetch interest rates on startup")
self.auto_fetch_check.setChecked(config.AUTO_FETCH_RATES_ON_STARTUP)
app_layout.addWidget(self.auto_fetch_check)
app_group.setLayout(app_layout)
layout.addWidget(app_group)
layout.addSpacing(15)
# ====== Save Button ======
save_layout = QHBoxLayout()
save_layout.addStretch()
save_btn = QPushButton("Save Settings")
save_btn.setMinimumHeight(40)
save_btn.setFont(QFont("Arial", 11, QFont.Bold))
save_btn.setStyleSheet("""
QPushButton {
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
padding: 10px 20px;
}
QPushButton:hover {
background-color: #2980b9;
}
""")
save_btn.clicked.connect(self._save_settings)
save_layout.addWidget(save_btn)
reset_btn = QPushButton("Reset to Defaults")
reset_btn.clicked.connect(self._reset_to_defaults)
save_layout.addWidget(reset_btn)
layout.addLayout(save_layout)
layout.addStretch()
main_widget.setLayout(layout)
scroll.setWidget(main_widget)
main_layout = QVBoxLayout()
main_layout.addWidget(scroll)
self.setLayout(main_layout)
def _load_settings(self):
"""Load settings from .env file."""
try:
env_vars = {}
if self.env_path.exists():
with open(self.env_path, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
env_vars[key.strip()] = value.strip()
# Load API key
api_key = env_vars.get('FRED_API_KEY', '')
self.api_key_input.setText(api_key)
# Load weights (convert from decimal to percentage)
weight_rate = float(env_vars.get('WEIGHT_RATE', config.WEIGHT_RATE)) * 100
weight_cpi = float(env_vars.get('WEIGHT_CPI', config.WEIGHT_CPI)) * 100
weight_pmi = float(env_vars.get('WEIGHT_PMI', config.WEIGHT_PMI)) * 100
self.weight_rate_spin.blockSignals(True)
self.weight_cpi_spin.blockSignals(True)
self.weight_pmi_spin.blockSignals(True)
self.weight_rate_spin.setValue(weight_rate)
self.weight_cpi_spin.setValue(weight_cpi)
self.weight_pmi_spin.setValue(weight_pmi)
self.weight_rate_spin.blockSignals(False)
self.weight_cpi_spin.blockSignals(False)
self.weight_pmi_spin.blockSignals(False)
# Load min gap
min_gap = float(env_vars.get('MIN_GAP', config.MIN_GAP_TO_TRADE))
self.min_gap_spin.setValue(int(min_gap))
# Load auto-fetch setting
auto_fetch = env_vars.get('AUTO_FETCH_RATES_ON_STARTUP', 'true').lower() == 'true'
self.auto_fetch_check.setChecked(auto_fetch)
self._update_weights_total()
except Exception as e:
print(f"[ERROR] Failed to load settings: {e}")
def _update_weights_total(self):
"""Update weights total display and color."""
total = (self.weight_rate_spin.value() +
self.weight_cpi_spin.value() +
self.weight_pmi_spin.value())
self.weights_total_label.setText(f"Total: {total:.1f}%")
if abs(total - 100) < 0.1:
self.weights_total_label.setStyleSheet("color: #27ae60; font-weight: bold;")
else:
self.weights_total_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
def _test_fred_connection(self):
"""Test FRED API connection in background."""
api_key = self.api_key_input.text().strip()
if not api_key:
QMessageBox.warning(self, "Missing API Key", "Please enter a FRED API key first.")
return
self.test_status.setText("Testing connection...")
self.test_worker = FredTestWorker(api_key)
self.test_worker.test_complete.connect(self._on_test_complete)
self.test_worker.start()
def _on_test_complete(self, success: bool, message: str):
"""Handle FRED test completion."""
self.test_status.setText(message)
if success:
self.test_status.setStyleSheet("color: #27ae60; font-weight: bold;")
else:
self.test_status.setStyleSheet("color: #e74c3c; font-weight: bold;")
def _save_settings(self):
"""Save settings to .env file."""
try:
# Validate weights sum to 100%
total = (self.weight_rate_spin.value() +
self.weight_cpi_spin.value() +
self.weight_pmi_spin.value())
if abs(total - 100) > 0.1:
QMessageBox.warning(
self,
"Invalid Weights",
f"Weights must sum to 100%. Current total: {total:.1f}%"
)
return
# Prepare new .env content
api_key = self.api_key_input.text().strip()
weight_rate = self.weight_rate_spin.value() / 100
weight_cpi = self.weight_cpi_spin.value() / 100
weight_pmi = self.weight_pmi_spin.value() / 100
min_gap = self.min_gap_spin.value()
auto_fetch = "true" if self.auto_fetch_check.isChecked() else "false"
env_content = f"""FRED_API_KEY={api_key}
DB_PATH=apex.db
MIN_GAP={min_gap}
WEIGHT_RATE={weight_rate:.2f}
WEIGHT_CPI={weight_cpi:.2f}
WEIGHT_PMI={weight_pmi:.2f}
AUTO_FETCH_RATES_ON_STARTUP={auto_fetch}
DEBUG=false
"""
# Write to .env
with open(self.env_path, 'w') as f:
f.write(env_content)
QMessageBox.information(
self,
"Settings Saved",
"Settings have been saved to .env\nPlease restart the application for changes to take effect."
)
self.settings_changed.emit()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save settings: {e}")
def _reset_to_defaults(self):
"""Reset all settings to defaults."""
reply = QMessageBox.question(
self,
"Reset to Defaults",
"Are you sure? This will reset all settings to factory defaults.",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.weight_rate_spin.setValue(50)
self.weight_cpi_spin.setValue(30)
self.weight_pmi_spin.setValue(20)
self.min_gap_spin.setValue(20)
self.auto_fetch_check.setChecked(True)