2972 lines
127 KiB
Python
2972 lines
127 KiB
Python
"""
|
||
Ultimate Investment Tool GUI — PySide6 rewrite
|
||
Part 1: imports, constants, formatters, tooltips, field defs, signals, redirector
|
||
"""
|
||
import ctypes, html as _html, io, math, multiprocessing, re, sys, threading, webbrowser
|
||
from datetime import datetime
|
||
import numpy as np
|
||
|
||
from PySide6.QtCore import *
|
||
from PySide6.QtWidgets import *
|
||
from PySide6.QtGui import *
|
||
|
||
_MATPLOTLIB_OK = False
|
||
_mpl_err = ""
|
||
try:
|
||
import matplotlib; matplotlib.use("QtAgg")
|
||
import matplotlib.ticker as mticker
|
||
from matplotlib.figure import Figure
|
||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
||
import matplotlib.dates as mdates
|
||
_MATPLOTLIB_OK = True
|
||
except Exception as _e:
|
||
_mpl_err = str(_e)
|
||
|
||
_YF_OK = False
|
||
try:
|
||
import yfinance as yf; _YF_OK = True
|
||
except Exception:
|
||
pass
|
||
|
||
from stock_screener import (INDEX_CHOICES, STRATEGY_PRESETS, WEIGHTS,
|
||
collect_tickers, fetch_all, score_stocks,
|
||
_load_sector_stats, get_news_headlines,
|
||
fetch_insider_trades, fetch_hedge_fund_filings)
|
||
|
||
multiprocessing.freeze_support()
|
||
|
||
try:
|
||
import license_check as _lc
|
||
_CHANNEL = _lc.get_channel()
|
||
except Exception:
|
||
_CHANNEL = "stable"
|
||
|
||
try:
|
||
from updater import APP_VERSION as _APP_VERSION
|
||
except Exception:
|
||
_APP_VERSION = "?"
|
||
|
||
_APP_NAME = "Ultimate Investment Tool"
|
||
_channel_label = "Beta" if _CHANNEL == "beta" else "Stable"
|
||
|
||
_C = {
|
||
"bg": "#0a0a0a",
|
||
"bg2": "#111111",
|
||
"bg3": "#181818",
|
||
"border": "#222222",
|
||
"fg": "#ffffff",
|
||
"fg2": "#888888",
|
||
"fg3": "#3a3a3a",
|
||
"positive": "#4ade80",
|
||
"negative": "#f87171",
|
||
"warning": "#fbbf24",
|
||
}
|
||
|
||
_QSS = """
|
||
QMainWindow, QWidget {{ background: {bg}; color: {fg}; font-family: 'Segoe UI'; font-size: 13px; }}
|
||
QSplitter::handle {{ background: {border}; width: 1px; }}
|
||
QScrollBar:vertical {{ background: {bg}; width: 6px; border: none; }}
|
||
QScrollBar::handle:vertical {{ background: {border}; border-radius: 3px; min-height: 20px; }}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||
QScrollBar:horizontal {{ background: {bg}; height: 6px; border: none; }}
|
||
QScrollBar::handle:horizontal {{ background: {border}; border-radius: 3px; }}
|
||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }}
|
||
QTableView, QTableWidget {{ background: {bg}; gridline-color: {border}; border: none; outline: none; selection-background-color: {bg2}; selection-color: {fg}; }}
|
||
QTableView::item, QTableWidget::item {{ padding: 0 8px; border: none; }}
|
||
QHeaderView::section {{ background: {bg2}; color: {fg2}; padding: 6px 8px; border: none; border-bottom: 1px solid {border}; font-size: 11px; font-weight: 600; }}
|
||
QTabWidget::pane {{ border: none; background: {bg}; }}
|
||
QTabBar::tab {{ background: {bg}; color: {fg2}; padding: 8px 18px; font-size: 12px; border: none; border-bottom: 2px solid transparent; }}
|
||
QTabBar::tab:selected {{ color: {fg}; border-bottom: 2px solid {fg}; }}
|
||
QTabBar::tab:hover {{ color: {fg2}; background: {bg3}; }}
|
||
QLineEdit {{ background: {bg3}; color: {fg}; border: 1px solid {border}; border-radius: 6px; padding: 5px 10px; font-size: 12px; }}
|
||
QLineEdit:focus {{ border-color: {fg2}; }}
|
||
QComboBox {{ background: {bg3}; color: {fg}; border: 1px solid {border}; border-radius: 6px; padding: 5px 10px; font-size: 12px; min-width: 80px; }}
|
||
QComboBox::drop-down {{ border: none; width: 20px; }}
|
||
QComboBox QAbstractItemView {{ background: {bg2}; color: {fg}; selection-background-color: {bg3}; border: 1px solid {border}; }}
|
||
QPushButton {{ background: {bg3}; color: {fg2}; border: 1px solid {border}; border-radius: 7px; padding: 6px 14px; font-size: 12px; font-weight: 600; }}
|
||
QPushButton:hover {{ background: rgba(255,255,255,0.06); color: {fg}; }}
|
||
QPushButton#run-btn {{ background: {fg}; color: #000000; border-color: {fg}; font-weight: 700; }}
|
||
QPushButton#run-btn:hover {{ background: #e0e0e0; }}
|
||
QPushButton:disabled {{ color: {fg3}; background: {bg3}; border-color: {border}; }}
|
||
QProgressBar {{ background: {bg3}; border: none; border-radius: 0; max-height: 3px; }}
|
||
QProgressBar::chunk {{ background: {fg}; border-radius: 0; }}
|
||
QTextEdit {{ background: {bg3}; color: {fg}; border: none; font-family: 'Segoe UI'; font-size: 12px; padding: 4px; }}
|
||
QToolTip {{ background: #1c1c1c; color: {fg}; border: 1px solid #333333; padding: 8px 12px; font-size: 11px; }}
|
||
""".format(**_C)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Formatters
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _nan(v): return v is None or (isinstance(v, float) and math.isnan(v))
|
||
def _pct(v): return "—" if _nan(v) else f"{v * 100:.1f}%"
|
||
def _flt(v, d=2): return "—" if _nan(v) else f"{v:.{d}f}"
|
||
def _price(v): return "—" if _nan(v) else f"${v:,.2f}"
|
||
def _score(v): return "—" if _nan(v) else f"{v:.1f}"
|
||
def _mcap(v):
|
||
if _nan(v): return "—"
|
||
if v >= 1e12: return f"${v/1e12:.2f}T"
|
||
if v >= 1e9: return f"${v/1e9:.2f}B"
|
||
if v >= 1e6: return f"${v/1e6:.2f}M"
|
||
return f"${v:.0f}"
|
||
def _fmt_shares(v): return "—" if v is None else f"{int(v):,}"
|
||
def _fmt_value(v):
|
||
if v is None: return "—"
|
||
if v >= 1e9: return f"${v/1e9:.1f}B"
|
||
if v >= 1e6: return f"${v/1e6:.1f}M"
|
||
if v >= 1e3: return f"${v/1e3:.0f}K"
|
||
return f"${v:.0f}"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Strategy descriptions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
STRATEGY_DESCRIPTIONS = {
|
||
"Balanced (Default)": (
|
||
"Best for: General all-purpose screening.\n\n"
|
||
"Blends all 8 factors evenly — no strong directional bias."
|
||
),
|
||
"High Growth Companies": (
|
||
"Best for: High-velocity growth investing.\n\n"
|
||
"Heavily weights revenue/earnings growth (28%) and price momentum (25%)."
|
||
),
|
||
"Conservative": (
|
||
"Best for: Capital preservation and low volatility.\n\n"
|
||
"Prioritises balance sheet quality (25%) and profitability (18%)."
|
||
),
|
||
"Recent Uptrends": (
|
||
"Best for: Short-term technical trend-following.\n\n"
|
||
"Momentum dominates at 40% — driven by 6-month price return."
|
||
),
|
||
"Buy and Hold Long Term": (
|
||
"Best for: Value-focused buy-and-hold investors.\n\n"
|
||
"Screens for deeply undervalued stocks by P/E, P/B, and EV/EBITDA (30%)."
|
||
),
|
||
"Low Risk, High Dividend Yield": (
|
||
"Best for: Income-focused portfolios seeking stable cash flow.\n\n"
|
||
"Profitability (28%) and quality (22%) dominate."
|
||
),
|
||
"High Risk High Reward": (
|
||
"Best for: Contrarian bounce plays on oversold names.\n\n"
|
||
"Reverse momentum (35%) rewards beaten-down stocks."
|
||
),
|
||
"Custom...": (
|
||
"Define your own strategy.\n\n"
|
||
"Set custom weights for each of the 8 scoring factors."
|
||
),
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Sector filter map
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_SECTOR_FILTER_MAP = {
|
||
"All Sectors": None,
|
||
"Technology": ("sector", "Technology"),
|
||
"Healthcare": ("sector", "Healthcare"),
|
||
"Financials": ("sector", "Financial Services"),
|
||
"Consumer Disc.": ("sector", "Consumer Cyclical"),
|
||
"Consumer Staples": ("sector", "Consumer Defensive"),
|
||
"Industrials": ("sector", "Industrials"),
|
||
"Defense": ("industry", "Aerospace & Defense"),
|
||
"Energy": ("sector", "Energy"),
|
||
"Materials": ("sector", "Basic Materials"),
|
||
"Real Estate": ("sector", "Real Estate"),
|
||
"Communication": ("sector", "Communication Services"),
|
||
"Utilities": ("sector", "Utilities"),
|
||
}
|
||
SECTOR_CHOICES = list(_SECTOR_FILTER_MAP.keys())
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Metric tooltips
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_METRIC_TOOLTIPS: dict = {
|
||
"Composite Score": (
|
||
"The overall investment attractiveness of the stock, scored 0-100.\n\n"
|
||
"Calculated as a weighted blend of all 8 factor scores."
|
||
),
|
||
"Value": "How cheaply the stock is priced relative to earnings, assets, and cash flow — scored 0-100.",
|
||
"Growth": "How fast the company is expanding its business — scored 0-100.",
|
||
"Momentum": "How strong the stock's recent price trend has been — scored 0-100.",
|
||
"Quality": "How financially sound and efficiently run the company is — scored 0-100.",
|
||
"Profitability": "How much of its revenue the company actually keeps as profit — scored 0-100.",
|
||
"Sentiment": "The overall tone of recent news coverage about the company — scored 0-100.",
|
||
"Analyst": "How bullish professional analysts are on the stock — scored 0-100.",
|
||
"Risk": "How low-risk the stock appears — scored 0-100, where higher = safer.",
|
||
"Price": "The most recent closing market price of the stock in USD.",
|
||
"Market Cap": "The total market value of the company — share price multiplied by shares outstanding.",
|
||
"P/E Forward": "How much investors are paying per dollar of the company's expected future earnings.",
|
||
"P/E Trailing": "How much investors are paying per dollar of actual earnings over the past 12 months.",
|
||
"P/B Ratio": "How much investors are paying relative to the company's net asset value.",
|
||
"EV / EBITDA": "Enterprise Value divided by operating earnings before non-cash charges.",
|
||
"52W High": "The highest price the stock reached over the past 52 weeks.",
|
||
"52W Low": "The lowest price the stock reached over the past 52 weeks.",
|
||
"Book Value": "Per-share net worth: total assets minus total liabilities divided by shares.",
|
||
"Revenue Growth": "How much total sales grew compared to the same period a year ago.",
|
||
"Earnings Growth": "How much net profit grew compared to a year ago.",
|
||
"EPS Forward": "Analyst consensus estimate for earnings per share over the next 12 months.",
|
||
"EPS Trailing": "Actual earnings per share reported over the past 12 months.",
|
||
"1-Month Return": "Stock's total price return over approximately the past month.",
|
||
"3-Month Return": "Stock's total price return over approximately the past three months.",
|
||
"6-Month Return": "Stock's total price return over approximately the past six months.",
|
||
"12-Month Return": "Stock's total price return over approximately the past year.",
|
||
"ROE": "Return on Equity — profit generated per dollar of shareholders' equity.",
|
||
"ROA": "Return on Assets — profit generated per dollar of total assets.",
|
||
"Revenue": "Total revenue earned by the company over the trailing twelve months.",
|
||
"Net Income": "The company's bottom-line profit over the trailing twelve months.",
|
||
"Operating Income": "Profit from core business operations before interest and taxes.",
|
||
"Total Debt": "Total amount the company owes to creditors.",
|
||
"Total Equity": "Total book value belonging to shareholders.",
|
||
"Total Cash": "Total cash and short-term liquid investments held by the company.",
|
||
"Current Ratio": "Current assets divided by current liabilities — measures short-term liquidity.",
|
||
"Profit Margin": "Percentage of revenue kept as net profit after all expenses.",
|
||
"Operating Margin": "Percentage of revenue remaining as profit from core operations.",
|
||
"FCF Yield": "Free cash flow generated relative to market value, as a percentage.",
|
||
"Dividend Yield": "Annual dividend per share as a percentage of the current stock price.",
|
||
"Recommendation": "Aggregated buy/sell opinion of all professional analysts currently covering the stock.",
|
||
"# Analysts": "Number of professional analysts actively covering this stock.",
|
||
"Price Target": "Average 12-month price target set by all analysts currently covering the stock.",
|
||
"Upside to Target": "Percentage gain implied by the analyst consensus price target.",
|
||
"News Sentiment": "Summary score of recent news coverage, from -1 (very negative) to +1 (very positive).",
|
||
"Short Interest": "Percentage of the stock's freely tradable shares currently sold short.",
|
||
"Beta": "How much the stock tends to move relative to the broader market.",
|
||
"30D Volatility": "Day-to-day price fluctuation over the past 30 trading days, annualised.",
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Field definitions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_SCORE_FIELDS = [
|
||
("Composite Score", "composite_score"),
|
||
("Value", "score_value"),
|
||
("Growth", "score_growth"),
|
||
("Momentum", "score_momentum"),
|
||
("Quality", "score_quality"),
|
||
("Profitability", "score_profitability"),
|
||
("Sentiment", "score_sentiment"),
|
||
("Analyst", "score_analyst"),
|
||
("Risk", "score_risk"),
|
||
]
|
||
_VAL_FIELDS = [
|
||
("Price", "price"),
|
||
("Market Cap", "market_cap"),
|
||
("P/E Forward", "pe_forward"),
|
||
("P/E Trailing", "pe_trailing"),
|
||
("P/B Ratio", "pb_ratio"),
|
||
("Book Value", "book_value"),
|
||
("EV / EBITDA", "ev_ebitda"),
|
||
("52W High", "52w_high"),
|
||
("52W Low", "52w_low"),
|
||
]
|
||
_GROWTH_FIELDS = [
|
||
("Revenue Growth", "revenue_growth"),
|
||
("Earnings Growth", "earnings_growth"),
|
||
("EPS Forward", "eps_forward"),
|
||
("EPS Trailing", "eps_trailing"),
|
||
]
|
||
_MOM_FIELDS = [
|
||
("1-Month Return", "ret_1m"),
|
||
("3-Month Return", "ret_3m"),
|
||
("6-Month Return", "ret_6m"),
|
||
("12-Month Return", "ret_12m"),
|
||
]
|
||
_QUAL_FIELDS = [
|
||
("Revenue", "_revenue_fmt"),
|
||
("Net Income", "_net_income_fmt"),
|
||
("Operating Income", "_op_income_fmt"),
|
||
("ROE", "roe"),
|
||
("ROA", "roa"),
|
||
("Total Debt", "total_debt"),
|
||
("Total Equity", "_equity_fmt"),
|
||
("Total Cash", "total_cash"),
|
||
("Current Ratio", "current_ratio"),
|
||
("Profit Margin", "_profit_margin_fmt"),
|
||
("Operating Margin", "_op_margin_fmt"),
|
||
("FCF Yield", "fcf_yield"),
|
||
("Dividend Yield", "dividend_yield"),
|
||
]
|
||
_ANLST_FIELDS = [
|
||
("Recommendation", "recommendation"),
|
||
("# Analysts", "analyst_count"),
|
||
("Price Target", "analyst_target"),
|
||
("Upside to Target", "analyst_upside"),
|
||
("News Sentiment", "news_sentiment"),
|
||
("Short Interest", "short_percent"),
|
||
("Beta", "beta"),
|
||
("30D Volatility", "volatility_30d"),
|
||
]
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Progress regex
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_PROGRESS_RE = re.compile(r"(\d+)/(\d+).*?ok=(\d+).*?skip=(\d+).*?ETA=(\d+(?:\.\d+)?)")
|
||
_PHASE1_RE = re.compile(r"(\d+)/(\d+)\s+scanned\s+valid=(\d+)")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Worker signals
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _WorkerSignals(QObject):
|
||
progress = Signal(int, int, int, int, float)
|
||
status = Signal(str)
|
||
done = Signal(int)
|
||
error = Signal(str)
|
||
stopped = Signal()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stdout redirector
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _StdoutRedirector(io.TextIOBase):
|
||
def __init__(self, signals: _WorkerSignals):
|
||
self._signals = signals
|
||
|
||
def write(self, s: str) -> int:
|
||
if not s or not s.strip():
|
||
return len(s) if s else 0
|
||
m = _PROGRESS_RE.search(s)
|
||
if m:
|
||
done, total, ok, skip, eta = m.groups()
|
||
self._signals.progress.emit(int(done), int(total), int(ok), int(skip), float(eta))
|
||
else:
|
||
m2 = _PHASE1_RE.search(s)
|
||
if m2:
|
||
done, total, ok = m2.groups()
|
||
self._signals.progress.emit(int(done), int(total), int(ok), 0, 0.0)
|
||
else:
|
||
self._signals.status.emit(s.strip())
|
||
return len(s)
|
||
|
||
def flush(self): pass
|
||
|
||
"""Part 2: ScoreBar, Sidebar, StockTableModel, StockTable, MetricCell, MetricsGrid"""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ScoreBar
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ScoreBar(QWidget):
|
||
_value_changed = Signal(float)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setFixedSize(220, 18)
|
||
self._val = 0.0
|
||
self._anim = None
|
||
|
||
def get_value(self): return self._val
|
||
def set_value(self, v):
|
||
self._val = float(v)
|
||
self.update()
|
||
value = Property(float, get_value, set_value)
|
||
|
||
def paintEvent(self, e):
|
||
from PySide6.QtGui import QPainter, QColor, QPainterPath
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.Antialiasing)
|
||
w, h = self.width(), self.height()
|
||
r = h / 2
|
||
# Track
|
||
track = QPainterPath()
|
||
track.addRoundedRect(0, 0, w, h, r, r)
|
||
p.fillPath(track, QColor("#222222"))
|
||
# Fill
|
||
if self._val > 0:
|
||
fill_w = max(h, int(w * self._val / 100.0))
|
||
fill_w = min(fill_w, w)
|
||
fill = QPainterPath()
|
||
fill.addRoundedRect(0, 0, fill_w, h, r, r)
|
||
p.fillPath(fill, QColor("#ffffff"))
|
||
p.end()
|
||
|
||
def animate_to(self, v):
|
||
if self._anim:
|
||
self._anim.stop()
|
||
self._anim = QPropertyAnimation(self, b"value", self)
|
||
self._anim.setDuration(500)
|
||
self._anim.setStartValue(0.0)
|
||
self._anim.setEndValue(float(max(0, min(100, v))))
|
||
self._anim.setEasingCurve(QEasingCurve.OutCubic)
|
||
self._anim.start()
|
||
|
||
def reset(self):
|
||
if self._anim:
|
||
self._anim.stop()
|
||
self.set_value(0.0)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Sidebar
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class Sidebar(QWidget):
|
||
page_changed = Signal(int)
|
||
|
||
_NAV = [("⊞", "Screener"), ("⌕", "Search"), ("↗", "Tracking"), ("⚙", "Settings")]
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setFixedWidth(72)
|
||
self._active = 0
|
||
self._btns = []
|
||
self._build()
|
||
|
||
def _build(self):
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
# Logo
|
||
logo = QLabel("V")
|
||
logo.setFixedSize(36, 36)
|
||
logo.setAlignment(Qt.AlignCenter)
|
||
logo.setStyleSheet(
|
||
"background:#ffffff; color:#000000; border-radius:8px;"
|
||
"font-size:18px; font-weight:700;"
|
||
)
|
||
logo_wrap = QWidget()
|
||
logo_wrap.setFixedHeight(64)
|
||
lw_lay = QHBoxLayout(logo_wrap)
|
||
lw_lay.setContentsMargins(0, 0, 0, 0)
|
||
lw_lay.addStretch()
|
||
lw_lay.addWidget(logo)
|
||
lw_lay.addStretch()
|
||
lay.addWidget(logo_wrap)
|
||
|
||
for i, (icon, label) in enumerate(self._NAV):
|
||
btn = _SidebarButton(icon, label, i, self)
|
||
btn.clicked_idx.connect(self._on_nav)
|
||
self._btns.append(btn)
|
||
lay.addWidget(btn)
|
||
|
||
lay.addStretch()
|
||
self.setStyleSheet(f"background:#111111; border-right:1px solid #222222;")
|
||
self._btns[0].set_active(True)
|
||
|
||
def _on_nav(self, idx):
|
||
if idx == 3: # Settings — no page
|
||
return
|
||
for i, btn in enumerate(self._btns):
|
||
btn.set_active(i == idx)
|
||
self._active = idx
|
||
self.page_changed.emit(idx)
|
||
|
||
|
||
class _SidebarButton(QWidget):
|
||
clicked_idx = Signal(int)
|
||
|
||
def __init__(self, icon, label, idx, parent=None):
|
||
super().__init__(parent)
|
||
self._idx = idx
|
||
self._active = False
|
||
self.setFixedSize(72, 64)
|
||
self.setCursor(Qt.PointingHandCursor)
|
||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||
self.setStyleSheet("background:#111111;")
|
||
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 10, 0, 10)
|
||
lay.setSpacing(4)
|
||
lay.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||
|
||
self._icon_lbl = QLabel(icon)
|
||
self._icon_lbl.setAlignment(Qt.AlignCenter)
|
||
self._icon_lbl.setFixedHeight(22)
|
||
self._icon_lbl.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||
|
||
self._text_lbl = QLabel(label)
|
||
self._text_lbl.setAlignment(Qt.AlignCenter)
|
||
self._text_lbl.setFixedHeight(14)
|
||
self._text_lbl.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||
|
||
lay.addWidget(self._icon_lbl)
|
||
lay.addWidget(self._text_lbl)
|
||
self._set_colors(False)
|
||
|
||
def _set_colors(self, active):
|
||
c = "#ffffff" if active else "#3a3a3a"
|
||
self._icon_lbl.setStyleSheet(
|
||
f"background:transparent; font-size:17px; color:{c};")
|
||
self._text_lbl.setStyleSheet(
|
||
f"background:transparent; font-size:9px; font-weight:500; color:{c};")
|
||
|
||
def set_active(self, active):
|
||
self._active = active
|
||
self._set_colors(active)
|
||
|
||
def mousePressEvent(self, e):
|
||
self.clicked_idx.emit(self._idx)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stock Table
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class StockTableModel(QAbstractTableModel):
|
||
_HEADERS = ["#", "Ticker", "Name", "Score", "Sector"]
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._rows = []
|
||
|
||
def set_data(self, rows):
|
||
self.beginResetModel()
|
||
self._rows = rows
|
||
self.endResetModel()
|
||
|
||
def rowCount(self, parent=QModelIndex()): return len(self._rows)
|
||
def columnCount(self, parent=QModelIndex()): return 5
|
||
|
||
def headerData(self, section, orientation, role=Qt.DisplayRole):
|
||
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
|
||
return self._HEADERS[section]
|
||
return None
|
||
|
||
def data(self, index, role=Qt.DisplayRole):
|
||
if not index.isValid(): return None
|
||
row = self._rows[index.row()]
|
||
col = index.column()
|
||
if role == Qt.DisplayRole:
|
||
if col == 0: return str(row[0])
|
||
if col == 1: return str(row[1])
|
||
if col == 2: return str(row[2])[:30]
|
||
if col == 3: return str(row[3])
|
||
if col == 4: return str(row[4])[:22]
|
||
if role == Qt.TextAlignmentRole:
|
||
if col in (0, 1, 3): return Qt.AlignCenter | Qt.AlignVCenter
|
||
return Qt.AlignLeft | Qt.AlignVCenter
|
||
if role == Qt.BackgroundRole:
|
||
c = "#181818" if index.row() % 2 else "#0a0a0a"
|
||
return QColor(c)
|
||
if role == Qt.ForegroundRole:
|
||
return QColor("#ffffff")
|
||
return None
|
||
|
||
def get_row_data(self, row_idx):
|
||
if 0 <= row_idx < len(self._rows):
|
||
return self._rows[row_idx]
|
||
return None
|
||
|
||
|
||
class StockTable(QTableView):
|
||
stock_selected = Signal(dict)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._model = StockTableModel()
|
||
self.setModel(self._model)
|
||
self.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||
self.setSelectionMode(QAbstractItemView.SingleSelection)
|
||
self.horizontalHeader().setHighlightSections(False)
|
||
self.verticalHeader().setVisible(False)
|
||
self.setShowGrid(False)
|
||
self.setSortingEnabled(False)
|
||
self.setAlternatingRowColors(False)
|
||
self.verticalHeader().setDefaultSectionSize(30)
|
||
|
||
hh = self.horizontalHeader()
|
||
hh.setSectionResizeMode(0, QHeaderView.Fixed)
|
||
hh.setSectionResizeMode(1, QHeaderView.Fixed)
|
||
hh.setSectionResizeMode(2, QHeaderView.Stretch)
|
||
hh.setSectionResizeMode(3, QHeaderView.Fixed)
|
||
hh.setSectionResizeMode(4, QHeaderView.Fixed)
|
||
self.setColumnWidth(0, 40)
|
||
self.setColumnWidth(1, 70)
|
||
self.setColumnWidth(3, 60)
|
||
self.setColumnWidth(4, 130)
|
||
|
||
self._df_ref = None
|
||
self.clicked.connect(self._on_click)
|
||
|
||
def populate(self, df):
|
||
self._df_ref = df
|
||
rows = []
|
||
for i, (_, row) in enumerate(df.iterrows()):
|
||
rows.append((
|
||
i + 1,
|
||
row.get("ticker", ""),
|
||
str(row.get("name", "")),
|
||
_score(row.get("composite_score")),
|
||
str(row.get("sector", "")),
|
||
))
|
||
self._model.set_data(rows)
|
||
|
||
def _on_click(self, index):
|
||
if self._df_ref is None: return
|
||
row_idx = index.row()
|
||
row_data = self._model.get_row_data(row_idx)
|
||
if row_data is None: return
|
||
ticker = row_data[1]
|
||
match = self._df_ref[self._df_ref["ticker"] == ticker]
|
||
if not match.empty:
|
||
self.stock_selected.emit(match.iloc[0].to_dict())
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MetricCell
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class MetricCell(QFrame):
|
||
def __init__(self, label, key, tooltip_text="", parent=None):
|
||
super().__init__(parent)
|
||
self._key = key
|
||
self.setStyleSheet(
|
||
f"QFrame {{ background:{_C['bg2']}; border-radius:6px; border:1px solid {_C['border']}; }}"
|
||
)
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(12, 10, 12, 10)
|
||
lay.setSpacing(4)
|
||
|
||
lbl = QLabel(label.upper())
|
||
lbl.setStyleSheet(f"color:{_C['fg3']}; font-size:10px; font-weight:600; border:none;")
|
||
lay.addWidget(lbl)
|
||
|
||
self.value_lbl = QLabel("—")
|
||
self.value_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:15px; font-weight:700; border:none;")
|
||
lay.addWidget(self.value_lbl)
|
||
|
||
if tooltip_text:
|
||
self.setToolTip(tooltip_text)
|
||
|
||
def set_value(self, text, color=None):
|
||
self.value_lbl.setText(text)
|
||
c = color if color else _C["fg"]
|
||
self.value_lbl.setStyleSheet(f"color:{c}; font-size:15px; font-weight:700; border:none;")
|
||
|
||
|
||
class MetricsGrid(QWidget):
|
||
def __init__(self, fields, cols=2, parent=None):
|
||
super().__init__(parent)
|
||
self.cells = {}
|
||
lay = QGridLayout(self)
|
||
lay.setContentsMargins(8, 8, 8, 8)
|
||
lay.setSpacing(6)
|
||
for i, (label, key) in enumerate(fields):
|
||
tip = _METRIC_TOOLTIPS.get(label, "")
|
||
cell = MetricCell(label, key, tip)
|
||
self.cells[key] = cell
|
||
lay.addWidget(cell, i // cols, i % cols)
|
||
|
||
"""Part 3: ScoreTab, CommentaryWidget (with full commentary logic), ChartWidget"""
|
||
import html as _html
|
||
import re
|
||
import threading
|
||
import webbrowser
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ScoreTab
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ScoreTab(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.bars = {}
|
||
self.value_labels = {}
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.NoFrame)
|
||
inner = QWidget()
|
||
lay = QVBoxLayout(inner)
|
||
lay.setContentsMargins(12, 12, 12, 12)
|
||
lay.setSpacing(0)
|
||
|
||
for label, key in _SCORE_FIELDS:
|
||
row_w = QWidget()
|
||
row_w.setFixedHeight(40)
|
||
rl = QHBoxLayout(row_w)
|
||
rl.setContentsMargins(4, 0, 4, 0)
|
||
rl.setSpacing(8)
|
||
|
||
tip = _METRIC_TOOLTIPS.get(label, "")
|
||
lbl = QLabel(label)
|
||
lbl.setFixedWidth(140)
|
||
lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;")
|
||
if tip:
|
||
lbl.setToolTip(tip)
|
||
rl.addWidget(lbl)
|
||
|
||
bar = ScoreBar()
|
||
self.bars[key] = bar
|
||
rl.addWidget(bar)
|
||
|
||
val_lbl = QLabel("—")
|
||
val_lbl.setFixedWidth(55)
|
||
val_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:12px; font-weight:600;")
|
||
val_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||
self.value_labels[key] = val_lbl
|
||
rl.addWidget(val_lbl)
|
||
rl.addStretch()
|
||
lay.addWidget(row_w)
|
||
|
||
sep = QFrame()
|
||
sep.setFixedHeight(1)
|
||
sep.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep)
|
||
|
||
lay.addStretch()
|
||
scroll.setWidget(inner)
|
||
outer = QVBoxLayout(self)
|
||
outer.setContentsMargins(0, 0, 0, 0)
|
||
outer.addWidget(scroll)
|
||
|
||
def update_scores(self, row):
|
||
for label, key in _SCORE_FIELDS:
|
||
v = row.get(key)
|
||
bar = self.bars[key]
|
||
val_lbl = self.value_labels[key]
|
||
if _nan(v):
|
||
bar.reset()
|
||
val_lbl.setText("—")
|
||
else:
|
||
bar.animate_to(float(v))
|
||
val_lbl.setText(f"{float(v):.1f}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Commentary builder (full logic from original _update_commentary)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_commentary_html(row):
|
||
parts = []
|
||
|
||
def h(text, color=None):
|
||
c = color or _C["fg"]
|
||
parts.append(f'<span style="color:{c};">{text}</span>')
|
||
|
||
def hl(text, color=None):
|
||
h(text, color)
|
||
parts.append("<br>")
|
||
|
||
def heading(text):
|
||
hr = f'<hr style="border:1px solid {_C["border"]}; margin:6px 0;">'
|
||
parts.append(f'{hr}<b><span style="color:{_C["fg"]};">{text}</span></b><br>')
|
||
|
||
ticker = row.get("ticker", "?")
|
||
name = str(row.get("name", ticker))
|
||
sector = str(row.get("sector", "N/A"))
|
||
score = row.get("composite_score")
|
||
|
||
def _one_para(text, max_chars=480):
|
||
text = (text or "").strip()
|
||
if not text: return ""
|
||
para = text.split("\n")[0].strip()
|
||
if len(para) <= max_chars: return para
|
||
snippet = para[:max_chars]
|
||
last_dot = snippet.rfind(". ")
|
||
return snippet[:last_dot + 1] if last_dot > max_chars // 3 else snippet.rstrip() + "…"
|
||
|
||
desc = _one_para(str(row.get("business_summary", "") or ""))
|
||
|
||
heading("SUMMARY")
|
||
if not _nan(score):
|
||
if score >= 72: tier, tier_c = "Strong Buy candidate", _C["positive"]
|
||
elif score >= 62: tier, tier_c = "Buy / above-average opportunity", _C["positive"]
|
||
elif score >= 52: tier, tier_c = "Neutral — Hold / Watch", _C["warning"]
|
||
elif score >= 42: tier, tier_c = "Underperform — caution advised", _C["negative"]
|
||
else: tier, tier_c = "Avoid — significant headwinds", _C["negative"]
|
||
parts.append(f'<br><b><span style="color:{_C["fg"]};">{name} ({ticker})</span></b><br>')
|
||
parts.append(f'<span style="color:{_C["fg2"]};">Sector: {sector} Composite Score: {score:.1f} / 100</span><br>')
|
||
parts.append(f'Verdict: <b><span style="color:{tier_c};">{tier}</span></b><br><br>')
|
||
else:
|
||
parts.append(f'<br><b><span style="color:{_C["fg"]};">{name} ({ticker}) — {sector}</span></b><br>')
|
||
hl("Insufficient data for full evaluation.", _C["warning"])
|
||
parts.append("<br>")
|
||
|
||
if desc:
|
||
parts.append(f'<span style="color:{_C["fg2"]}; font-size:11px;">{_html.escape(desc)}</span><br><br>')
|
||
|
||
heading("INVESTMENT THESIS")
|
||
pe = row.get("pe_forward") or row.get("pe_trailing")
|
||
rev_g = row.get("revenue_growth")
|
||
earn_g = row.get("earnings_growth")
|
||
roe = row.get("roe")
|
||
pm = row.get("profit_margin")
|
||
upside = row.get("analyst_upside")
|
||
rec = str(row.get("recommendation", ""))
|
||
ns = row.get("news_sentiment")
|
||
r3 = row.get("ret_3m")
|
||
r6 = row.get("ret_6m")
|
||
sv = row.get("score_value", 50)
|
||
sg = row.get("score_growth", 50)
|
||
sm = row.get("score_momentum", 50)
|
||
sq = row.get("score_quality", 50)
|
||
sp = row.get("score_profitability", 50)
|
||
ss = row.get("score_sentiment", 50)
|
||
sa = row.get("score_analyst", 50)
|
||
sr = row.get("score_risk", 50)
|
||
|
||
strengths = []
|
||
if not _nan(sv) and sv >= 62:
|
||
pe_str = f" (P/E: {pe:.1f}x)" if not _nan(pe) and pe > 0 else ""
|
||
strengths.append(f"Valuation: Trading at an attractive multiple relative to peers{pe_str}.")
|
||
if not _nan(sg) and sg >= 62:
|
||
g_parts = []
|
||
if not _nan(rev_g) and rev_g > 0.05: g_parts.append(f"revenue +{rev_g*100:.1f}%")
|
||
if not _nan(earn_g) and earn_g > 0.05: g_parts.append(f"earnings +{earn_g*100:.1f}%")
|
||
g_str = " (" + ", ".join(g_parts) + ")" if g_parts else ""
|
||
strengths.append(f"Growth: Strong fundamental growth trajectory{g_str}.")
|
||
if not _nan(sm) and sm >= 62:
|
||
m_parts = []
|
||
if not _nan(r3): m_parts.append(f"3M: {r3*100:+.1f}%")
|
||
if not _nan(r6): m_parts.append(f"6M: {r6*100:+.1f}%")
|
||
m_str = " (" + ", ".join(m_parts) + ")" if m_parts else ""
|
||
strengths.append(f"Momentum: Positive price trend relative to universe{m_str}.")
|
||
if not _nan(sq) and sq >= 62:
|
||
r_str = f" ROE: {roe*100:.1f}%." if not _nan(roe) else ""
|
||
strengths.append(f"Quality: Strong balance sheet and capital efficiency.{r_str}")
|
||
if not _nan(sp) and sp >= 62:
|
||
p_str = f" Net margin: {pm*100:.1f}%." if not _nan(pm) else ""
|
||
strengths.append(f"Profitability: High-quality earnings with strong margins.{p_str}")
|
||
if not _nan(ss) and ss >= 62:
|
||
s_str = f" (score: {ns:+.3f})" if not _nan(ns) else ""
|
||
strengths.append(f"Sentiment: Recent news coverage is broadly positive{s_str}.")
|
||
if not _nan(sa) and sa >= 62:
|
||
a_parts = []
|
||
if rec and rec not in ("nan", "N/A"): a_parts.append(rec.replace("_", " ").title())
|
||
if not _nan(upside) and upside > 0: a_parts.append(f"{upside*100:.1f}% upside to consensus target")
|
||
a_str = " — " + ", ".join(a_parts) if a_parts else ""
|
||
strengths.append(f"Analyst Consensus: Bullish institutional view{a_str}.")
|
||
if not _nan(sr) and sr >= 62:
|
||
strengths.append("Risk Profile: Low short interest and stable volatility — favourable risk/reward.")
|
||
|
||
parts.append("<br>")
|
||
if strengths:
|
||
for txt in strengths:
|
||
parts.append(f'<span style="color:{_C["positive"]};"> ✔ {_html.escape(txt)}</span><br>')
|
||
else:
|
||
parts.append(f'<span style="color:{_C["warning"]};"> No strong positive catalysts identified at current levels.</span><br>')
|
||
parts.append("<br>")
|
||
|
||
heading("RISK FACTORS")
|
||
short_pct = row.get("short_percent")
|
||
beta = row.get("beta")
|
||
vol = row.get("volatility_30d")
|
||
debt_eq = row.get("debt_to_equity")
|
||
risks = []
|
||
if not _nan(sv) and sv <= 38:
|
||
pe_str = f" (P/E: {pe:.1f}x)" if not _nan(pe) and pe > 0 else ""
|
||
risks.append(f"Valuation Risk: Elevated multiples relative to peers{pe_str}. Leaves little margin for error.")
|
||
if not _nan(sg) and sg <= 38:
|
||
risks.append("Growth Risk: Weak or decelerating growth may disappoint investors.")
|
||
if not _nan(sm) and sm <= 38:
|
||
m_parts = []
|
||
if not _nan(r3): m_parts.append(f"3M: {r3*100:+.1f}%")
|
||
if not _nan(r6): m_parts.append(f"6M: {r6*100:+.1f}%")
|
||
m_str = " (" + ", ".join(m_parts) + ")" if m_parts else ""
|
||
risks.append(f"Momentum Risk: Negative price trend{m_str}. Downtrend may continue.")
|
||
if not _nan(sq) and sq <= 38:
|
||
risks.append("Balance Sheet Risk: High leverage or weak liquidity could amplify downside.")
|
||
if not _nan(ss) and ss <= 38:
|
||
s_str = f" (score: {ns:+.3f})" if not _nan(ns) else ""
|
||
risks.append(f"Sentiment Risk: Negative news flow may weigh on near-term price action{s_str}.")
|
||
if not _nan(sa) and sa <= 38:
|
||
r_str = rec.replace("_", " ").title() if rec and rec not in ("nan", "N/A") else ""
|
||
u_str = f" | {upside*100:.1f}% downside to target" if not _nan(upside) and upside < 0 else ""
|
||
risks.append(f"Analyst Risk: Bearish institutional view. {r_str}{u_str}")
|
||
if not _nan(short_pct) and short_pct > 0.08:
|
||
lvl = "Very high" if short_pct > 0.20 else "Elevated"
|
||
risks.append(f"Short Interest: {lvl} at {short_pct*100:.1f}% of float — signals institutional bearish conviction.")
|
||
if not _nan(beta):
|
||
if beta > 1.8:
|
||
risks.append(f"Volatility Risk: High beta ({beta:.2f}x) significantly amplifies both market upside and downside moves.")
|
||
elif beta < 0:
|
||
risks.append(f"Unusual Beta: Negative beta ({beta:.2f}) — moves inversely to the market.")
|
||
if not _nan(vol) and vol > 0.40:
|
||
risks.append(f"Volatility Risk: 30-day realised volatility of {vol*100:.1f}% (annualised) is significantly above average.")
|
||
if not _nan(debt_eq) and debt_eq > 200:
|
||
risks.append(f"Leverage Risk: Debt/equity ratio of {debt_eq:.0f}% indicates heavy debt load.")
|
||
|
||
parts.append("<br>")
|
||
if risks:
|
||
for txt in risks:
|
||
parts.append(f'<span style="color:{_C["negative"]};"> ✘ {_html.escape(txt)}</span><br>')
|
||
else:
|
||
parts.append(f'<span style="color:{_C["positive"]};"> No major risk flags identified at this time.</span><br>')
|
||
parts.append("<br>")
|
||
|
||
heading("ADDITIONAL CONTEXT")
|
||
div = row.get("dividend_yield")
|
||
curr = row.get("current_ratio")
|
||
fcf = row.get("fcf_yield")
|
||
price = row.get("price")
|
||
hi52 = row.get("52w_high")
|
||
lo52 = row.get("52w_low")
|
||
parts.append("<br>")
|
||
if not _nan(div) and div > 0:
|
||
parts.append(f'<span style="color:{_C["positive"]};"> • Dividend yield of {div*100:.2f}% provides income support.</span><br>')
|
||
if not _nan(curr) and curr >= 2.0:
|
||
parts.append(f'<span style="color:{_C["positive"]};"> • Current ratio of {curr:.1f}x indicates strong short-term liquidity.</span><br>')
|
||
elif not _nan(curr) and curr < 1.0:
|
||
parts.append(f'<span style="color:{_C["negative"]};"> • Current ratio of {curr:.1f}x raises near-term liquidity concerns.</span><br>')
|
||
if not _nan(fcf) and fcf > 0.03:
|
||
parts.append(f'<span style="color:{_C["positive"]};"> • FCF yield of {fcf*100:.1f}% suggests meaningful free cash flow generation.</span><br>')
|
||
if not _nan(price) and not _nan(hi52) and not _nan(lo52) and hi52 > lo52:
|
||
pct_from_high = (price - hi52) / hi52
|
||
pct_from_low = (price - lo52) / lo52
|
||
parts.append(f'<span style="color:{_C["fg"]};"> • Current price is {abs(pct_from_high)*100:.1f}% below 52-week high and {pct_from_low*100:.1f}% above 52-week low.</span><br>')
|
||
if not _nan(beta) and 0.7 <= beta <= 1.3:
|
||
parts.append(f'<span style="color:{_C["fg"]};"> • Beta of {beta:.2f} closely tracks the broader market — suitable for balanced portfolios.</span><br>')
|
||
parts.append("<br>")
|
||
|
||
# News headlines
|
||
headlines = row.get("news_headlines") or []
|
||
if headlines:
|
||
heading("NEWS HIGHLIGHTS")
|
||
parts.append("<br>")
|
||
|
||
def _classify(title):
|
||
t = title.lower()
|
||
if any(w in t for w in ["earn","profit","revenue","eps","beat","miss","quarter","guidance","forecast","sales","income","margin"]): return "earnings"
|
||
if any(w in t for w in ["acqui","merger","deal","buyout","takeover","bid","acquire"]): return "ma"
|
||
if any(w in t for w in ["partner","contract","agreement","launch","expand","open","win"]): return "growth"
|
||
if any(w in t for w in ["upgrade","downgrade","target","initiat","rating","analyst","overweight","underweight"]): return "analyst"
|
||
if any(w in t for w in ["lawsuit","investig","sec","fine","penalty","fraud","violation","probe","regulat","recall"]): return "legal"
|
||
if any(w in t for w in ["layoff","restructur","closure","shut down","dismissal","job cut"]): return "restructuring"
|
||
if any(w in t for w in ["ceo","cfo","coo","chief","executive","appoint","resign","depart","hire","leadership"]): return "leadership"
|
||
if any(w in t for w in ["dividend","buyback","repurchas","split","special pay"]): return "shareholder"
|
||
if any(w in t for w in ["patent","innovat","product","drug","trial","fda","approv","launch","technolog"," ai "]): return "product"
|
||
return "general"
|
||
|
||
_IMPL = {
|
||
("earnings",True): "Strong financials can support multiple expansion and analyst upgrades.",
|
||
("earnings",False): "Weak results or missed guidance may trigger near-term selling pressure.",
|
||
("ma",True): "M&A activity can unlock value — watch for deal certainty and synergy capture.",
|
||
("ma",False): "Deal uncertainty or high premiums may weigh on near-term returns.",
|
||
("growth",True): "Expansion signals may support long-term revenue growth and market share gains.",
|
||
("growth",False): "Setbacks in growth plans could delay targets and disappoint investors.",
|
||
("analyst",True): "Analyst upgrades and raised price targets often attract institutional buying.",
|
||
("analyst",False): "Downgrades or reduced targets may reduce institutional demand.",
|
||
("legal",True): "Positive legal resolution removes an investor overhang.",
|
||
("legal",False): "Regulatory or legal headwinds can create lasting uncertainty and valuation pressure.",
|
||
("restructuring",True): "Cost reduction efforts can improve margins and free cash flow over time.",
|
||
("restructuring",False): "Restructuring may signal operational challenges or weakening demand.",
|
||
("leadership",True): "New leadership can bring strategic clarity and renewed investor confidence.",
|
||
("leadership",False): "Executive departures may signal internal issues or strategic uncertainty.",
|
||
("shareholder",True): "Capital returns signal management confidence and can attract income investors.",
|
||
("shareholder",False): "Dividend cuts or suspension may indicate cash flow stress.",
|
||
("product",True): "New products or approvals can open significant revenue opportunities.",
|
||
("product",False): "Product setbacks or failures may materially impact future revenue projections.",
|
||
("general",True): "Positive market narrative may support near-term investor sentiment.",
|
||
("general",False): "Negative coverage can weigh on investor sentiment and short-term price action.",
|
||
}
|
||
|
||
def _trim_summary(text, title="", max_sentences=2):
|
||
text = _html.unescape(text or "")
|
||
text = re.sub(r'<[^>]+>', ' ', text)
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
if not text: return ""
|
||
if title:
|
||
norm_title = re.sub(r'\W+', ' ', title.lower()).strip()
|
||
norm_text = re.sub(r'\W+', ' ', text.lower()).strip()
|
||
if norm_text.startswith(norm_title[:50]) or norm_title[:50] in norm_text[:len(norm_title)+20]:
|
||
return ""
|
||
pts = re.split(r'(?<=[.!?])\s+', text)
|
||
trimmed = " ".join(pts[:max_sentences]).strip()
|
||
if trimmed and trimmed[-1] not in ".!?": trimmed += "."
|
||
return trimmed
|
||
|
||
for idx, item in enumerate(headlines):
|
||
if len(item) == 5:
|
||
title, h_score, age_days, url, summary = item
|
||
else:
|
||
title, h_score, age_days = item
|
||
url, summary = "", ""
|
||
|
||
category = _classify(title)
|
||
if h_score >= 0.05:
|
||
h_color, h_label = _C["positive"], "Positive"
|
||
implication = _IMPL.get((category, True), _IMPL[("general", True)])
|
||
elif h_score <= -0.05:
|
||
h_color, h_label = _C["negative"], "Negative"
|
||
implication = _IMPL.get((category, False), _IMPL[("general", False)])
|
||
else:
|
||
h_color, h_label = _C["warning"], "Neutral"
|
||
implication = "Neutral coverage — monitor for shifts in tone as the story develops."
|
||
|
||
age_hours = age_days * 24
|
||
age_mins = age_days * 1440
|
||
if age_mins < 1: age_str = "Just now"
|
||
elif age_hours < 1: age_str = f"{int(age_mins)}m ago"
|
||
elif age_hours < 24: age_str = f"{int(age_hours)}h ago"
|
||
elif age_days < 2: age_str = "Yesterday"
|
||
else: age_str = f"{int(age_days)}d ago"
|
||
|
||
short_summary = _trim_summary(summary, title=title, max_sentences=2)
|
||
recap = f"{short_summary} {implication}" if short_summary else implication
|
||
|
||
parts.append(f'<span style="color:{h_color}; font-weight:600;"> [{h_label}] </span>')
|
||
if url:
|
||
parts.append(f'<a href="{url}" style="color:{_C["fg"]};">{_html.escape(title)}</a><br>')
|
||
else:
|
||
parts.append(f'<span style="color:{_C["fg"]};">{_html.escape(title)}</span><br>')
|
||
parts.append(f'<span style="color:{_C["fg2"]}; font-size:11px;"> {age_str} · {_html.escape(recap)}</span><br><br>')
|
||
|
||
parts.append("<br>")
|
||
|
||
heading("RECOMMENDATION")
|
||
n_strengths = len(strengths)
|
||
n_risks = len(risks)
|
||
hi_mom = not _nan(sm) and sm >= 65
|
||
hi_val = not _nan(sv) and sv >= 65
|
||
hi_qual = not _nan(sq) and sq >= 65
|
||
hi_growth = not _nan(sg) and sg >= 65
|
||
hi_short = not _nan(row.get("short_percent")) and (row.get("short_percent") or 0) > 0.15
|
||
hi_vol = not _nan(vol) and (vol or 0) > 0.50
|
||
parts.append("<br>")
|
||
|
||
if not _nan(score):
|
||
if score >= 72 and n_strengths >= 3 and n_risks == 0:
|
||
rec_label, rec_c = "STRONG BUY", _C["positive"]
|
||
if hi_val and hi_qual: timeframe, tf_reason = "12–36 months (fundamental thesis)", "Strong valuation discount and balance sheet quality support a longer holding period."
|
||
elif hi_mom: timeframe, tf_reason = "3–12 months (momentum-driven)", "Price trend is strong — re-evaluate if momentum fades."
|
||
else: timeframe, tf_reason = "6–18 months", "Multiple factors aligned. Monitor quarterly earnings for confirmation."
|
||
elif score >= 62 and n_strengths >= 2:
|
||
rec_label, rec_c = "BUY", _C["positive"]
|
||
if hi_val and not hi_mom: timeframe, tf_reason = "12–24 months (value unlock)", "Undervalued on fundamentals — patience required for market to re-rate."
|
||
elif hi_mom and not hi_val: timeframe, tf_reason = "1–6 months (trend play)", "Momentum-driven opportunity. Set a stop-loss and monitor price action."
|
||
elif hi_growth: timeframe, tf_reason = "6–18 months (growth compounding)", "Strong growth trajectory. Suitable while earnings acceleration continues."
|
||
else: timeframe, tf_reason = "6–12 months", "Favorable setup. Review if fundamentals or sentiment deteriorate."
|
||
elif score >= 52:
|
||
rec_label, rec_c = "HOLD / WATCH", _C["warning"]
|
||
timeframe, tf_reason = "Reassess in 1–3 months", "Mixed signals. No compelling entry at current levels — monitor for improvement."
|
||
elif score >= 42:
|
||
rec_label, rec_c = "UNDERPERFORM", _C["negative"]
|
||
timeframe, tf_reason = "Avoid new positions", "Below-average score with notable risk factors. Wait for a clearer signal."
|
||
else:
|
||
rec_label, rec_c = "AVOID", _C["negative"]
|
||
timeframe, tf_reason = "Do not initiate", "Significant headwinds across multiple factors. Risk outweighs reward."
|
||
|
||
if hi_short:
|
||
rec_label = f"{rec_label} — CAUTION (High Short Interest)"
|
||
tf_reason += " High short interest warns of institutional bearish conviction."
|
||
if hi_vol:
|
||
tf_reason += " Elevated volatility increases risk; size positions accordingly."
|
||
|
||
parts.append(f' Verdict: <b><span style="color:{rec_c};">{rec_label}</span></b><br>')
|
||
parts.append(f'<span style="color:{_C["fg"]};"> Timeframe: {timeframe}</span><br><br>')
|
||
parts.append(f'<span style="color:{_C["warning"]};"> Reasoning: {_html.escape(tf_reason)}</span><br><br>')
|
||
|
||
parts.append(f'<span style="color:{_C["fg"]};"> Key metrics driving this view:</span><br>')
|
||
if not _nan(sv):
|
||
extra = f" — P/E {pe:.1f}x" if not _nan(pe) and pe > 0 else ""
|
||
parts.append(f'<span style="color:{_C["fg"]};"> • Value score {sv:.0f}/100{extra}</span><br>')
|
||
if not _nan(sg):
|
||
extra = f" — Rev growth {rev_g*100:+.1f}%" if not _nan(rev_g) else ""
|
||
parts.append(f'<span style="color:{_C["fg"]};"> • Growth score {sg:.0f}/100{extra}</span><br>')
|
||
if not _nan(sm):
|
||
extra = f" — 6M return {r6*100:+.1f}%" if not _nan(r6) else ""
|
||
parts.append(f'<span style="color:{_C["fg"]};"> • Momentum score {sm:.0f}/100{extra}</span><br>')
|
||
if not _nan(sa):
|
||
extra = f" — {upside*100:.1f}% upside to target" if not _nan(upside) and upside > 0 else ""
|
||
parts.append(f'<span style="color:{_C["fg"]};"> • Analyst score {sa:.0f}/100{extra}</span><br>')
|
||
else:
|
||
parts.append(f'<span style="color:{_C["warning"]};"> Insufficient data to generate a recommendation.</span><br>')
|
||
|
||
parts.append("<br>")
|
||
parts.append(f'<hr style="border:1px solid {_C["border"]}; margin:6px 0;">')
|
||
parts.append(
|
||
f'<span style="color:{_C["fg2"]}; font-size:10px;">'
|
||
"This commentary is generated algorithmically from public market data. "
|
||
"It is for informational purposes only and does not constitute financial advice. "
|
||
"Always conduct your own due diligence before investing."
|
||
"</span>"
|
||
)
|
||
|
||
return '<html><body style="font-family:Consolas,monospace; font-size:12px; color:#ffffff; margin:10px;">' + "".join(parts) + "</body></html>"
|
||
|
||
|
||
class CommentaryWidget(QTextBrowser):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setReadOnly(True)
|
||
self.setOpenLinks(False)
|
||
self.anchorClicked.connect(lambda url: webbrowser.open(url.toString()))
|
||
self._row = None
|
||
|
||
def update_commentary(self, row):
|
||
self._row = row
|
||
headlines = row.get("news_headlines") or []
|
||
if not headlines and not row.get("_news_fetched"):
|
||
self.setHtml(_build_commentary_html(row))
|
||
ticker = row.get("ticker", "")
|
||
name = row.get("name", "")
|
||
if ticker:
|
||
def _fetch(_r=row, _t=ticker, _n=name):
|
||
try:
|
||
import yfinance as _yf
|
||
_, _hl = get_news_headlines(_yf.Ticker(_t), ticker=_t, company_name=_n)
|
||
_r["news_headlines"] = _hl
|
||
except Exception:
|
||
_r["news_headlines"] = None
|
||
_r["_news_fetched"] = True
|
||
QMetaObject.invokeMethod(self, "_refresh_commentary", Qt.QueuedConnection)
|
||
threading.Thread(target=_fetch, daemon=True).start()
|
||
else:
|
||
self.setHtml(_build_commentary_html(row))
|
||
|
||
@Slot()
|
||
def _refresh_commentary(self):
|
||
if self._row:
|
||
self.setHtml(_build_commentary_html(self._row))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ChartWidget
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ChartWidget(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._ticker = None
|
||
self._period = "6mo"
|
||
self._canvas = None
|
||
self._build()
|
||
|
||
def _build(self):
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
# Period selector
|
||
ctrl = QWidget()
|
||
ctrl.setFixedHeight(34)
|
||
ctrl_lay = QHBoxLayout(ctrl)
|
||
ctrl_lay.setContentsMargins(8, 4, 8, 0)
|
||
ctrl_lay.setSpacing(4)
|
||
self._period_btns = {}
|
||
for val, lbl in [("1mo","1M"),("3mo","3M"),("6mo","6M"),("1y","1Y"),("2y","2Y"),("5y","5Y")]:
|
||
btn = QPushButton(lbl)
|
||
btn.setFixedHeight(24)
|
||
btn.setCheckable(True)
|
||
btn.setChecked(val == "6mo")
|
||
btn.setStyleSheet(
|
||
f"QPushButton {{ background:{_C['bg3']}; color:{_C['fg2']}; border:1px solid {_C['border']}; border-radius:4px; padding:2px 10px; font-size:11px; }}"
|
||
f"QPushButton:checked {{ background:{_C['fg']}; color:#000000; border-color:{_C['fg']}; }}"
|
||
)
|
||
btn.clicked.connect(lambda checked, v=val: self._select_period(v))
|
||
ctrl_lay.addWidget(btn)
|
||
self._period_btns[val] = btn
|
||
ctrl_lay.addStretch()
|
||
lay.addWidget(ctrl)
|
||
|
||
sep = QFrame(); sep.setFixedHeight(1)
|
||
sep.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep)
|
||
|
||
self._placeholder = QLabel("Select a stock to view its price chart.")
|
||
self._placeholder.setAlignment(Qt.AlignCenter)
|
||
self._placeholder.setStyleSheet(f"color:{_C['fg2']}; font-size:13px;")
|
||
lay.addWidget(self._placeholder, 1)
|
||
|
||
self._chart_container = QWidget()
|
||
chart_lay = QVBoxLayout(self._chart_container)
|
||
chart_lay.setContentsMargins(0, 0, 0, 0)
|
||
self._chart_inner_lay = chart_lay
|
||
self._chart_container.hide()
|
||
lay.addWidget(self._chart_container, 1)
|
||
|
||
def _select_period(self, val):
|
||
self._period = val
|
||
for v, btn in self._period_btns.items():
|
||
btn.setChecked(v == val)
|
||
if self._ticker:
|
||
self.load_chart(self._ticker, val)
|
||
|
||
def load_chart(self, ticker, period=None):
|
||
self._ticker = ticker
|
||
if period:
|
||
self._period = period
|
||
self._placeholder.setText(f"Loading chart for {ticker}…")
|
||
self._placeholder.show()
|
||
if self._canvas:
|
||
self._chart_container.hide()
|
||
threading.Thread(target=self._fetch_thread, args=(ticker, self._period), daemon=True).start()
|
||
|
||
def _fetch_thread(self, ticker, period):
|
||
if not _MATPLOTLIB_OK:
|
||
QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, f"Chart unavailable: {_mpl_err}"))
|
||
return
|
||
if not _YF_OK:
|
||
QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, "Chart unavailable: yfinance not installed."))
|
||
return
|
||
try:
|
||
hist = yf.Ticker(ticker).history(period=period, auto_adjust=True)
|
||
if hist is None or hist.empty:
|
||
QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, f"No price history found for {ticker}."))
|
||
return
|
||
QMetaObject.invokeMethod(self, "_render_chart", Qt.QueuedConnection,
|
||
Q_ARG(object, hist), Q_ARG(str, ticker))
|
||
except Exception as e:
|
||
QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, f"Chart error: {e}"))
|
||
|
||
@Slot(object, str)
|
||
def _render_chart(self, hist, ticker):
|
||
if not _MATPLOTLIB_OK: return
|
||
import pandas as pd
|
||
# Clear old canvas
|
||
while self._chart_inner_lay.count():
|
||
item = self._chart_inner_lay.takeAt(0)
|
||
if item.widget(): item.widget().deleteLater()
|
||
self._canvas = None
|
||
|
||
close = hist["Close"]
|
||
volume = hist["Volume"] if "Volume" in hist.columns else None
|
||
ma20 = close.rolling(20).mean() if len(close) >= 20 else None
|
||
ma50 = close.rolling(50).mean() if len(close) >= 50 else None
|
||
dates = pd.to_datetime(close.index).tz_localize(None) if close.index.tz else pd.to_datetime(close.index)
|
||
prices = close.values
|
||
|
||
dpi = max(72, min(300, self.logicalDpiX()))
|
||
fig = Figure(figsize=(5, 3.8), dpi=dpi, facecolor=_C["bg"])
|
||
|
||
if volume is not None:
|
||
ax_price = fig.add_axes([0.09, 0.30, 0.87, 0.62], facecolor=_C["bg"])
|
||
ax_volume = fig.add_axes([0.09, 0.06, 0.87, 0.20], facecolor=_C["bg"], sharex=ax_price)
|
||
else:
|
||
ax_price = fig.add_axes([0.09, 0.08, 0.87, 0.86], facecolor=_C["bg"])
|
||
ax_volume = None
|
||
|
||
ax_price.plot(dates, prices, color="#4ade80", linewidth=1.6, label="Close")
|
||
if ma20 is not None:
|
||
ax_price.plot(dates, ma20.values, color="#fbbf24", linewidth=1.0, linestyle="--", label="20-Day MA")
|
||
if ma50 is not None:
|
||
ax_price.plot(dates, ma50.values, color="#888888", linewidth=1.0, linestyle="--", label="50-Day MA")
|
||
ax_price.set_title(f"{ticker} — Price History", color="#4ade80", fontsize=10, pad=6)
|
||
ax_price.tick_params(colors=_C["fg2"], labelsize=8)
|
||
for spine in ax_price.spines.values(): spine.set_edgecolor(_C["border"])
|
||
ax_price.grid(color="#111111", linewidth=0.5)
|
||
ax_price.legend(facecolor=_C["bg"], edgecolor=_C["border"], labelcolor=_C["fg"], fontsize=8, loc="upper left")
|
||
ax_price.tick_params(axis="x", labelbottom=(ax_volume is None))
|
||
|
||
vol_ma20_arr = None
|
||
if ax_volume is not None and volume is not None:
|
||
vol_dates = pd.to_datetime(volume.index).tz_localize(None) if volume.index.tz else pd.to_datetime(volume.index)
|
||
bar_colors = [_C["positive"] if i == 0 or prices[i] >= prices[i-1] else _C["negative"] for i in range(len(prices))]
|
||
ax_volume.bar(vol_dates, volume.values, color=bar_colors, width=0.8, align="center")
|
||
ax_volume.tick_params(colors=_C["fg2"], labelsize=7)
|
||
for spine in ax_volume.spines.values(): spine.set_edgecolor(_C["border"])
|
||
ax_volume.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x/1e6:.0f}M" if x >= 1e6 else f"{x/1e3:.0f}K"))
|
||
ax_volume.grid(color=_C["border"], linewidth=0.4)
|
||
_vol_arr = volume.values.astype(float)
|
||
_kernel = np.ones(20) / 20
|
||
_conv = np.convolve(_vol_arr, _kernel, mode="full")[:len(_vol_arr)]
|
||
_conv[:19] = np.nan
|
||
vol_ma20_arr = _conv
|
||
|
||
ax_bottom = ax_volume if ax_volume is not None else ax_price
|
||
try:
|
||
ax_bottom.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
|
||
ax_bottom.xaxis.set_major_locator(mdates.AutoDateLocator())
|
||
except Exception: pass
|
||
for lbl in ax_bottom.get_xticklabels():
|
||
lbl.set_rotation(30); lbl.set_ha("right"); lbl.set_color(_C["fg2"])
|
||
|
||
# Crosshair
|
||
vline_p = ax_price.axvline(x=dates[0], color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False)
|
||
hline_p = ax_price.axhline(y=prices[0], color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False)
|
||
dot_p = ax_price.plot([], [], "o", color="#4ade80", markersize=5, zorder=5)[0]
|
||
info_box = ax_price.text(0.99, 0.97, "", transform=ax_price.transAxes, ha="right", va="top",
|
||
fontsize=8, color="#4ade80",
|
||
bbox=dict(boxstyle="round,pad=0.4", facecolor=_C["bg"], edgecolor="#4ade80", alpha=0.96),
|
||
visible=False, zorder=10)
|
||
vline_v = ax_volume.axvline(x=dates[0], color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False) if ax_volume else None
|
||
hline_v = ax_volume.axhline(y=0, color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False) if ax_volume else None
|
||
info_box_v = ax_volume.text(0.99, 0.97, "", transform=ax_volume.transAxes, ha="right", va="top",
|
||
fontsize=7.5, color="#4ade80",
|
||
bbox=dict(boxstyle="round,pad=0.35", facecolor=_C["bg"], edgecolor="#4ade80", alpha=0.96),
|
||
visible=False, zorder=10) if ax_volume else None
|
||
|
||
date_nums = np.array([d.timestamp() for d in dates])
|
||
|
||
def _on_move(event):
|
||
if event.inaxes not in ([ax_price] + ([ax_volume] if ax_volume else [])):
|
||
vline_p.set_visible(False); hline_p.set_visible(False); dot_p.set_data([], [])
|
||
info_box.set_visible(False)
|
||
if vline_v: vline_v.set_visible(False)
|
||
if hline_v: hline_v.set_visible(False)
|
||
if info_box_v: info_box_v.set_visible(False)
|
||
try: canvas.draw_idle()
|
||
except: pass
|
||
return
|
||
try:
|
||
x_dt = mdates.num2date(event.xdata).replace(tzinfo=None)
|
||
ts = x_dt.timestamp()
|
||
idx = int(np.argmin(np.abs(date_nums - ts)))
|
||
snap_date = dates[idx]; snap_price = prices[idx]
|
||
snap_x = mdates.date2num(snap_date)
|
||
vline_p.set_xdata([snap_x, snap_x]); vline_p.set_visible(True)
|
||
hline_p.set_ydata([snap_price, snap_price]); hline_p.set_visible(True)
|
||
dot_p.set_data([snap_x], [snap_price])
|
||
pct = (snap_price - prices[idx-1]) / prices[idx-1] * 100 if idx > 0 and prices[idx-1] != 0 else None
|
||
chg_str = f"\nChange: {'+' if pct and pct >= 0 else ''}{pct:.2f}%" if pct is not None else ""
|
||
vol_str = ""
|
||
if volume is not None and idx < len(volume.values):
|
||
v = volume.values[idx]
|
||
vol_str = f"\nVol: {v/1e6:.2f}M" if v >= 1e6 else f"\nVol: {int(v):,}"
|
||
info_box.set_text(f"{snap_date.strftime('%b %d, %Y')}\nPrice: ${snap_price:,.2f}{chg_str}{vol_str}")
|
||
info_box.set_visible(True)
|
||
if vline_v: vline_v.set_xdata([snap_x, snap_x]); vline_v.set_visible(True)
|
||
if info_box_v and volume is not None and idx < len(volume.values):
|
||
v = volume.values[idx]
|
||
vol_fmt = f"{v/1e6:.2f}M" if v >= 1e6 else f"{int(v):,}"
|
||
direction = ""
|
||
if pct is not None: direction = f"\n{'↑ Buying' if pct >= 0 else '↓ Selling'} pressure {pct:+.2f}%"
|
||
avg_str = ""
|
||
if vol_ma20_arr is not None and idx < len(vol_ma20_arr):
|
||
avg = vol_ma20_arr[idx]
|
||
if not np.isnan(avg) and avg > 0:
|
||
avg_fmt = f"{avg/1e6:.2f}M" if avg >= 1e6 else f"{int(avg):,}"
|
||
avg_str = f"\nvs 20D Avg: {v/avg:.2f}× ({avg_fmt})"
|
||
info_box_v.set_text(f"{snap_date.strftime('%b %d, %Y')}\nVolume: {vol_fmt}{direction}{avg_str}")
|
||
info_box_v.set_visible(True)
|
||
if hline_v: hline_v.set_ydata([v, v]); hline_v.set_visible(True)
|
||
canvas.draw_idle()
|
||
except: pass
|
||
|
||
def _on_leave(event):
|
||
vline_p.set_visible(False); hline_p.set_visible(False); dot_p.set_data([], [])
|
||
info_box.set_visible(False)
|
||
if vline_v: vline_v.set_visible(False)
|
||
if hline_v: hline_v.set_visible(False)
|
||
if info_box_v: info_box_v.set_visible(False)
|
||
try: canvas.draw_idle()
|
||
except: pass
|
||
|
||
canvas = FigureCanvasQTAgg(fig)
|
||
canvas.draw()
|
||
fig.canvas.mpl_connect("motion_notify_event", _on_move)
|
||
fig.canvas.mpl_connect("axes_leave_event", _on_leave)
|
||
fig.canvas.mpl_connect("figure_leave_event", _on_leave)
|
||
|
||
self._chart_inner_lay.addWidget(canvas)
|
||
self._canvas = canvas
|
||
self._placeholder.hide()
|
||
self._chart_container.show()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Row data preparation helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _prepare_row_dict(row):
|
||
d = dict(row)
|
||
total_debt = d.get("total_debt")
|
||
de_ratio = d.get("debt_to_equity")
|
||
revenue = d.get("revenue")
|
||
pm = d.get("profit_margin")
|
||
om = d.get("operating_margin")
|
||
ns = d.get("news_sentiment")
|
||
|
||
if not _nan(total_debt) and not _nan(de_ratio) and de_ratio != 0:
|
||
d["_equity_fmt"] = _mcap(total_debt / (de_ratio / 100.0))
|
||
else:
|
||
d["_equity_fmt"] = "—"
|
||
|
||
d["_revenue_fmt"] = _mcap(revenue) if not _nan(revenue) else "—"
|
||
d["_net_income_fmt"] = _mcap(revenue * pm) if not _nan(revenue) and not _nan(pm) else "—"
|
||
d["_op_income_fmt"] = _mcap(revenue * om) if not _nan(revenue) and not _nan(om) else "—"
|
||
|
||
if not _nan(pm):
|
||
_pm_pct = f"{pm * 100:.1f}%"
|
||
d["_profit_margin_fmt"] = f"{_pm_pct} ({_mcap(revenue * pm)})" if not _nan(revenue) else _pm_pct
|
||
else:
|
||
d["_profit_margin_fmt"] = "—"
|
||
|
||
if not _nan(om):
|
||
_om_pct = f"{om * 100:.1f}%"
|
||
d["_op_margin_fmt"] = f"{_om_pct} ({_mcap(revenue * om)})" if not _nan(revenue) else _om_pct
|
||
else:
|
||
d["_op_margin_fmt"] = "—"
|
||
|
||
if _nan(ns): d["_sentiment_label"] = "— (no data)"
|
||
elif ns >= 0.05: d["_sentiment_label"] = f"Positive ({ns:+.3f})"
|
||
elif ns <= -0.05: d["_sentiment_label"] = f"Negative ({ns:+.3f})"
|
||
else: d["_sentiment_label"] = f"Neutral ({ns:+.3f})"
|
||
|
||
return d
|
||
|
||
|
||
def _fmt_for_key(key, d):
|
||
def _rec_fmt(v): return "—" if not v or str(v) in ("nan","N/A","None") else str(v).replace("_"," ").title()
|
||
def _cnt_fmt(v): return "—" if _nan(v) else str(int(v))
|
||
ns = d.get("news_sentiment")
|
||
_FMTS = {
|
||
"composite_score": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_value": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_growth": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_momentum": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_quality": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_profitability":lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_sentiment": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_analyst": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"score_risk": lambda v: (_score(v) + " / 100") if not _nan(v) else "—",
|
||
"price": _price, "market_cap": _mcap,
|
||
"pe_forward": _flt, "pe_trailing": _flt, "pb_ratio": _flt,
|
||
"book_value": _mcap, "ev_ebitda": _flt, "52w_high": _price, "52w_low": _price,
|
||
"revenue_growth": _pct, "earnings_growth": _pct, "eps_forward": _flt, "eps_trailing": _flt,
|
||
"ret_1m": _pct, "ret_3m": _pct, "ret_6m": _pct, "ret_12m": _pct,
|
||
"roe": _pct, "roa": _pct, "total_debt": _mcap, "total_cash": _mcap,
|
||
"_equity_fmt": lambda v: v or "—",
|
||
"_revenue_fmt": lambda v: v or "—",
|
||
"_net_income_fmt": lambda v: v or "—",
|
||
"_op_income_fmt": lambda v: v or "—",
|
||
"_profit_margin_fmt": lambda v: v or "—",
|
||
"_op_margin_fmt": lambda v: v or "—",
|
||
"current_ratio": _flt, "profit_margin": _pct, "operating_margin": _pct,
|
||
"fcf_yield": _pct, "dividend_yield": _pct,
|
||
"recommendation": _rec_fmt, "analyst_count": _cnt_fmt,
|
||
"analyst_target": _price, "analyst_upside": _pct,
|
||
"news_sentiment": lambda v: d.get("_sentiment_label", "—"),
|
||
"short_percent": _pct, "beta": _flt, "volatility_30d": _pct,
|
||
}
|
||
fmt = _FMTS.get(key, lambda v: _flt(v))
|
||
val = d.get(key)
|
||
return fmt(val)
|
||
|
||
|
||
def _color_for_key(key, d):
|
||
val = d.get(key)
|
||
ns = d.get("news_sentiment")
|
||
pm = d.get("profit_margin")
|
||
om = d.get("operating_margin")
|
||
if key in _GREEN_KEYS and not _nan(val):
|
||
return _C["positive"] if val >= 0 else _C["negative"]
|
||
if key == "news_sentiment" and not _nan(ns):
|
||
return _C["positive"] if ns >= 0.05 else (_C["negative"] if ns <= -0.05 else _C["warning"])
|
||
if key in ("short_percent", "volatility_30d") and not _nan(val):
|
||
return _C["negative"] if val > 0.10 else _C["positive"]
|
||
if key == "total_debt" and not _nan(val):
|
||
return _C["negative"] if val > 0 else _C["fg"]
|
||
if key == "total_cash" and not _nan(val):
|
||
return _C["positive"] if val > 0 else _C["fg"]
|
||
if key == "_net_income_fmt" and not _nan(pm):
|
||
return _C["positive"] if pm >= 0 else _C["negative"]
|
||
if key == "_op_income_fmt" and not _nan(om):
|
||
return _C["positive"] if om >= 0 else _C["negative"]
|
||
if key in ("_profit_margin_fmt", "profit_margin") and not _nan(pm):
|
||
return _C["positive"] if pm >= 0 else _C["negative"]
|
||
if key in ("_op_margin_fmt", "operating_margin") and not _nan(om):
|
||
return _C["positive"] if om >= 0 else _C["negative"]
|
||
return _C["fg"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DetailPanel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _MetricRow(QWidget):
|
||
def __init__(self, label, key, parent=None):
|
||
super().__init__(parent)
|
||
self._key = key
|
||
lay = QHBoxLayout(self)
|
||
lay.setContentsMargins(16, 6, 16, 6)
|
||
lay.setSpacing(8)
|
||
tip = _METRIC_TOOLTIPS.get(label, "")
|
||
lbl = QLabel(label)
|
||
lbl.setFixedWidth(160)
|
||
lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;")
|
||
if tip: lbl.setToolTip(tip)
|
||
lay.addWidget(lbl)
|
||
self.val_lbl = QLabel("—")
|
||
self.val_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:12px; font-family:'Consolas';")
|
||
lay.addWidget(self.val_lbl)
|
||
lay.addStretch()
|
||
|
||
def set_value(self, text, color=None):
|
||
self.val_lbl.setText(text)
|
||
c = color or _C["fg"]
|
||
self.val_lbl.setStyleSheet(f"color:{c}; font-size:12px; font-family:'Consolas';")
|
||
|
||
|
||
def _make_grid_tab(fields, cols=2):
|
||
"""Return (QWidget tab, dict of key->MetricCell) using 2-col metric card grid."""
|
||
tab = QWidget()
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.NoFrame)
|
||
inner = QWidget()
|
||
lay = QGridLayout(inner)
|
||
lay.setContentsMargins(10, 10, 10, 10)
|
||
lay.setSpacing(6)
|
||
cells = {}
|
||
for i, (label, key) in enumerate(fields):
|
||
tip = _METRIC_TOOLTIPS.get(label, "")
|
||
cell = MetricCell(label, key, tip)
|
||
cells[key] = cell
|
||
lay.addWidget(cell, i // cols, i % cols)
|
||
# stretch the last row so cards stay top-aligned
|
||
lay.setRowStretch((len(fields) - 1) // cols + 1, 1)
|
||
scroll.setWidget(inner)
|
||
outer = QVBoxLayout(tab)
|
||
outer.setContentsMargins(0, 0, 0, 0)
|
||
outer.addWidget(scroll)
|
||
return tab, cells
|
||
|
||
|
||
class DetailPanel(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._current_ticker = None
|
||
self._all_rows = {} # key -> _MetricRow across all tabs
|
||
self._score_tab = None
|
||
self._commentary = None
|
||
self._chart = None
|
||
self._build()
|
||
|
||
def _build(self):
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
# Header
|
||
self._header = QLabel(" \u2190 Select a stock from the list")
|
||
self._header.setStyleSheet(f"color:{_C['fg2']}; font-size:14px; font-weight:600; padding:12px 16px;")
|
||
lay.addWidget(self._header)
|
||
|
||
sep = QFrame(); sep.setFixedHeight(1)
|
||
sep.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep)
|
||
|
||
# No-data warning
|
||
self._no_data_bar = QLabel(" \u26a0 No fundamental data found for this ticker — scores based on price & momentum only.")
|
||
self._no_data_bar.setStyleSheet(f"background:#1a1200; color:{_C['warning']}; font-size:11px; padding:6px 16px;")
|
||
self._no_data_bar.hide()
|
||
lay.addWidget(self._no_data_bar)
|
||
|
||
# Tabs
|
||
self._tabs = QTabWidget()
|
||
self._tabs.setVisible(False)
|
||
lay.addWidget(self._tabs, 1)
|
||
|
||
# Scores tab
|
||
self._score_tab = ScoreTab()
|
||
self._tabs.addTab(self._score_tab, "Scores")
|
||
|
||
# Valuation
|
||
t, cells = _make_grid_tab(_VAL_FIELDS)
|
||
self._all_rows.update(cells)
|
||
self._tabs.addTab(t, "Valuation")
|
||
|
||
# Growth
|
||
t, cells = _make_grid_tab(_GROWTH_FIELDS)
|
||
self._all_rows.update(cells)
|
||
self._tabs.addTab(t, "Growth")
|
||
|
||
# Momentum
|
||
t, cells = _make_grid_tab(_MOM_FIELDS)
|
||
self._all_rows.update(cells)
|
||
self._tabs.addTab(t, "Momentum")
|
||
|
||
# Quality & Profit
|
||
t, cells = _make_grid_tab(_QUAL_FIELDS)
|
||
self._all_rows.update(cells)
|
||
self._tabs.addTab(t, "Quality & Profit")
|
||
|
||
# Analyst tab (metric grid + commentary)
|
||
analyst_tab = QWidget()
|
||
al = QVBoxLayout(analyst_tab)
|
||
al.setContentsMargins(0, 0, 0, 0)
|
||
al.setSpacing(0)
|
||
# Metrics as a 2-col grid
|
||
anlst_grid_w = QWidget()
|
||
anlst_grid_lay = QGridLayout(anlst_grid_w)
|
||
anlst_grid_lay.setContentsMargins(10, 10, 10, 6)
|
||
anlst_grid_lay.setSpacing(6)
|
||
for i, (label, key) in enumerate(_ANLST_FIELDS):
|
||
tip = _METRIC_TOOLTIPS.get(label, "")
|
||
cell = MetricCell(label, key, tip)
|
||
self._all_rows[key] = cell
|
||
anlst_grid_lay.addWidget(cell, i // 2, i % 2)
|
||
al.addWidget(anlst_grid_w)
|
||
|
||
hdiv = QFrame(); hdiv.setFixedHeight(1)
|
||
hdiv.setStyleSheet(f"background:{_C['border']};")
|
||
al.addWidget(hdiv)
|
||
|
||
# Commentary header
|
||
chdr = QWidget()
|
||
chdr_lay = QHBoxLayout(chdr)
|
||
chdr_lay.setContentsMargins(8, 4, 8, 4)
|
||
lc = QLabel("Analyst Commentary")
|
||
lc.setStyleSheet(f"color:{_C['fg']}; font-size:12px; font-weight:700;")
|
||
chdr_lay.addWidget(lc)
|
||
ls = QLabel("auto-generated from market data")
|
||
ls.setStyleSheet(f"color:{_C['fg2']}; font-size:10px;")
|
||
chdr_lay.addWidget(ls)
|
||
chdr_lay.addStretch()
|
||
al.addWidget(chdr)
|
||
|
||
self._commentary = CommentaryWidget()
|
||
al.addWidget(self._commentary, 1)
|
||
self._tabs.addTab(analyst_tab, "Analyst")
|
||
|
||
# Chart tab
|
||
self._chart = ChartWidget()
|
||
self._tabs.addTab(self._chart, "Chart")
|
||
|
||
def update(self, row):
|
||
if isinstance(row, _pd.Series):
|
||
row = row.to_dict()
|
||
|
||
ticker = row.get("ticker", "?")
|
||
name = str(row.get("name", ticker))
|
||
sector = str(row.get("sector", "N/A"))
|
||
self._header.setText(f" {ticker} — {name} \u00b7 {sector}")
|
||
self._header.setStyleSheet(f"color:{_C['fg']}; font-size:14px; font-weight:600; padding:12px 16px;")
|
||
self._tabs.setVisible(True)
|
||
|
||
if str(row.get("_data_source", "")) == "none":
|
||
self._no_data_bar.show()
|
||
else:
|
||
self._no_data_bar.hide()
|
||
|
||
d = _derive_row_extras(row)
|
||
|
||
# Update metric rows
|
||
for key, row_w in self._all_rows.items():
|
||
text = _fmt_for_key(key, d)
|
||
color = _color_for_key(key, d)
|
||
row_w.set_value(text, color)
|
||
|
||
# Score tab
|
||
self._score_tab.update_scores(d)
|
||
|
||
# Commentary
|
||
self._commentary.update_commentary(d)
|
||
|
||
# Chart
|
||
new_ticker = row.get("ticker")
|
||
if new_ticker and new_ticker != self._current_ticker:
|
||
self._current_ticker = new_ticker
|
||
self._chart.load_chart(new_ticker)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FiltersDialog
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class FiltersDialog(QDialog):
|
||
filters_changed = Signal()
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Filters")
|
||
self.setWindowFlags(Qt.Dialog | Qt.WindowCloseButtonHint)
|
||
self.setModal(False)
|
||
self.setMinimumWidth(320)
|
||
|
||
# State
|
||
self.price_min = ""
|
||
self.price_max = ""
|
||
self.sort_by = "Score ↓"
|
||
self.mktcap = "Any"
|
||
self.score_min = ""
|
||
self.ret_period = "Any"
|
||
self.ret_min = ""
|
||
self.upside_min = ""
|
||
self.search_q = ""
|
||
|
||
lay = QVBoxLayout(self)
|
||
lay.setSpacing(10)
|
||
|
||
def _label(text):
|
||
l = QLabel(text)
|
||
l.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;")
|
||
return l
|
||
|
||
def _entry():
|
||
e = QLineEdit()
|
||
e.setFixedWidth(100)
|
||
return e
|
||
|
||
def _combo(items):
|
||
c = QComboBox()
|
||
for it in items: c.addItem(it)
|
||
return c
|
||
|
||
title = QLabel("Post-Screen Filters")
|
||
title.setStyleSheet(f"color:{_C['fg']}; font-size:14px; font-weight:700;")
|
||
lay.addWidget(title)
|
||
|
||
g = QGridLayout()
|
||
g.setColumnStretch(1, 1)
|
||
|
||
r = 0
|
||
g.addWidget(_label("Price $"), r, 0)
|
||
pf = QWidget(); pfl = QHBoxLayout(pf); pfl.setContentsMargins(0,0,0,0)
|
||
self._price_min = _entry(); self._price_max = _entry()
|
||
pfl.addWidget(self._price_min); pfl.addWidget(QLabel("–")); pfl.addWidget(self._price_max)
|
||
g.addWidget(pf, r, 1); r += 1
|
||
|
||
g.addWidget(_label("Sort by"), r, 0)
|
||
self._sort_cb = _combo(["Score ↓", "Price ↓", "Price ↑", "Upside ↓"])
|
||
g.addWidget(self._sort_cb, r, 1); r += 1
|
||
|
||
g.addWidget(_label("Market Cap"), r, 0)
|
||
self._mktcap_cb = _combo(["Any","Mega (>$100B)","Large ($10B–$100B)","Mid ($2B–$10B)","Small (<$2B)"])
|
||
g.addWidget(self._mktcap_cb, r, 1); r += 1
|
||
|
||
g.addWidget(_label("Min Score"), r, 0)
|
||
self._score_min = _entry()
|
||
g.addWidget(self._score_min, r, 1); r += 1
|
||
|
||
g.addWidget(_label("Return"), r, 0)
|
||
rf = QWidget(); rfl = QHBoxLayout(rf); rfl.setContentsMargins(0,0,0,0)
|
||
self._ret_period_cb = _combo(["Any","1M","3M","6M","12M"])
|
||
self._ret_min = _entry()
|
||
rfl.addWidget(self._ret_period_cb); rfl.addWidget(QLabel("≥")); rfl.addWidget(self._ret_min); rfl.addWidget(QLabel("%"))
|
||
g.addWidget(rf, r, 1); r += 1
|
||
|
||
g.addWidget(_label("Min Upside"), r, 0)
|
||
uf = QWidget(); ufl = QHBoxLayout(uf); ufl.setContentsMargins(0,0,0,0)
|
||
self._upside_min = _entry()
|
||
ufl.addWidget(self._upside_min); ufl.addWidget(QLabel("% to target"))
|
||
g.addWidget(uf, r, 1); r += 1
|
||
|
||
g.addWidget(_label("Search"), r, 0)
|
||
self._search_edit = QLineEdit()
|
||
g.addWidget(self._search_edit, r, 1); r += 1
|
||
|
||
lay.addLayout(g)
|
||
|
||
close_btn = QPushButton("Close")
|
||
close_btn.clicked.connect(self.hide)
|
||
lay.addWidget(close_btn)
|
||
|
||
# Connections
|
||
for w in [self._price_min, self._price_max, self._score_min,
|
||
self._ret_min, self._upside_min, self._search_edit]:
|
||
w.editingFinished.connect(self._emit)
|
||
w.returnPressed.connect(self._emit)
|
||
self._sort_cb.currentTextChanged.connect(self._emit)
|
||
self._mktcap_cb.currentTextChanged.connect(self._emit)
|
||
self._ret_period_cb.currentTextChanged.connect(self._emit)
|
||
self._search_edit.textChanged.connect(self._emit)
|
||
|
||
def _emit(self):
|
||
self.price_min = self._price_min.text().strip()
|
||
self.price_max = self._price_max.text().strip()
|
||
self.sort_by = self._sort_cb.currentText()
|
||
self.mktcap = self._mktcap_cb.currentText()
|
||
self.score_min = self._score_min.text().strip()
|
||
self.ret_period = self._ret_period_cb.currentText()
|
||
self.ret_min = self._ret_min.text().strip()
|
||
self.upside_min = self._upside_min.text().strip()
|
||
self.search_q = self._search_edit.text().strip()
|
||
self.filters_changed.emit()
|
||
|
||
def get_state(self):
|
||
return {
|
||
"price_min": self.price_min, "price_max": self.price_max,
|
||
"sort_by": self.sort_by, "mktcap": self.mktcap,
|
||
"score_min": self.score_min, "ret_period": self.ret_period,
|
||
"ret_min": self.ret_min, "upside_min": self.upside_min,
|
||
"search_q": self.search_q,
|
||
}
|
||
|
||
"""Part 5: ScreenerPage, SearchPage, TrackingPage (Insider + HedgeFund), ScreenerApp, main"""
|
||
import calendar as _cal
|
||
import threading
|
||
import webbrowser
|
||
import pandas as _pd
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ScreenerPage
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ScreenerPage(QWidget):
|
||
def __init__(self, app_ref, parent=None):
|
||
super().__init__(parent)
|
||
self._app = app_ref
|
||
self._df = None
|
||
self._df_raw = None
|
||
self._sector_stats = {}
|
||
self._pending_df_raw = None
|
||
self._pending_df_scored = None
|
||
self._pending_sector_stats = {}
|
||
self._stop_event = threading.Event()
|
||
self._run_id = 0
|
||
self._rescoring = False
|
||
self._rescore_id = 0
|
||
self._custom_weights = None
|
||
self._signals = None
|
||
self._filters_dlg = None
|
||
self._selected_ticker = None
|
||
self._build()
|
||
|
||
def _build(self):
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
# Toolbar
|
||
toolbar = QWidget()
|
||
toolbar.setFixedHeight(52)
|
||
tl = QHBoxLayout(toolbar)
|
||
tl.setContentsMargins(12, 8, 12, 8)
|
||
tl.setSpacing(6)
|
||
|
||
self._run_btn = QPushButton("▶ Run")
|
||
self._run_btn.setObjectName("run-btn")
|
||
self._run_btn.clicked.connect(self._on_run)
|
||
tl.addWidget(self._run_btn)
|
||
|
||
self._stop_btn = QPushButton("■ Stop")
|
||
self._stop_btn.setEnabled(False)
|
||
self._stop_btn.clicked.connect(self._on_stop)
|
||
tl.addWidget(self._stop_btn)
|
||
|
||
tl.addWidget(self._vsep())
|
||
|
||
tl.addWidget(QLabel("Index"))
|
||
self._index_cb = QComboBox()
|
||
for x in INDEX_CHOICES: self._index_cb.addItem(x)
|
||
tl.addWidget(self._index_cb)
|
||
|
||
tl.addWidget(self._vsep())
|
||
|
||
tl.addWidget(QLabel("Sector"))
|
||
self._sector_cb = QComboBox()
|
||
for x in SECTOR_CHOICES: self._sector_cb.addItem(x)
|
||
self._sector_cb.currentTextChanged.connect(self._apply_filters)
|
||
tl.addWidget(self._sector_cb)
|
||
|
||
tl.addWidget(self._vsep())
|
||
|
||
tl.addWidget(QLabel("Strategy"))
|
||
self._strategy_cb = QComboBox()
|
||
for x in list(STRATEGY_PRESETS.keys()) + ["Custom..."]:
|
||
self._strategy_cb.addItem(x)
|
||
self._strategy_cb.currentTextChanged.connect(self._on_strategy_change)
|
||
tl.addWidget(self._strategy_cb)
|
||
|
||
tl.addWidget(self._vsep())
|
||
|
||
filters_btn = QPushButton("⚙ Filters")
|
||
filters_btn.clicked.connect(self._open_filters)
|
||
tl.addWidget(filters_btn)
|
||
|
||
tl.addStretch()
|
||
|
||
self._lastrun_lbl = QLabel("")
|
||
self._lastrun_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;")
|
||
tl.addWidget(self._lastrun_lbl)
|
||
|
||
lay.addWidget(toolbar)
|
||
|
||
sep = QFrame(); sep.setFixedHeight(1)
|
||
sep.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep)
|
||
|
||
# Progress bar
|
||
self._progress = QProgressBar()
|
||
self._progress.setFixedHeight(3)
|
||
self._progress.setRange(0, 100)
|
||
self._progress.setValue(0)
|
||
self._progress.setTextVisible(False)
|
||
self._progress.hide()
|
||
lay.addWidget(self._progress)
|
||
|
||
# Main splitter
|
||
splitter = QSplitter(Qt.Horizontal)
|
||
splitter.setHandleWidth(1)
|
||
|
||
# Left: table
|
||
left = QWidget()
|
||
ll = QVBoxLayout(left)
|
||
ll.setContentsMargins(0, 0, 0, 0)
|
||
ll.setSpacing(0)
|
||
results_lbl = QLabel("Results")
|
||
results_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700; padding:10px 16px;")
|
||
ll.addWidget(results_lbl)
|
||
sep2 = QFrame(); sep2.setFixedHeight(1)
|
||
sep2.setStyleSheet(f"background:{_C['border']};")
|
||
ll.addWidget(sep2)
|
||
self._table = StockTable()
|
||
self._table.stock_selected.connect(self._on_stock_selected)
|
||
ll.addWidget(self._table, 1)
|
||
splitter.addWidget(left)
|
||
|
||
# Right: detail
|
||
self._detail = DetailPanel()
|
||
splitter.addWidget(self._detail)
|
||
|
||
splitter.setStretchFactor(0, 1)
|
||
splitter.setStretchFactor(1, 2)
|
||
splitter.setSizes([300, 600])
|
||
lay.addWidget(splitter, 1)
|
||
|
||
# Status bar
|
||
sep3 = QFrame(); sep3.setFixedHeight(1)
|
||
sep3.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep3)
|
||
self._status_lbl = QLabel(" Ready — press Run to start a scan")
|
||
self._status_lbl.setFixedHeight(26)
|
||
self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px; padding:0 12px;")
|
||
lay.addWidget(self._status_lbl)
|
||
|
||
def _vsep(self):
|
||
f = QFrame()
|
||
f.setFixedSize(1, 20)
|
||
f.setStyleSheet(f"background:{_C['border']};")
|
||
return f
|
||
|
||
def get_current_weights(self):
|
||
strategy = self._strategy_cb.currentText()
|
||
if strategy == "Custom..." and self._custom_weights:
|
||
return self._custom_weights
|
||
return STRATEGY_PRESETS.get(strategy)
|
||
|
||
def _on_stock_selected(self, row_dict):
|
||
self._selected_ticker = row_dict.get("ticker")
|
||
self._detail.update(row_dict)
|
||
|
||
def _on_run(self):
|
||
self._run_id += 1
|
||
self._stop_event.clear()
|
||
self._run_btn.setEnabled(False)
|
||
self._stop_btn.setEnabled(True)
|
||
self._progress.setValue(0)
|
||
self._progress.show()
|
||
self._status_lbl.setText(" Starting…")
|
||
|
||
strategy = self._strategy_cb.currentText()
|
||
run_weights = self._custom_weights if strategy == "Custom..." and self._custom_weights else STRATEGY_PRESETS.get(strategy)
|
||
|
||
sector_filter = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText())
|
||
screen_filters = {"sector_filter": sector_filter, "mktcap_range": None}
|
||
|
||
signals = _WorkerSignals()
|
||
self._signals = signals
|
||
signals.progress.connect(self._on_progress)
|
||
signals.status.connect(self._on_status)
|
||
signals.done.connect(self._on_done)
|
||
signals.error.connect(self._on_error)
|
||
signals.stopped.connect(self._on_stopped)
|
||
|
||
settings = {
|
||
"index": self._index_cb.currentText(),
|
||
"weights": run_weights,
|
||
"workers": 10,
|
||
"screen_filters": screen_filters,
|
||
}
|
||
threading.Thread(target=self._worker, args=(settings, self._run_id, signals), daemon=True).start()
|
||
|
||
def _on_stop(self):
|
||
self._stop_event.set()
|
||
self._run_id += 1
|
||
self._run_btn.setEnabled(True)
|
||
self._stop_btn.setEnabled(False)
|
||
self._progress.setValue(0)
|
||
self._progress.hide()
|
||
self._status_lbl.setText(" Stopped.")
|
||
|
||
def _worker(self, settings, run_id, signals):
|
||
import os as _os, traceback as _tb
|
||
_log_path = _os.path.join(_os.environ.get("TEMP", _os.path.expanduser("~")), "screener_run.log")
|
||
def _log(msg):
|
||
try:
|
||
with open(_log_path, "a", encoding="utf-8") as _f:
|
||
from datetime import datetime as _dt
|
||
_f.write(f"[{_dt.now().strftime('%H:%M:%S')}] {msg}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
_log("=== Run started ===")
|
||
redirector = _StdoutRedirector(signals)
|
||
old_stdout = sys.stdout
|
||
sys.stdout = redirector
|
||
try:
|
||
signals.status.emit("Fetching ticker lists…")
|
||
_log("collect_tickers start")
|
||
tickers = collect_tickers(index=settings["index"])
|
||
_log(f"collect_tickers done: {len(tickers)} tickers")
|
||
if not tickers:
|
||
signals.error.emit("Could not retrieve any ticker lists.")
|
||
return
|
||
if self._stop_event.is_set():
|
||
signals.stopped.emit(); return
|
||
|
||
sf = settings.get("screen_filters", {})
|
||
signals.status.emit(f"Screening {len(tickers)} stocks…")
|
||
_log("fetch_all start")
|
||
df_raw = fetch_all(tickers, max_workers=settings["workers"], screen_filters=sf)
|
||
_log(f"fetch_all done: {len(df_raw)} rows, empty={df_raw.empty}")
|
||
|
||
if self._stop_event.is_set():
|
||
signals.stopped.emit(); return
|
||
if df_raw.empty:
|
||
signals.error.emit("No data returned from yfinance.")
|
||
return
|
||
|
||
signals.status.emit("Loading sector stats…")
|
||
sector_stats = _load_sector_stats()
|
||
_log(f"sector_stats: {len(sector_stats)} sectors")
|
||
signals.status.emit(f"Computing scores for {len(df_raw)} stocks…")
|
||
df_scored = score_stocks(df_raw, weights=settings.get("weights"), sector_stats=sector_stats)
|
||
_log(f"score_stocks done: {len(df_scored)} rows")
|
||
# Store results in instance vars — avoids passing large objects through signal
|
||
self._pending_df_raw = df_raw
|
||
self._pending_df_scored = df_scored
|
||
self._pending_sector_stats = sector_stats
|
||
_log("invoking _on_done on main thread")
|
||
QMetaObject.invokeMethod(self, "_on_done", Qt.QueuedConnection, Q_ARG(int, len(df_scored)))
|
||
_log("invoke call returned")
|
||
except Exception as exc:
|
||
_log(f"EXCEPTION: {_tb.format_exc()}")
|
||
signals.error.emit(str(exc))
|
||
finally:
|
||
sys.stdout = old_stdout
|
||
_log("=== Run finished ===")
|
||
|
||
@Slot(int, int, int, int, float)
|
||
def _on_progress(self, done, total, ok, skip, eta):
|
||
if total:
|
||
self._progress.setValue(int(done / total * 100))
|
||
self._status_lbl.setText(f" {done}/{total} ok={ok} skip={skip} ETA {eta:.0f}s")
|
||
|
||
@Slot(str)
|
||
def _on_status(self, text):
|
||
self._status_lbl.setText(" " + text)
|
||
|
||
@Slot(int)
|
||
def _on_done(self, total):
|
||
import os as _os2
|
||
_lp2 = _os2.path.join(_os2.environ.get("TEMP", _os2.path.expanduser("~")), "screener_run.log")
|
||
try:
|
||
with open(_lp2, "a") as _lf2:
|
||
from datetime import datetime as _dt2
|
||
_lf2.write(f"[{_dt2.now().strftime('%H:%M:%S')}] _on_done called, total={total}\n")
|
||
except Exception:
|
||
pass
|
||
df_raw = getattr(self, "_pending_df_raw", None)
|
||
df_scored = getattr(self, "_pending_df_scored", None)
|
||
sector_stats = getattr(self, "_pending_sector_stats", {})
|
||
if df_scored is None:
|
||
self._status_lbl.setText(" Error: result data missing.")
|
||
self._run_btn.setEnabled(True)
|
||
self._stop_btn.setEnabled(False)
|
||
return
|
||
self._df_raw = df_raw
|
||
self._df = df_scored
|
||
self._sector_stats = sector_stats
|
||
try:
|
||
with open(_lp2, "a") as _lf2b:
|
||
from datetime import datetime as _dt2b
|
||
_lf2b.write(f"[{_dt2b.now().strftime('%H:%M:%S')}] _on_done: df_scored={'None' if df_scored is None else len(df_scored)}, self._df={'None' if self._df is None else 'set'}\n")
|
||
except Exception:
|
||
pass
|
||
self._progress.setValue(100)
|
||
self._status_lbl.setText(f" Done — {total} stocks scored.")
|
||
self._apply_filters()
|
||
self._lastrun_lbl.setText("Last run: " + datetime.now().strftime("%H:%M:%S"))
|
||
self._run_btn.setEnabled(True)
|
||
self._stop_btn.setEnabled(False)
|
||
# Share with app
|
||
if hasattr(self._app, '_df'):
|
||
self._app._df = df_scored
|
||
self._app._df_raw = df_raw
|
||
self._app._sector_stats = sector_stats
|
||
|
||
@Slot(str)
|
||
def _on_error(self, text):
|
||
self._status_lbl.setText(" Error: " + text)
|
||
self._run_btn.setEnabled(True)
|
||
self._stop_btn.setEnabled(False)
|
||
self._progress.hide()
|
||
QMessageBox.critical(self, "Screen Error", f"The screening run failed:\n\n{text}")
|
||
|
||
@Slot()
|
||
def _on_stopped(self):
|
||
self._status_lbl.setText(" Stopped.")
|
||
self._run_btn.setEnabled(True)
|
||
self._stop_btn.setEnabled(False)
|
||
self._progress.hide()
|
||
|
||
def _apply_filters(self):
|
||
import os as _os4, traceback as _tb4
|
||
_lp4 = _os4.path.join(_os4.environ.get("TEMP", _os4.path.expanduser("~")), "screener_run.log")
|
||
def _log4(msg):
|
||
try:
|
||
with open(_lp4, "a") as _lf4:
|
||
from datetime import datetime as _dt4
|
||
_lf4.write(f"[{_dt4.now().strftime('%H:%M:%S')}] AF: {msg}\n")
|
||
except Exception:
|
||
pass
|
||
_log4(f"called, self._df={'None' if self._df is None else len(self._df)}")
|
||
if self._df is None:
|
||
_log4("returning early — df is None")
|
||
return
|
||
try:
|
||
self._apply_filters_inner()
|
||
_log4("inner completed OK")
|
||
except Exception as exc:
|
||
_log4(f"EXCEPTION: {_tb4.format_exc()}")
|
||
try:
|
||
QMessageBox.critical(self, "Display Error",
|
||
f"Failed to display results:\n\n{_tb4.format_exc()}")
|
||
except Exception as e2:
|
||
_log4(f"QMessageBox also failed: {e2}")
|
||
|
||
def _apply_filters_inner(self):
|
||
import os as _os3
|
||
_lp3 = _os3.path.join(_os3.environ.get("TEMP", _os3.path.expanduser("~")), "screener_run.log")
|
||
def _log3(msg):
|
||
try:
|
||
with open(_lp3, "a") as _lf3:
|
||
from datetime import datetime as _dt3
|
||
_lf3.write(f"[{_dt3.now().strftime('%H:%M:%S')}] AFI: {msg}\n")
|
||
except Exception:
|
||
pass
|
||
_log3(f"start, df={None if self._df is None else len(self._df)} rows")
|
||
df = self._df
|
||
rule = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText())
|
||
if rule:
|
||
col, val = rule
|
||
if col in df.columns: df = df[df[col] == val]
|
||
|
||
state = self._filters_dlg.get_state() if self._filters_dlg else {}
|
||
|
||
search_q = state.get("search_q", "").strip()
|
||
if search_q:
|
||
q = search_q.lower()
|
||
mask = (df["ticker"].str.lower().str.contains(q, na=False, regex=False) |
|
||
df["name"].str.lower().str.contains(q, na=False, regex=False))
|
||
df = df[mask]
|
||
|
||
def _f(s):
|
||
try: return float(s.replace("$","").replace(",","").strip())
|
||
except: return None
|
||
|
||
for col2 in ("price","market_cap","composite_score","ret_1m","ret_3m","ret_6m","ret_12m","analyst_upside"):
|
||
if col2 in df.columns:
|
||
df = df.copy()
|
||
df[col2] = _pd.to_numeric(df[col2], errors="coerce")
|
||
|
||
pmin = _f(state.get("price_min",""))
|
||
pmax = _f(state.get("price_max",""))
|
||
if pmin is not None and "price" in df.columns: df = df[df["price"].notna() & (df["price"] >= pmin)]
|
||
if pmax is not None and "price" in df.columns: df = df[df["price"].notna() & (df["price"] <= pmax)]
|
||
|
||
mktcap = state.get("mktcap","Any")
|
||
if mktcap != "Any" and "market_cap" in df.columns:
|
||
if mktcap.startswith("Mega"): df = df[df["market_cap"].fillna(0) >= 100e9]
|
||
elif mktcap.startswith("Large"): df = df[(df["market_cap"].fillna(0) >= 10e9) & (df["market_cap"].fillna(0) < 100e9)]
|
||
elif mktcap.startswith("Mid"): df = df[(df["market_cap"].fillna(0) >= 2e9) & (df["market_cap"].fillna(0) < 10e9)]
|
||
elif mktcap.startswith("Small"): df = df[df["market_cap"].fillna(0) < 2e9]
|
||
|
||
smin = _f(state.get("score_min",""))
|
||
if smin is not None and "composite_score" in df.columns:
|
||
df = df[df["composite_score"].fillna(0) >= smin]
|
||
|
||
ret_period = state.get("ret_period","Any")
|
||
ret_min = _f(state.get("ret_min",""))
|
||
_rcol = {"1M":"ret_1m","3M":"ret_3m","6M":"ret_6m","12M":"ret_12m"}
|
||
if ret_period != "Any" and ret_min is not None:
|
||
col2 = _rcol.get(ret_period)
|
||
if col2 and col2 in df.columns:
|
||
df = df[df[col2].fillna(-999) >= ret_min / 100.0]
|
||
|
||
umin = _f(state.get("upside_min",""))
|
||
if umin is not None and "analyst_upside" in df.columns:
|
||
df = df[df["analyst_upside"].fillna(-999) >= umin / 100.0]
|
||
|
||
sort_by = state.get("sort_by","Score ↓")
|
||
if sort_by == "Price ↓" and "price" in df.columns:
|
||
df = df.sort_values("price", ascending=False, na_position="last")
|
||
elif sort_by == "Price ↑" and "price" in df.columns:
|
||
df = df.sort_values("price", ascending=True, na_position="last")
|
||
elif sort_by == "Upside ↓" and "analyst_upside" in df.columns:
|
||
df = df.sort_values("analyst_upside", ascending=False, na_position="last")
|
||
|
||
_log3(f"calling populate with {len(df)} rows")
|
||
self._table.populate(df)
|
||
_log3("populate done")
|
||
n = len(df)
|
||
self._status_lbl.setText(f" Showing {n} stocks. Click a ticker to see details.")
|
||
|
||
# Refresh selected ticker if still present
|
||
if self._selected_ticker and self._df is not None:
|
||
match = self._df[self._df["ticker"] == self._selected_ticker]
|
||
if not match.empty:
|
||
self._detail.update(match.iloc[0])
|
||
|
||
def _open_filters(self):
|
||
if self._filters_dlg is None:
|
||
self._filters_dlg = FiltersDialog(self)
|
||
self._filters_dlg.filters_changed.connect(self._apply_filters)
|
||
self._filters_dlg.show()
|
||
self._filters_dlg.raise_()
|
||
|
||
def _on_strategy_change(self, strategy):
|
||
if strategy == "Custom...":
|
||
self._open_custom_strategy_dialog()
|
||
return
|
||
if self._df_raw is not None and not self._rescoring:
|
||
self._rescoring = True
|
||
self._rescore_id += 1
|
||
current_id = self._rescore_id
|
||
weights = STRATEGY_PRESETS.get(strategy)
|
||
self._status_lbl.setText(f" Rescoring with strategy: {strategy}…")
|
||
def _rescore():
|
||
try:
|
||
result = score_stocks(self._df_raw, weights=weights, sector_stats=self._sector_stats)
|
||
except Exception:
|
||
self._rescoring = False
|
||
return
|
||
def _done():
|
||
self._rescoring = False
|
||
if self._rescore_id != current_id: return
|
||
self._df = result
|
||
if hasattr(self._app, '_df'): self._app._df = result
|
||
self._apply_filters()
|
||
self._status_lbl.setText(f" Strategy: {strategy} — rescored {len(result)} stocks.")
|
||
QMetaObject.invokeMethod(self, "_apply_filters", Qt.QueuedConnection)
|
||
threading.Thread(target=_rescore, daemon=True).start()
|
||
|
||
def _open_custom_strategy_dialog(self):
|
||
dlg = QDialog(self)
|
||
dlg.setWindowTitle("Custom Strategy Weights")
|
||
dlg.setModal(True)
|
||
lay = QVBoxLayout(dlg)
|
||
lbl = QLabel("Set factor weights (must sum to 100%)")
|
||
lbl.setStyleSheet(f"color:{_C['fg']}; font-weight:700;")
|
||
lay.addWidget(lbl)
|
||
|
||
FACTORS = [
|
||
("value","Value"),("growth","Growth"),("momentum","Momentum"),
|
||
("quality","Quality"),("profitability","Profitability"),
|
||
("sentiment","Sentiment"),("analyst","Analyst"),("risk","Risk"),
|
||
]
|
||
strategy = self._strategy_cb.currentText()
|
||
base = self._custom_weights or STRATEGY_PRESETS.get(strategy, WEIGHTS)
|
||
entries = {}
|
||
grid = QGridLayout()
|
||
for i, (key, lbl_txt) in enumerate(FACTORS):
|
||
grid.addWidget(QLabel(lbl_txt), i, 0)
|
||
e = QLineEdit(f"{base[key]*100:.1f}")
|
||
e.setFixedWidth(80)
|
||
entries[key] = e
|
||
grid.addWidget(e, i, 1)
|
||
grid.addWidget(QLabel("%"), i, 2)
|
||
lay.addLayout(grid)
|
||
|
||
sum_lbl = QLabel("Sum: 100.0%")
|
||
sum_lbl.setStyleSheet(f"color:{_C['positive']};")
|
||
lay.addWidget(sum_lbl)
|
||
|
||
def _update_sum():
|
||
total = 0.0
|
||
for e in entries.values():
|
||
try: total += float(e.text())
|
||
except: pass
|
||
c = _C["positive"] if abs(total - 100) < 0.5 else _C["negative"]
|
||
sum_lbl.setText(f"Sum: {total:.1f}%")
|
||
sum_lbl.setStyleSheet(f"color:{c};")
|
||
|
||
for e in entries.values(): e.textChanged.connect(_update_sum)
|
||
|
||
btn_row = QWidget(); brl = QHBoxLayout(btn_row)
|
||
apply_btn = QPushButton("Apply"); cancel_btn = QPushButton("Cancel")
|
||
brl.addWidget(apply_btn); brl.addWidget(cancel_btn)
|
||
lay.addWidget(btn_row)
|
||
|
||
def _apply():
|
||
w = {}
|
||
for key, e in entries.items():
|
||
try: w[key] = float(e.text()) / 100.0
|
||
except: return
|
||
total = sum(w.values())
|
||
if total <= 0: return
|
||
w = {k: v / total for k, v in w.items()}
|
||
self._custom_weights = w
|
||
dlg.accept()
|
||
if self._df_raw is not None:
|
||
self._df = score_stocks(self._df_raw, weights=w, sector_stats=self._sector_stats)
|
||
if hasattr(self._app, '_df'): self._app._df = self._df
|
||
self._apply_filters()
|
||
|
||
def _cancel():
|
||
if not self._custom_weights:
|
||
self._strategy_cb.blockSignals(True)
|
||
self._strategy_cb.setCurrentText("Balanced (Default)")
|
||
self._strategy_cb.blockSignals(False)
|
||
dlg.reject()
|
||
|
||
apply_btn.clicked.connect(_apply)
|
||
cancel_btn.clicked.connect(_cancel)
|
||
dlg.exec()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SearchPage
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SearchPage(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._df = None
|
||
self._df_raw = None
|
||
self._sector_stats = {}
|
||
self._get_weights = lambda: None
|
||
self._last_row = None
|
||
self._results_shown = False
|
||
self._build()
|
||
|
||
def set_data(self, df, df_raw, sector_stats, get_weights):
|
||
self._df = df
|
||
self._df_raw = df_raw
|
||
self._sector_stats = sector_stats
|
||
self._get_weights = get_weights or (lambda: None)
|
||
|
||
def _build(self):
|
||
self._main_lay = QStackedLayout(self)
|
||
|
||
# Hero page
|
||
hero_w = QWidget()
|
||
hl = QVBoxLayout(hero_w)
|
||
hl.addStretch()
|
||
center = QWidget()
|
||
cl = QVBoxLayout(center)
|
||
cl.setSpacing(12)
|
||
cl.setAlignment(Qt.AlignCenter)
|
||
|
||
title = QLabel(_APP_NAME.upper())
|
||
title.setStyleSheet(f"color:{_C['fg']}; font-size:24px; font-weight:700;")
|
||
title.setAlignment(Qt.AlignCenter)
|
||
cl.addWidget(title)
|
||
|
||
self._search_edit = QLineEdit()
|
||
self._search_edit.setPlaceholderText("Ticker or company name…")
|
||
self._search_edit.setFixedWidth(400)
|
||
self._search_edit.returnPressed.connect(self._do_search)
|
||
cl.addWidget(self._search_edit, alignment=Qt.AlignCenter)
|
||
|
||
search_btn = QPushButton("Search Stock")
|
||
search_btn.setObjectName("run-btn")
|
||
search_btn.setFixedWidth(160)
|
||
search_btn.clicked.connect(self._do_search)
|
||
cl.addWidget(search_btn, alignment=Qt.AlignCenter)
|
||
|
||
self._search_status = QLabel("")
|
||
self._search_status.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;")
|
||
self._search_status.setAlignment(Qt.AlignCenter)
|
||
cl.addWidget(self._search_status)
|
||
|
||
hl.addWidget(center)
|
||
hl.addStretch()
|
||
self._main_lay.addWidget(hero_w)
|
||
|
||
# Results page
|
||
results_w = QWidget()
|
||
rl = QVBoxLayout(results_w)
|
||
rl.setContentsMargins(0, 0, 0, 0)
|
||
rl.setSpacing(0)
|
||
|
||
hdr = QWidget()
|
||
hdr_lay = QHBoxLayout(hdr)
|
||
hdr_lay.setContentsMargins(12, 6, 12, 6)
|
||
self._status2 = QLabel("")
|
||
self._status2.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;")
|
||
hdr_lay.addWidget(self._status2)
|
||
hdr_lay.addStretch()
|
||
back_btn = QPushButton("← Back")
|
||
back_btn.clicked.connect(self._go_back)
|
||
hdr_lay.addWidget(back_btn)
|
||
rl.addWidget(hdr)
|
||
|
||
self._detail = DetailPanel()
|
||
rl.addWidget(self._detail, 1)
|
||
self._main_lay.addWidget(results_w)
|
||
|
||
def _go_back(self):
|
||
self._main_lay.setCurrentIndex(0)
|
||
self._results_shown = False
|
||
self._search_edit.clear()
|
||
self._search_status.setText("")
|
||
|
||
def _do_search(self):
|
||
query = self._search_edit.text().strip().lstrip("$")
|
||
if not query: return
|
||
self._search_status.setText("Searching…")
|
||
threading.Thread(target=self._search_worker, args=(query,), daemon=True).start()
|
||
|
||
def _search_worker(self, query):
|
||
row = self._find_in_df(query)
|
||
if row is not None:
|
||
import pandas as pd
|
||
if isinstance(row, pd.Series): row = row.to_dict()
|
||
QMetaObject.invokeMethod(self, "_show_result", Qt.QueuedConnection,
|
||
Q_ARG(object, row), Q_ARG(str, f"Found in screener data: {row['ticker']}"))
|
||
return
|
||
|
||
QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection, Q_ARG(str, "Fetching data…"))
|
||
fetch_result = [None]
|
||
def _do():
|
||
fetch_result[0] = self._fetch_live(query)
|
||
t = threading.Thread(target=_do, daemon=True)
|
||
t.start(); t.join(timeout=20)
|
||
|
||
if t.is_alive():
|
||
QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, "Search timed out. Check your connection and try again."))
|
||
return
|
||
|
||
live_row = fetch_result[0]
|
||
if live_row is None:
|
||
QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, f"No results for '{query}'. Try the exact ticker symbol."))
|
||
return
|
||
|
||
# Score it
|
||
try:
|
||
import pandas as pd
|
||
_w = self._get_weights()
|
||
scored = score_stocks(pd.DataFrame([live_row]), weights=_w, sector_stats=self._sector_stats)
|
||
if not scored.empty:
|
||
s = scored.iloc[0]
|
||
for col in ("composite_score","score_value","score_growth","score_momentum",
|
||
"score_quality","score_profitability","score_sentiment","score_analyst","score_risk"):
|
||
if col in s: live_row[col] = s[col]
|
||
except Exception: pass
|
||
|
||
QMetaObject.invokeMethod(self, "_show_result", Qt.QueuedConnection,
|
||
Q_ARG(object, live_row), Q_ARG(str, f"Live data: {live_row['ticker']}"))
|
||
|
||
def _find_in_df(self, query):
|
||
if self._df is None or self._df.empty: return None
|
||
q = query.upper().strip().lstrip("$")
|
||
mask = self._df["ticker"].str.upper() == q
|
||
if mask.any(): return self._df[mask].iloc[0]
|
||
if "name" in self._df.columns:
|
||
mask2 = self._df["name"].str.upper().str.contains(q, regex=False, na=False)
|
||
if mask2.any(): return self._df[mask2].iloc[0]
|
||
return None
|
||
|
||
def _fetch_live(self, query):
|
||
if not _YF_OK: return None
|
||
import pandas as pd
|
||
ticker = query.upper().strip().lstrip("$")
|
||
try:
|
||
stock = yf.Ticker(ticker)
|
||
info = stock.info
|
||
if not info or not isinstance(info, dict) or len(info) < 3:
|
||
try:
|
||
results = yf.Search(query, max_results=1)
|
||
quotes = getattr(results, "quotes", [])
|
||
if quotes:
|
||
ticker = quotes[0].get("symbol", ticker)
|
||
stock = yf.Ticker(ticker)
|
||
info = stock.info
|
||
except: pass
|
||
if not info or not isinstance(info, dict) or len(info) < 3: return None
|
||
|
||
def _g(k, default=None):
|
||
v = info.get(k, default)
|
||
return v if v not in (None, "N/A", "", "None") else default
|
||
|
||
hist = stock.history(period="1y", auto_adjust=True)
|
||
close = hist["Close"].dropna() if not hist.empty else pd.Series(dtype=float)
|
||
price = close.iloc[-1] if not close.empty else _g("regularMarketPrice")
|
||
ret_1m = (close.iloc[-1]/close.iloc[-22]-1) if len(close) >= 22 else None
|
||
ret_3m = (close.iloc[-1]/close.iloc[-66]-1) if len(close) >= 66 else None
|
||
ret_6m = (close.iloc[-1]/close.iloc[-130]-1) if len(close) >= 130 else None
|
||
ret_12m= (close.iloc[-1]/close.iloc[0]-1) if len(close) >= 2 else None
|
||
|
||
_company_name = _g("longName") or _g("shortName") or ""
|
||
try:
|
||
_news_agg, _news_headlines = get_news_headlines(stock, ticker=ticker, company_name=_company_name)
|
||
except:
|
||
_news_agg, _news_headlines = None, []
|
||
|
||
return {
|
||
"ticker": ticker,
|
||
"name": _g("longName") or _g("shortName") or ticker,
|
||
"sector": _g("sector","N/A"), "industry": _g("industry","N/A"),
|
||
"price": price, "market_cap": _g("marketCap"),
|
||
"pe_forward": _g("forwardPE"), "pe_trailing": _g("trailingPE"),
|
||
"pb_ratio": _g("priceToBook"), "book_value": _g("bookValue"),
|
||
"ev_ebitda": _g("enterpriseToEbitda"),
|
||
"52w_high": _g("fiftyTwoWeekHigh"), "52w_low": _g("fiftyTwoWeekLow"),
|
||
"revenue_growth": _g("revenueGrowth"), "earnings_growth": _g("earningsGrowth"),
|
||
"eps_forward": _g("forwardEps"), "eps_trailing": _g("trailingEps"),
|
||
"ret_1m": ret_1m, "ret_3m": ret_3m, "ret_6m": ret_6m, "ret_12m": ret_12m,
|
||
"roe": _g("returnOnEquity"), "roa": _g("returnOnAssets"),
|
||
"total_debt": _g("totalDebt"), "debt_to_equity": _g("debtToEquity"),
|
||
"total_cash": _g("totalCash"), "current_ratio": _g("currentRatio"),
|
||
"revenue": _g("totalRevenue"), "profit_margin": _g("profitMargins"),
|
||
"operating_margin": _g("operatingMargins"),
|
||
"fcf_yield": (float(_g("freeCashflow"))/float(_g("marketCap"))
|
||
if _g("freeCashflow") and _g("marketCap") and float(_g("marketCap")) > 0 else None),
|
||
"dividend_yield": _g("dividendYield"),
|
||
"recommendation": _g("recommendationKey"),
|
||
"analyst_count": _g("numberOfAnalystOpinions"),
|
||
"analyst_target": _g("targetMeanPrice"),
|
||
"analyst_upside": ((_g("targetMeanPrice") or 0) / price - 1
|
||
if price and price > 0 and _g("targetMeanPrice") else None),
|
||
"analyst_norm": ((5.0 - float(info["recommendationMean"])) / 4.0
|
||
if info.get("recommendationMean") is not None
|
||
and 1 <= float(info["recommendationMean"]) <= 5 else None),
|
||
"news_sentiment": _news_agg, "news_headlines": _news_headlines,
|
||
"short_percent": _g("shortPercentOfFloat"), "beta": _g("beta"),
|
||
"volatility_30d": (float(close.iloc[-30:].pct_change().dropna().std() * (252**0.5))
|
||
if len(close) >= 35 else None),
|
||
"composite_score": None, "score_value": None, "score_growth": None,
|
||
"score_momentum": None, "score_quality": None, "score_profitability": None,
|
||
"score_sentiment": None, "score_analyst": None, "score_risk": None,
|
||
"business_summary": _g("longBusinessSummary","") or "",
|
||
}
|
||
except: return None
|
||
|
||
@Slot(object, str)
|
||
def _show_result(self, row, status_msg):
|
||
self._last_row = row
|
||
self._search_status.setText(status_msg)
|
||
self._status2.setText(status_msg)
|
||
self._detail.update(row)
|
||
self._main_lay.setCurrentIndex(1)
|
||
self._results_shown = True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# InsiderTable
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class InsiderTable(QWidget):
|
||
_COLS = [
|
||
("Date",70,"center"),("Ticker",65,"center"),("Company",175,"left"),
|
||
("Insider",155,"left"),("Title",125,"left"),("Type",90,"center"),
|
||
("Shares",90,"right"),("Price",70,"right"),("Value",90,"right"),("After",95,"right"),
|
||
]
|
||
_PERIODS = [("Today",1),("3D",3),("1W",7),("2W",14)]
|
||
_TYPES = [("All","all"),("Buys","buy"),("Sales","sale")]
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._all_trades = []
|
||
self._iid_to_url = {}
|
||
self._loading = False
|
||
self._has_loaded = False
|
||
self._days_back = 3
|
||
self._type_filter = "all"
|
||
self._build()
|
||
|
||
def _build(self):
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
# Controls
|
||
ctrl = QWidget(); ctrl.setFixedHeight(44)
|
||
cl = QHBoxLayout(ctrl); cl.setContentsMargins(12,6,12,6); cl.setSpacing(8)
|
||
title = QLabel("Insider Transactions")
|
||
title.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700;")
|
||
cl.addWidget(title)
|
||
|
||
self._period_btns = {}
|
||
for lbl, days in self._PERIODS:
|
||
btn = QPushButton(lbl)
|
||
btn.setCheckable(True); btn.setChecked(days == self._days_back)
|
||
btn.clicked.connect(lambda checked, d=days: self._set_period(d))
|
||
cl.addWidget(btn)
|
||
self._period_btns[days] = btn
|
||
|
||
cl.addWidget(self._vsep())
|
||
self._type_btns = {}
|
||
for lbl, val in self._TYPES:
|
||
btn = QPushButton(lbl)
|
||
btn.setCheckable(True); btn.setChecked(val == "all")
|
||
btn.clicked.connect(lambda checked, v=val: self._set_type(v))
|
||
cl.addWidget(btn)
|
||
self._type_btns[val] = btn
|
||
|
||
cl.addWidget(self._vsep())
|
||
self._refresh_btn = QPushButton("↻ Refresh")
|
||
self._refresh_btn.setObjectName("run-btn")
|
||
self._refresh_btn.clicked.connect(self._refresh)
|
||
cl.addWidget(self._refresh_btn)
|
||
|
||
self._status_lbl = QLabel("Click ↻ Refresh to load insider trading data.")
|
||
self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;")
|
||
cl.addWidget(self._status_lbl)
|
||
cl.addStretch()
|
||
lay.addWidget(ctrl)
|
||
|
||
sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep)
|
||
|
||
self._table = QTableWidget(0, len(self._COLS))
|
||
self._table.setHorizontalHeaderLabels([c[0] for c in self._COLS])
|
||
self._table.verticalHeader().setVisible(False)
|
||
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||
self._table.setShowGrid(False)
|
||
self._table.setAlternatingRowColors(False)
|
||
self._table.horizontalHeader().setStretchLastSection(False)
|
||
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||
for i, (_, w, _a) in enumerate(self._COLS):
|
||
self._table.setColumnWidth(i, w)
|
||
self._table.clicked.connect(self._on_row_click)
|
||
lay.addWidget(self._table, 1)
|
||
|
||
def _vsep(self):
|
||
f = QFrame(); f.setFixedSize(1, 20); f.setStyleSheet(f"background:{_C['border']};"); return f
|
||
|
||
def _set_period(self, days):
|
||
self._days_back = days
|
||
for d, btn in self._period_btns.items(): btn.setChecked(d == days)
|
||
self._refresh()
|
||
|
||
def _set_type(self, val):
|
||
self._type_filter = val
|
||
for v, btn in self._type_btns.items(): btn.setChecked(v == val)
|
||
self._apply_filter()
|
||
|
||
def on_show(self):
|
||
if not self._has_loaded: self._refresh()
|
||
|
||
def _refresh(self):
|
||
if self._loading: return
|
||
self._loading = True
|
||
self._refresh_btn.setEnabled(False)
|
||
self._status_lbl.setText("Fetching from SEC EDGAR...")
|
||
days = self._days_back
|
||
|
||
def _progress(done, total):
|
||
QMetaObject.invokeMethod(self._status_lbl, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, f"Loading... ({done}/{total} filings)"))
|
||
|
||
def _worker():
|
||
try: trades = fetch_insider_trades(days_back=days, progress_cb=_progress)
|
||
except: trades = []
|
||
QMetaObject.invokeMethod(self, "_on_fetched", Qt.QueuedConnection, Q_ARG(object, trades))
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
@Slot(object)
|
||
def _on_fetched(self, trades):
|
||
self._loading = False; self._has_loaded = True
|
||
self._all_trades = trades
|
||
self._refresh_btn.setEnabled(True)
|
||
self._apply_filter()
|
||
n = len(trades)
|
||
self._status_lbl.setText(f"{n} transaction{'s' if n!=1 else ''} loaded." if n else "No transactions found.")
|
||
|
||
def _apply_filter(self):
|
||
trades = self._all_trades or []
|
||
if self._type_filter == "buy": trades = [t for t in trades if t.get("transaction_code") == "P"]
|
||
elif self._type_filter == "sale": trades = [t for t in trades if t.get("transaction_code") == "S"]
|
||
self._populate(trades)
|
||
|
||
def _populate(self, trades):
|
||
self._table.setRowCount(0)
|
||
self._iid_to_url.clear()
|
||
for i, tx in enumerate(trades):
|
||
tc = tx.get("transaction_code","")
|
||
date_str = tx.get("tx_date") or tx.get("filed_date") or ""
|
||
try:
|
||
y, m, d = date_str[:10].split("-")
|
||
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
|
||
except: pass
|
||
|
||
color = {"P": _C["positive"], "S": _C["negative"], "A": _C["warning"]}.get(tc, _C["fg2"])
|
||
vals = [
|
||
date_str, tx.get("ticker","—"), (tx.get("company") or "")[:26],
|
||
(tx.get("insider") or "")[:22], (tx.get("title") or "")[:18],
|
||
tx.get("transaction_type","—"), _fmt_shares(tx.get("shares")),
|
||
_price(tx.get("price")) if tx.get("price") else "—",
|
||
_fmt_value(tx.get("value")), _fmt_shares(tx.get("owned_after")),
|
||
]
|
||
self._table.insertRow(i)
|
||
for j, v in enumerate(vals):
|
||
item = QTableWidgetItem(str(v))
|
||
item.setForeground(QColor(color))
|
||
bg = QColor("#181818") if i % 2 else QColor("#0a0a0a")
|
||
item.setBackground(bg)
|
||
self._table.setItem(i, j, item)
|
||
self._iid_to_url[i] = tx.get("url","")
|
||
self._table.setRowHeight(0, 28)
|
||
for r in range(self._table.rowCount()):
|
||
self._table.setRowHeight(r, 28)
|
||
|
||
def _on_row_click(self, index):
|
||
url = self._iid_to_url.get(index.row(),"")
|
||
if url: webbrowser.open(url)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HedgeFundTable
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class HedgeFundTable(QWidget):
|
||
_COLS = [
|
||
("Filed",82,"center"),("Fund",195,"left"),("Company",185,"left"),
|
||
("Shares",90,"right"),("Value",90,"right"),("Class",60,"center"),("Type",45,"center"),
|
||
]
|
||
_PERIODS = [("30D",30),("60D",60),("90D",90),("180D",180)]
|
||
_MIN_VALS = [("All",0),("$1M+",1_000_000),("$10M+",10_000_000),("$100M+",100_000_000)]
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._all_holdings = []
|
||
self._iid_to_url = {}
|
||
self._loading = False
|
||
self._has_loaded = False
|
||
self._days_back = 90
|
||
self._min_val = 1_000_000
|
||
self._build()
|
||
|
||
def _build(self):
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
ctrl = QWidget(); ctrl.setFixedHeight(44)
|
||
cl = QHBoxLayout(ctrl); cl.setContentsMargins(12,6,12,6); cl.setSpacing(8)
|
||
title = QLabel("Hedge Fund Holdings (13F-HR)")
|
||
title.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700;")
|
||
cl.addWidget(title)
|
||
|
||
self._period_btns = {}
|
||
for lbl, days in self._PERIODS:
|
||
btn = QPushButton(lbl); btn.setCheckable(True); btn.setChecked(days == self._days_back)
|
||
btn.clicked.connect(lambda checked, d=days: self._set_period(d))
|
||
cl.addWidget(btn); self._period_btns[days] = btn
|
||
|
||
cl.addWidget(self._vsep())
|
||
self._minval_btns = {}
|
||
for lbl, val in self._MIN_VALS:
|
||
btn = QPushButton(lbl); btn.setCheckable(True); btn.setChecked(val == self._min_val)
|
||
btn.clicked.connect(lambda checked, v=val: self._set_min_val(v))
|
||
cl.addWidget(btn); self._minval_btns[val] = btn
|
||
|
||
cl.addWidget(self._vsep())
|
||
self._refresh_btn = QPushButton("↻ Refresh")
|
||
self._refresh_btn.setObjectName("run-btn")
|
||
self._refresh_btn.clicked.connect(self._refresh)
|
||
cl.addWidget(self._refresh_btn)
|
||
|
||
self._status_lbl = QLabel("Click ↻ Refresh to load hedge fund holdings.")
|
||
self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;")
|
||
cl.addWidget(self._status_lbl)
|
||
cl.addStretch()
|
||
lay.addWidget(ctrl)
|
||
|
||
sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep)
|
||
|
||
self._table = QTableWidget(0, len(self._COLS))
|
||
self._table.setHorizontalHeaderLabels([c[0] for c in self._COLS])
|
||
self._table.verticalHeader().setVisible(False)
|
||
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||
self._table.setShowGrid(False)
|
||
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||
for i, (_, w, _a) in enumerate(self._COLS):
|
||
self._table.setColumnWidth(i, w)
|
||
self._table.clicked.connect(self._on_row_click)
|
||
lay.addWidget(self._table, 1)
|
||
|
||
def _vsep(self):
|
||
f = QFrame(); f.setFixedSize(1, 20); f.setStyleSheet(f"background:{_C['border']};"); return f
|
||
|
||
def _set_period(self, days):
|
||
self._days_back = days
|
||
for d, btn in self._period_btns.items(): btn.setChecked(d == days)
|
||
self._refresh()
|
||
|
||
def _set_min_val(self, val):
|
||
self._min_val = val
|
||
for v, btn in self._minval_btns.items(): btn.setChecked(v == val)
|
||
self._apply_filter()
|
||
|
||
def on_show(self):
|
||
if not self._has_loaded: self._refresh()
|
||
|
||
def _refresh(self):
|
||
if self._loading: return
|
||
self._loading = True; self._refresh_btn.setEnabled(False)
|
||
self._status_lbl.setText("Fetching from SEC EDGAR...")
|
||
days = self._days_back
|
||
|
||
def _progress(done, total):
|
||
QMetaObject.invokeMethod(self._status_lbl, "setText", Qt.QueuedConnection,
|
||
Q_ARG(str, f"Loading... ({done}/{total} funds)"))
|
||
|
||
def _worker():
|
||
try: holdings = fetch_hedge_fund_filings(days_back=days, progress_cb=_progress)
|
||
except: holdings = []
|
||
QMetaObject.invokeMethod(self, "_on_fetched", Qt.QueuedConnection, Q_ARG(object, holdings))
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
@Slot(object)
|
||
def _on_fetched(self, holdings):
|
||
self._loading = False; self._has_loaded = True
|
||
self._all_holdings = holdings; self._refresh_btn.setEnabled(True)
|
||
self._apply_filter()
|
||
n = len(holdings)
|
||
self._status_lbl.setText(f"{n} holding{'s' if n!=1 else ''} loaded." if n else "No holdings found.")
|
||
|
||
def _apply_filter(self):
|
||
rows = [h for h in self._all_holdings if (h.get("value") or 0) >= self._min_val] if self._min_val > 0 else list(self._all_holdings)
|
||
self._populate(rows)
|
||
|
||
def _populate(self, holdings):
|
||
self._table.setRowCount(0)
|
||
self._iid_to_url.clear()
|
||
for i, h in enumerate(holdings):
|
||
date_str = h.get("filed_date","")
|
||
try:
|
||
y, m, d = date_str[:10].split("-")
|
||
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
|
||
except: pass
|
||
opt = (h.get("put_call") or "").strip()
|
||
color = _C["warning"] if opt else _C["fg"]
|
||
vals = [
|
||
date_str, (h.get("fund_name") or "")[:28], (h.get("company") or "")[:26],
|
||
_fmt_shares(h.get("shares")), _fmt_value(h.get("value")),
|
||
(h.get("class_") or "")[:8], opt[:4] if opt else "—",
|
||
]
|
||
self._table.insertRow(i)
|
||
for j, v in enumerate(vals):
|
||
item = QTableWidgetItem(str(v))
|
||
item.setForeground(QColor(color))
|
||
item.setBackground(QColor("#181818") if i % 2 else QColor("#0a0a0a"))
|
||
self._table.setItem(i, j, item)
|
||
self._iid_to_url[i] = h.get("url","")
|
||
for r in range(self._table.rowCount()):
|
||
self._table.setRowHeight(r, 28)
|
||
|
||
def _on_row_click(self, index):
|
||
url = self._iid_to_url.get(index.row(),"")
|
||
if url: webbrowser.open(url)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TrackingPage
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TrackingPage(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._active_sub = "insider"
|
||
self._build()
|
||
|
||
def _build(self):
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
# Sub-nav
|
||
subnav = QWidget(); subnav.setFixedHeight(44)
|
||
sl = QHBoxLayout(subnav); sl.setContentsMargins(12,8,12,8); sl.setSpacing(8)
|
||
self._insider_btn = QPushButton("Insider Trades")
|
||
self._insider_btn.setCheckable(True); self._insider_btn.setChecked(True)
|
||
self._insider_btn.clicked.connect(lambda: self._switch("insider"))
|
||
self._hf_btn = QPushButton("Hedge Funds")
|
||
self._hf_btn.setCheckable(True)
|
||
self._hf_btn.clicked.connect(lambda: self._switch("hf"))
|
||
sl.addWidget(self._insider_btn); sl.addWidget(self._hf_btn); sl.addStretch()
|
||
lay.addWidget(subnav)
|
||
|
||
sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};")
|
||
lay.addWidget(sep)
|
||
|
||
self._stack = QStackedWidget()
|
||
self._insider = InsiderTable()
|
||
self._hf = HedgeFundTable()
|
||
self._stack.addWidget(self._insider)
|
||
self._stack.addWidget(self._hf)
|
||
lay.addWidget(self._stack, 1)
|
||
|
||
def _switch(self, which):
|
||
self._active_sub = which
|
||
self._insider_btn.setChecked(which == "insider")
|
||
self._hf_btn.setChecked(which == "hf")
|
||
self._stack.setCurrentIndex(0 if which == "insider" else 1)
|
||
self.on_show()
|
||
|
||
def on_show(self):
|
||
if self._active_sub == "hf": self._hf.on_show()
|
||
else: self._insider.on_show()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ScreenerApp
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ScreenerApp(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle(f"{_APP_NAME} v{_APP_VERSION} — {_channel_label}")
|
||
self.setMinimumSize(900, 560)
|
||
self._df = None
|
||
self._df_raw = None
|
||
self._sector_stats = {}
|
||
self._build_ui()
|
||
self.showMaximized()
|
||
|
||
def _build_ui(self):
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
layout = QHBoxLayout(central)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
self._sidebar = Sidebar()
|
||
self._sidebar.page_changed.connect(self._switch_page)
|
||
layout.addWidget(self._sidebar)
|
||
|
||
self._stack = QStackedWidget()
|
||
layout.addWidget(self._stack, 1)
|
||
|
||
self._screener_page = ScreenerPage(self)
|
||
self._search_page = SearchPage(self)
|
||
self._tracking_page = TrackingPage(self)
|
||
|
||
self._stack.addWidget(self._screener_page)
|
||
self._stack.addWidget(self._search_page)
|
||
self._stack.addWidget(self._tracking_page)
|
||
|
||
def _switch_page(self, idx):
|
||
self._stack.setCurrentIndex(idx)
|
||
if idx == 1:
|
||
self._search_page.set_data(
|
||
self._df, self._df_raw, self._sector_stats,
|
||
self._screener_page.get_current_weights
|
||
)
|
||
elif idx == 2:
|
||
self._tracking_page.on_show()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry point
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main():
|
||
multiprocessing.freeze_support()
|
||
try:
|
||
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
||
except Exception:
|
||
pass
|
||
app = QApplication(sys.argv)
|
||
app.setStyleSheet(_QSS)
|
||
win = ScreenerApp()
|
||
win.show()
|
||
# Force window onto primary screen
|
||
primary = app.primaryScreen()
|
||
geo = primary.availableGeometry()
|
||
win.move(geo.x() + (geo.width() - win.width()) // 2,
|
||
geo.y() + (geo.height() - win.height()) // 2)
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|