mirror of
https://github.com/Sabermrddz/QuantCore-FX.git
synced 2026-08-15 19:58:06 +00:00
v1 first layer
This commit is contained in:
@@ -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