mirror of
https://github.com/Sabermrddz/QuantCore-FX.git
synced 2026-07-28 11:07:46 +00:00
561 lines
21 KiB
Python
561 lines
21 KiB
Python
"""
|
||
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, QDoubleSpinBox, QHeaderView,
|
||
QFileDialog, QMessageBox
|
||
)
|
||
from PyQt5.QtCore import Qt, pyqtSignal
|
||
from PyQt5.QtGui import QColor
|
||
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()
|
||
|
||
self.import_btn = QPushButton("📊 Import Excel")
|
||
self.import_btn.setMinimumHeight(42)
|
||
button_layout.addWidget(self.import_btn)
|
||
|
||
button_layout.addStretch()
|
||
|
||
self.save_btn = QPushButton("Save & Calculate Scores")
|
||
self.save_btn.setObjectName("success")
|
||
self.save_btn.setEnabled(False)
|
||
self.save_btn.setMinimumHeight(42)
|
||
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}")
|