Phase 1: initial codes

This commit is contained in:
2026-05-26 11:04:27 -04:00
parent 03bff49523
commit f49a283059
32 changed files with 4194 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
from __future__ import annotations
import math
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget,
QTableWidgetItem, QTabWidget, QSplitter, QFrame, QMessageBox,
QDialog, QFormLayout, QLineEdit, QDialogButtonBox, QDoubleSpinBox, QSizePolicy
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QRectF
from PyQt6.QtGui import QColor, QPainter, QBrush, QPen, QFont
from utils.formatters import fmt_currency, fmt_percent, fmt_large_number, pnl_color
class SyncWorker(QThread):
positions_ready = pyqtSignal(list, object)
error = pyqtSignal(str)
def __init__(self, broker):
super().__init__()
self._broker = broker
def run(self):
try:
positions = self._broker.get_positions()
balance = self._broker.get_balance()
self.positions_ready.emit(positions, balance)
except Exception as e:
self.error.emit(str(e))
class PieChartWidget(QWidget):
def __init__(self, data: dict[str, float] | None = None, parent=None):
super().__init__(parent)
self._data = data or {}
self.setMinimumSize(220, 220)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def set_data(self, data: dict[str, float]):
self._data = data
self.update()
def paintEvent(self, event):
if not self._data:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
margin = 40
side = min(w - margin * 2, h - margin * 2)
x = (w - side) // 2
y = (h - side) // 2
rect = QRectF(x, y, side, side)
palette = ["#89b4fa", "#a6e3a1", "#f38ba8", "#fab387", "#cba6f7",
"#f9e2af", "#89dceb", "#94e2d5", "#b4befe", "#eba0ac"]
total = sum(self._data.values())
if total == 0:
return
angle = 0.0
items = sorted(self._data.items(), key=lambda x: -x[1])
for i, (label, value) in enumerate(items):
span = value / total * 360 * 16
color = QColor(palette[i % len(palette)])
painter.setBrush(QBrush(color))
painter.setPen(QPen(QColor("#1e1e2e"), 2))
painter.drawPie(rect, int(angle), int(span))
angle += span
# Legend
legend_x = 8
legend_y = 8
painter.setFont(QFont("Segoe UI", 8))
for i, (label, value) in enumerate(items[:8]):
color = QColor(palette[i % len(palette)])
painter.setBrush(QBrush(color))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRect(legend_x, legend_y + i * 16, 10, 10)
painter.setPen(QPen(QColor("#cdd6f4")))
painter.drawText(legend_x + 14, legend_y + i * 16 + 9,
f"{label[:14]} {value:.1f}%")
class AddPositionDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Add Position")
self.setMinimumWidth(300)
layout = QFormLayout(self)
self.symbol_input = QLineEdit()
self.symbol_input.setPlaceholderText("e.g. AAPL")
self.shares_input = QDoubleSpinBox()
self.shares_input.setRange(0.0001, 1_000_000)
self.shares_input.setDecimals(4)
self.cost_input = QDoubleSpinBox()
self.cost_input.setRange(0.0, 1_000_000)
self.cost_input.setDecimals(2)
self.cost_input.setPrefix("$")
layout.addRow("Symbol:", self.symbol_input)
layout.addRow("Shares:", self.shares_input)
layout.addRow("Avg Cost/Share:", self.cost_input)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addRow(buttons)
class PortfolioWidget(QWidget):
symbol_selected = pyqtSignal(str)
def __init__(self, config, parent=None):
super().__init__(parent)
self._config = config
self._positions: list[dict] = []
self._worker: SyncWorker | None = None
self._setup_ui()
self._load_manual_positions()
def _setup_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(8)
header_row = QHBoxLayout()
header = QLabel("Portfolio")
header.setObjectName("title")
header_row.addWidget(header)
header_row.addStretch()
schwab_btn = QPushButton("Sync Schwab")
schwab_btn.clicked.connect(self._sync_schwab)
rh_btn = QPushButton("Sync Robinhood")
rh_btn.clicked.connect(self._sync_robinhood)
add_btn = QPushButton("+ Add Manual")
add_btn.setObjectName("primary_btn")
add_btn.clicked.connect(self._add_manual)
for btn in (schwab_btn, rh_btn, add_btn):
header_row.addWidget(btn)
root.addLayout(header_row)
# Balance summary
self._balance_frame = QFrame()
self._balance_frame.setObjectName("card")
bal_layout = QHBoxLayout(self._balance_frame)
self._equity_label = QLabel("Total Equity\n")
self._cash_label = QLabel("Cash\n")
self._bp_label = QLabel("Buying Power\n")
self._day_pnl_label = QLabel("Day P&L\n")
for lbl in (self._equity_label, self._cash_label, self._bp_label, self._day_pnl_label):
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setFont(QFont("Segoe UI", 9))
bal_layout.addWidget(lbl)
root.addWidget(self._balance_frame)
# Holdings table + pie chart
splitter = QSplitter(Qt.Orientation.Horizontal)
self._table = QTableWidget()
self._table.setColumnCount(7)
self._table.setHorizontalHeaderLabels(["Symbol", "Shares", "Avg Cost", "Price", "Value", "P&L $", "P&L %"])
self._table.setAlternatingRowColors(True)
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._table.horizontalHeader().setStretchLastSection(True)
self._table.doubleClicked.connect(self._on_row_double_click)
splitter.addWidget(self._table)
self._pie_chart = PieChartWidget()
splitter.addWidget(self._pie_chart)
splitter.setSizes([700, 250])
root.addWidget(splitter)
self._status_label = QLabel("")
self._status_label.setObjectName("subtitle")
root.addWidget(self._status_label)
def _load_manual_positions(self):
from db.database import get_session
from db.models import PortfolioPosition
from core.market_data import get_batch_quotes
from core.portfolio import calculate_pnl
with get_session() as session:
rows = session.query(PortfolioPosition).all()
raw = [{"symbol": r.symbol, "shares": r.shares, "avg_cost": r.avg_cost, "broker": r.broker} for r in rows]
if raw:
quotes = get_batch_quotes([r["symbol"] for r in raw])
self._positions = calculate_pnl(raw, quotes)
else:
self._positions = []
self._render_table()
def _render_table(self):
self._table.setRowCount(len(self._positions))
total_value = 0.0
sector_alloc: dict[str, float] = {}
for row, pos in enumerate(self._positions):
symbol = pos.get("symbol", "")
items = [
symbol,
f"{pos.get('shares', 0):.4f}",
fmt_currency(pos.get("avg_cost", 0)),
fmt_currency(pos.get("price", 0)),
fmt_currency(pos.get("market_value", 0)),
fmt_currency(pos.get("pnl", 0)),
fmt_percent(pos.get("pnl_pct", 0), plus=True),
]
for col, text in enumerate(items):
cell = QTableWidgetItem(text)
cell.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
if col == 0:
cell.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
if col in (5, 6):
pnl = pos.get("pnl", 0)
cell.setForeground(QColor(pnl_color(pnl)))
self._table.setItem(row, col, cell)
mv = pos.get("market_value", 0)
total_value += mv
sector = pos.get("sector", "Unknown") or "Unknown"
sector_alloc[sector] = sector_alloc.get(sector, 0) + mv
if total_value > 0:
sector_pct = {s: v / total_value * 100 for s, v in sector_alloc.items()}
self._pie_chart.set_data(sector_pct)
self._status_label.setText(f"Total Value: {fmt_currency(total_value)} | {len(self._positions)} positions")
def _update_balance(self, balance):
self._equity_label.setText(f"Total Equity\n{fmt_currency(balance.total_equity)}")
self._cash_label.setText(f"Cash\n{fmt_currency(balance.cash)}")
self._bp_label.setText(f"Buying Power\n{fmt_currency(balance.buying_power)}")
day_pnl = balance.day_pnl
color = pnl_color(day_pnl)
self._day_pnl_label.setText(f"Day P&L\n{fmt_currency(day_pnl)}")
self._day_pnl_label.setStyleSheet(f"color: {color};")
def _sync_schwab(self):
from brokers.schwab import SchwabBroker
key = self._config.schwab_app_key
secret = self._config.schwab_app_secret
if not key or not secret:
QMessageBox.warning(self, "Schwab", "Schwab API keys not configured. Go to Settings.")
return
try:
broker = SchwabBroker(key, secret)
broker.connect()
self._status_label.setText("Syncing Schwab…")
self._worker = SyncWorker(broker)
self._worker.positions_ready.connect(self._on_broker_sync)
self._worker.error.connect(lambda e: QMessageBox.critical(self, "Schwab Error", e))
self._worker.start()
except Exception as e:
QMessageBox.critical(self, "Schwab Error", str(e))
def _sync_robinhood(self):
from brokers.robinhood import RobinhoodBroker
user = self._config.robinhood_username
pwd = self._config.robinhood_password
if not user or not pwd:
QMessageBox.warning(self, "Robinhood", "Robinhood credentials not configured. Go to Settings.")
return
try:
broker = RobinhoodBroker(user, pwd)
broker.connect()
self._status_label.setText("Syncing Robinhood…")
self._worker = SyncWorker(broker)
self._worker.positions_ready.connect(self._on_broker_sync)
self._worker.error.connect(lambda e: QMessageBox.critical(self, "Robinhood Error", e))
self._worker.start()
except Exception as e:
QMessageBox.critical(self, "Robinhood Error", str(e))
def _on_broker_sync(self, positions, balance):
from core.market_data import get_batch_quotes
from core.portfolio import calculate_pnl
raw = [{"symbol": p.symbol, "shares": p.shares, "avg_cost": p.avg_cost, "broker": "broker"} for p in positions]
quotes = get_batch_quotes([p.symbol for p in positions])
self._positions = calculate_pnl(raw, quotes)
self._render_table()
self._update_balance(balance)
def _add_manual(self):
dlg = AddPositionDialog(self)
if dlg.exec() == QDialog.DialogCode.Accepted:
symbol = dlg.symbol_input.text().strip().upper()
shares = dlg.shares_input.value()
avg_cost = dlg.cost_input.value()
if symbol and shares > 0:
from db.database import get_session
from db.models import PortfolioPosition
with get_session() as session:
existing = session.query(PortfolioPosition).filter_by(symbol=symbol, broker="manual").first()
if existing:
existing.shares = shares
existing.avg_cost = avg_cost
else:
session.add(PortfolioPosition(symbol=symbol, shares=shares, avg_cost=avg_cost))
session.commit()
self._load_manual_positions()
def _on_row_double_click(self, index):
row = index.row()
if row < len(self._positions):
symbol = self._positions[row].get("symbol", "")
if symbol:
self.symbol_selected.emit(symbol)