Phase 1: initial codes
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||
QTreeWidget, QTreeWidgetItem, QMenu, QInputDialog, QMessageBox, QComboBox
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
from utils.formatters import fmt_currency, fmt_percent, pnl_color
|
||||
|
||||
|
||||
class WatchlistFetchWorker(QThread):
|
||||
quotes_ready = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, symbols: list[str]):
|
||||
super().__init__()
|
||||
self._symbols = symbols
|
||||
|
||||
def run(self):
|
||||
from core.market_data import get_batch_quotes
|
||||
self.quotes_ready.emit(get_batch_quotes(self._symbols))
|
||||
|
||||
|
||||
class WatchlistWidget(QWidget):
|
||||
symbol_selected = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._worker: WatchlistFetchWorker | None = None
|
||||
self._setup_ui()
|
||||
self._load_from_db()
|
||||
self._start_timer()
|
||||
|
||||
def _setup_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(12, 12, 12, 12)
|
||||
root.setSpacing(8)
|
||||
|
||||
header = QLabel("Watchlist")
|
||||
header.setObjectName("title")
|
||||
root.addWidget(header)
|
||||
|
||||
# Add symbol row
|
||||
add_row = QHBoxLayout()
|
||||
self._symbol_input = QLineEdit()
|
||||
self._symbol_input.setPlaceholderText("Add symbol…")
|
||||
self._symbol_input.returnPressed.connect(self._add_symbol)
|
||||
|
||||
self._group_combo = QComboBox()
|
||||
self._group_combo.setEditable(True)
|
||||
self._group_combo.addItems(["Default", "Tech", "Crypto", "ETFs"])
|
||||
self._group_combo.setMaximumWidth(100)
|
||||
|
||||
add_btn = QPushButton("Add")
|
||||
add_btn.setObjectName("primary_btn")
|
||||
add_btn.clicked.connect(self._add_symbol)
|
||||
|
||||
add_row.addWidget(self._symbol_input)
|
||||
add_row.addWidget(self._group_combo)
|
||||
add_row.addWidget(add_btn)
|
||||
root.addLayout(add_row)
|
||||
|
||||
# Tree
|
||||
self._tree = QTreeWidget()
|
||||
self._tree.setColumnCount(4)
|
||||
self._tree.setHeaderLabels(["Symbol", "Price", "Change", "% Change"])
|
||||
self._tree.header().setStretchLastSection(False)
|
||||
self._tree.setColumnWidth(0, 120)
|
||||
self._tree.setColumnWidth(1, 90)
|
||||
self._tree.setColumnWidth(2, 80)
|
||||
self._tree.setColumnWidth(3, 80)
|
||||
self._tree.setAlternatingRowColors(True)
|
||||
self._tree.itemDoubleClicked.connect(self._on_item_double_clicked)
|
||||
self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._tree.customContextMenuRequested.connect(self._show_context_menu)
|
||||
root.addWidget(self._tree)
|
||||
|
||||
self._status_label = QLabel("")
|
||||
self._status_label.setObjectName("subtitle")
|
||||
root.addWidget(self._status_label)
|
||||
|
||||
def _start_timer(self):
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._refresh_quotes)
|
||||
self._timer.start(60_000)
|
||||
|
||||
def _load_from_db(self):
|
||||
from db.database import get_session
|
||||
from db.models import WatchlistItem
|
||||
self._tree.clear()
|
||||
groups: dict[str, QTreeWidgetItem] = {}
|
||||
with get_session() as session:
|
||||
items = session.query(WatchlistItem).order_by(WatchlistItem.group_name).all()
|
||||
for item in items:
|
||||
group = item.group_name or "Default"
|
||||
if group not in groups:
|
||||
group_item = QTreeWidgetItem([group])
|
||||
group_item.setFont(0, QFont("Segoe UI", 9, QFont.Weight.Bold))
|
||||
group_item.setForeground(0, QColor("#89b4fa"))
|
||||
self._tree.addTopLevelItem(group_item)
|
||||
group_item.setExpanded(True)
|
||||
groups[group] = group_item
|
||||
|
||||
child = QTreeWidgetItem([item.symbol, "—", "—", "—"])
|
||||
child.setData(0, Qt.ItemDataRole.UserRole, item.symbol)
|
||||
groups[group].addChild(child)
|
||||
|
||||
self._refresh_quotes()
|
||||
|
||||
def _refresh_quotes(self):
|
||||
symbols = self._collect_symbols()
|
||||
if not symbols or (self._worker and self._worker.isRunning()):
|
||||
return
|
||||
self._worker = WatchlistFetchWorker(symbols)
|
||||
self._worker.quotes_ready.connect(self._apply_quotes)
|
||||
self._worker.start()
|
||||
|
||||
def _collect_symbols(self) -> list[str]:
|
||||
symbols = []
|
||||
for i in range(self._tree.topLevelItemCount()):
|
||||
group = self._tree.topLevelItem(i)
|
||||
for j in range(group.childCount()):
|
||||
sym = group.child(j).data(0, Qt.ItemDataRole.UserRole)
|
||||
if sym:
|
||||
symbols.append(sym)
|
||||
return symbols
|
||||
|
||||
def _apply_quotes(self, quotes: dict):
|
||||
from datetime import datetime
|
||||
for i in range(self._tree.topLevelItemCount()):
|
||||
group = self._tree.topLevelItem(i)
|
||||
for j in range(group.childCount()):
|
||||
child = group.child(j)
|
||||
sym = child.data(0, Qt.ItemDataRole.UserRole)
|
||||
q = quotes.get(sym, {})
|
||||
price = q.get("price", 0)
|
||||
change = q.get("change", 0)
|
||||
change_pct = q.get("change_pct", 0)
|
||||
child.setText(1, fmt_currency(price))
|
||||
child.setText(2, f"{'+' if change >= 0 else ''}{fmt_currency(change)}")
|
||||
child.setText(3, fmt_percent(change_pct, plus=True))
|
||||
color = QColor(pnl_color(change))
|
||||
for col in (1, 2, 3):
|
||||
child.setForeground(col, color)
|
||||
self._status_label.setText(f"Updated {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
def _add_symbol(self):
|
||||
symbol = self._symbol_input.text().strip().upper()
|
||||
group = self._group_combo.currentText().strip() or "Default"
|
||||
if not symbol:
|
||||
return
|
||||
from db.database import get_session
|
||||
from db.models import WatchlistItem
|
||||
with get_session() as session:
|
||||
existing = session.query(WatchlistItem).filter_by(symbol=symbol).first()
|
||||
if existing:
|
||||
return
|
||||
session.add(WatchlistItem(symbol=symbol, group_name=group))
|
||||
session.commit()
|
||||
self._symbol_input.clear()
|
||||
self._load_from_db()
|
||||
|
||||
if self._group_combo.findText(group) == -1:
|
||||
self._group_combo.addItem(group)
|
||||
|
||||
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
|
||||
sym = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if sym:
|
||||
self.symbol_selected.emit(sym)
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
item = self._tree.itemAt(pos)
|
||||
if not item or not item.data(0, Qt.ItemDataRole.UserRole):
|
||||
return
|
||||
sym = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
menu = QMenu(self)
|
||||
view_action = menu.addAction(f"View Chart: {sym}")
|
||||
remove_action = menu.addAction("Remove")
|
||||
action = menu.exec(self._tree.mapToGlobal(pos))
|
||||
if action == view_action:
|
||||
self.symbol_selected.emit(sym)
|
||||
elif action == remove_action:
|
||||
self._remove_symbol(sym)
|
||||
|
||||
def _remove_symbol(self, symbol: str):
|
||||
from db.database import get_session
|
||||
from db.models import WatchlistItem
|
||||
with get_session() as session:
|
||||
session.query(WatchlistItem).filter_by(symbol=symbol).delete()
|
||||
session.commit()
|
||||
self._load_from_db()
|
||||
|
||||
def add_symbol_external(self, symbol: str, group: str = "Default"):
|
||||
self._symbol_input.setText(symbol)
|
||||
idx = self._group_combo.findText(group)
|
||||
if idx >= 0:
|
||||
self._group_combo.setCurrentIndex(idx)
|
||||
self._add_symbol()
|
||||
Reference in New Issue
Block a user