Stock-Tool/dist/screener_gui.py

5530 lines
242 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Ultimate Investment Tool GUI
============================
Graphical interface for the multi-factor stock screener.
Run: python screener_gui.py
Build exe: build.bat
"""
import ctypes
import html as _html
import io
import multiprocessing
import queue
import re
import sys
import threading
import tkinter as tk
import webbrowser
from tkinter import ttk
import numpy as np
_MATPLOTLIB_OK = False
_MATPLOTLIB_ERR = ""
try:
import matplotlib
matplotlib.use("TkAgg")
import matplotlib.ticker as mticker
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import matplotlib.dates as mdates
_MATPLOTLIB_OK = True
except Exception as _e:
_MATPLOTLIB_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)
# Short descriptions shown as tooltips when hovering strategy dropdown items
STRATEGY_DESCRIPTIONS = {
"Balanced (Default)": (
"Best for: General all-purpose screening.\n\n"
"Blends all 8 factors evenly — no strong directional bias. "
"Good starting point for building a diversified watch list."
),
"High Growth Companies": (
"Best for: High-velocity growth investing.\n\n"
"Heavily weights revenue/earnings growth (28%) and price momentum (25%). "
"Targets fast-growing tech, biotech, and SaaS. "
"Higher risk — low weight on value and safety metrics."
),
"Conservative": (
"Best for: Capital preservation and low volatility.\n\n"
"Prioritises balance sheet quality (25%) and profitability (18%). "
"Targets blue-chip companies with low debt and stable earnings."
),
"Recent Uptrends": (
"Best for: Short-term technical trend-following.\n\n"
"Momentum dominates at 40% — driven by 6-month price return. "
"News sentiment adds 18%. Valuation nearly irrelevant. "
"Best used in bull markets with high portfolio turnover."
),
"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%). "
"Risk factor ignored — long holding period absorbs volatility. "
"Classic fundamental investing approach."
),
"Low Risk, High Dividend Yield": (
"Best for: Income-focused portfolios seeking stable cash flow.\n\n"
"Profitability (28%) and quality (22%) dominate. Targets mature companies "
"converting revenue to free cash flow reliably. Suitable for retirees."
),
"High Risk High Reward": (
"Best for: Contrarian bounce plays on oversold names.\n\n"
"Reverse momentum (35%) rewards beaten-down stocks. Analyst upside (30%) "
"filters for institutional conviction. Quality floor (15%) avoids value traps."
),
"Custom...": (
"Define your own strategy.\n\n"
"Set custom weights for each of the 8 scoring factors. "
"Click to open the configuration dialog."
),
}
multiprocessing.freeze_support()
# Read update channel from license cache (set during auth in launcher.py)
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"
_WINDOW_TITLE = f"{_APP_NAME} v{_APP_VERSION}{_channel_label}"
# Tell Windows the process handles its own DPI scaling (crisp rendering).
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2) # per-monitor DPI aware
except Exception:
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
# ---------------------------------------------------------------------------
# DPI scaling — computed HERE at module load via the Windows GDI API,
# BEFORE any Tk window is created.
#
# Why not winfo_fpixels()? That call requires a visible window that has
# been assigned to a monitor. When called immediately after Tk() in
# __init__, the window is still hidden/off-screen, so Windows reports the
# primary monitor DPI — which may differ from the monitor the maximised
# window actually lands on. GetDeviceCaps on the screen DC is always
# consistent and works before any window exists.
#
# _S : scale factor (actual PPI / 96). 1.0 at 100%, 1.25 at 125%, etc.
# _s(n): scale any hard-coded pixel value for the current display DPI.
# ---------------------------------------------------------------------------
_S: float = 1.0
try:
_hdc = ctypes.windll.user32.GetDC(0) # 0 = screen DC
_dpi = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 88) # LOGPIXELSX = 88
ctypes.windll.user32.ReleaseDC(0, _hdc)
if _dpi > 0:
_S = _dpi / 96.0
except Exception:
_S = 1.0
def _s(n: float) -> int:
"""Return n scaled to the current display DPI (minimum 1 pixel)."""
return max(1, int(n * _S))
# ---------------------------------------------------------------------------
# Sector filter (display name → (dataframe column, exact value to match))
# None means "no filter" (show all sectors).
# Defense is a sub-industry inside Industrials, so it filters on "industry".
# ---------------------------------------------------------------------------
_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())
# ---------------------------------------------------------------------------
# Theme
# ---------------------------------------------------------------------------
T = {
"bg": "#000000", # pure black — main background
"bg2": "#000000", # same black — seamless panels
"bg3": "#0d0d0d", # near-black — barely raised surfaces
"border": "#0d0d0d", # near-invisible — seamless separators
"fg": "#ffffff", # white — primary text
"fg2": "#555555", # gray — secondary / dim text
"accent": "#00e676", # bright green — primary accent
"green": "#00e676", # green — positive indicators
"red": "#ff4545", # red — negative indicators
"yellow": "#ffd740", # amber — neutral indicators
"sel": "#00261a", # dark green — selection highlight
"row_odd": "#040404", # near-black row stripe
"row_even": "#000000", # pure black row stripe
"font_ui": ("Segoe UI", 11),
"font_mono": ("Consolas", 11),
"font_head": ("Segoe UI", 12, "bold"),
"font_sec": ("Segoe UI", 10, "bold"),
"font_small": ("Segoe UI", 10),
}
# ---------------------------------------------------------------------------
# Stdout redirector
# ---------------------------------------------------------------------------
_PROGRESS_RE = re.compile(
r"(\d+)/(\d+).*?ok=(\d+).*?skip=(\d+).*?ETA=(\d+(?:\.\d+)?)"
)
# Matches new fetch_all Phase-1 output: " 123/456 scanned valid=89 (27%)"
_PHASE1_RE = re.compile(r"(\d+)/(\d+)\s+scanned\s+valid=(\d+)")
class _StdoutRedirector(io.TextIOBase):
def __init__(self, q: queue.Queue, run_id: int = 0):
self._q = q
self._run_id = run_id
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._q.put({"type": "progress", "run_id": self._run_id,
"done": int(done), "total": int(total),
"ok": int(ok), "skip": int(skip), "eta": float(eta)})
else:
# Match Phase-1 price-history progress: "N/N scanned valid=V (X%)"
m2 = _PHASE1_RE.search(s)
if m2:
done, total, ok = m2.groups()
self._q.put({"type": "progress", "run_id": self._run_id,
"done": int(done), "total": int(total),
"ok": int(ok), "skip": 0, "eta": 0.0})
else:
self._q.put({"type": "status", "run_id": self._run_id,
"text": s.strip()})
return len(s)
def flush(self):
pass
# ---------------------------------------------------------------------------
# Formatters
# ---------------------------------------------------------------------------
def _nan(v):
return v is None or (isinstance(v, float) and np.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 _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 _score(v):
return "" if _nan(v) else f"{v:.1f}"
def _fmt_shares(v):
"""Format a share count with commas; returns '' for None."""
if v is None: return ""
return f"{int(v):,}"
def _fmt_value(v):
"""Format a dollar value compactly: $1.2M, $340K, etc."""
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}"
# ---------------------------------------------------------------------------
# Tooltip
# ---------------------------------------------------------------------------
class _Tooltip:
"""
Attaches a dark-themed popup to a tk.Label.
Strategy:
- <Enter> → always show immediately (reliable trigger).
- <Motion> → hide if the cursor has drifted outside the text bounding
box (padding area), so the popup disappears the moment the
cursor leaves the actual characters.
- <Leave> → hide when the cursor leaves the widget entirely.
font, padx, pady, anchor are passed explicitly at construction time so
no fragile runtime widget inspection is needed.
"""
_PAD = 8
def __init__(self, widget: tk.Widget, text: str,
font=None, padx: int = 0, pady: int = 0,
anchor: str = "center"):
self._w = widget
self._text = text.strip()
self._tip: tk.Toplevel | None = None
self._padx = padx
self._pady = pady
self._anchor = anchor
self._font_spec = font # kept as a tuple for tk.call font commands
widget.bind("<Enter>", self._show, add="+")
widget.bind("<Motion>", self._on_motion, add="+")
widget.bind("<Leave>", self._hide, add="+")
widget.bind("<Button>", self._hide, add="+")
def _text_bbox(self) -> tuple[int, int, int, int]:
"""(x1, y1, x2, y2) of the rendered text inside the widget.
Uses Tk's built-in 'font measure' / 'font metrics' so no Python
tkinter.font module is required (safe in frozen .exe builds)."""
w = self._w
ww, wh = w.winfo_width(), w.winfo_height()
if self._font_spec is None or ww <= 1:
return 0, 0, ww, wh # fallback: accept full widget
try:
txt = w.cget("text")
tw = int(w.tk.call("font", "measure", self._font_spec, txt))
th = int(w.tk.call("font", "metrics", self._font_spec, "-linespace"))
if self._anchor in ("w", "nw", "sw"): x1 = self._padx
elif self._anchor in ("e", "ne", "se"): x1 = ww - self._padx - tw
else: x1 = (ww - tw) // 2
if self._anchor in ("n", "nw", "ne"): y1 = self._pady
elif self._anchor in ("s", "sw", "se"): y1 = wh - self._pady - th
else: y1 = (wh - th) // 2
return x1, y1, x1 + tw, y1 + th
except Exception:
return 0, 0, ww, wh
def _on_motion(self, event):
x1, y1, x2, y2 = self._text_bbox()
if not (x1 <= event.x <= x2 and y1 <= event.y <= y2):
self._hide()
def _show(self, _event=None):
if self._tip or not self._text:
return
x = self._w.winfo_rootx() + self._PAD
y = self._w.winfo_rooty() + self._w.winfo_height() + 4
self._tip = tw = tk.Toplevel(self._w)
tw.wm_overrideredirect(True)
tw.wm_attributes("-topmost", True)
tw.wm_geometry(f"+{x}+{y}")
tw.configure(bg=T["bg3"])
tk.Label(
tw, text=self._text,
bg=T["bg3"], fg=T["fg"],
font=("Segoe UI", 9),
justify="left", wraplength=340,
padx=12, pady=8,
).pack()
def _hide(self, _event=None):
if self._tip:
self._tip.destroy()
self._tip = None
# ---------------------------------------------------------------------------
# Metric tooltip text (keyed by the display label shown in the detail panel)
# ---------------------------------------------------------------------------
_METRIC_TOOLTIPS: dict[str, str] = {
# ── Scores ──────────────────────────────────────────────────────────────
"Composite Score": (
"The overall investment attractiveness of the stock, scored 0100.\n\n"
"Calculated as a weighted blend of all 8 factor scores — Value, Growth,\n"
"Momentum, Quality, Profitability, Sentiment, Analyst, and Risk.\n\n"
"The weights shift based on your selected strategy, so the same stock\n"
"can score differently under Growth vs. Conservative, for example.\n"
"A higher score means the stock looks more attractive across the\n"
"factors that matter most to your chosen investment approach."
),
"Value": (
"How cheaply the stock is priced relative to what the company earns,\n"
"owns, and generates — scored 0100.\n\n"
"A high Value score means the market is pricing the stock at a discount\n"
"compared to its earnings power and asset base. This can signal an\n"
"undervalued opportunity, though cheap stocks can stay cheap.\n\n"
"Calculated by blending three valuation ratios: how much investors\n"
"pay per dollar of earnings (P/E), per dollar of book assets (P/B),\n"
"and per dollar of operating profit including debt (EV/EBITDA)."
),
"Growth": (
"How fast the company is expanding its business — scored 0100.\n\n"
"A high Growth score means the company is meaningfully growing its\n"
"revenue and profits year over year, and analysts expect that trend\n"
"to continue. Strong growth justifies higher valuations and often\n"
"drives long-term stock price appreciation.\n\n"
"Calculated by combining year-over-year revenue growth, earnings\n"
"growth, and the gap between analysts' future EPS estimates and\n"
"the company's most recent actual earnings."
),
"Momentum": (
"How strong the stock's recent price trend has been — scored 0100.\n\n"
"Stocks that have risen steadily tend to keep rising in the near term,\n"
"a well-documented market pattern. A high Momentum score means the\n"
"stock has outperformed over recent months, which can reflect growing\n"
"investor confidence or improving fundamentals.\n\n"
"Calculated by measuring total price returns over four time windows\n"
"(1, 3, 6, and 12 months), with the 6-month window weighted most\n"
"heavily as it is historically the most predictive."
),
"Quality": (
"How financially sound and efficiently run the company is — scored 0100.\n\n"
"A high Quality score means the company earns strong returns on its\n"
"capital, carries manageable debt, and has enough liquid assets to\n"
"cover near-term obligations. High-quality businesses tend to be more\n"
"resilient during downturns.\n\n"
"Calculated from four balance-sheet metrics: return on equity, return\n"
"on assets, debt-to-equity ratio, and current ratio."
),
"Profitability": (
"How much of its revenue the company actually keeps as profit — scored 0100.\n\n"
"A highly profitable business earns wide margins and converts sales into\n"
"real cash. This is a sign of pricing power and operational efficiency,\n"
"and companies with strong profitability can self-fund growth without\n"
"relying on debt or dilutive share issuance.\n\n"
"Calculated by blending net profit margin, operating margin, and free\n"
"cash flow yield (how much cash the business generates relative to\n"
"its market value)."
),
"Sentiment": (
"The overall tone of recent news coverage about the company — scored 0100.\n\n"
"Positive news flow often reflects improving business conditions or\n"
"favorable market perception, while negative sentiment can signal\n"
"headwinds. This score captures the market narrative around a stock\n"
"that may not yet show up in financial statements.\n\n"
"Calculated by running recent headlines through an AI language model\n"
"that rates each article from strongly negative to strongly positive.\n"
"Older articles carry less weight than recent ones."
),
"Analyst": (
"How bullish professional analysts are on the stock — scored 0100.\n\n"
"Institutional analysts spend significant time modeling companies and\n"
"meeting with management. A high Analyst score means the consensus\n"
"view is favorable: analysts rate it a Buy (or better) and their\n"
"average price target sits meaningfully above the current price.\n\n"
"Calculated from three inputs: the consensus buy/sell recommendation,\n"
"the percentage upside implied by the average price target, and the\n"
"number of analysts actively covering the stock."
),
"Risk": (
"How low-risk the stock appears — scored 0100, where higher = safer.\n\n"
"This score is inverted: a high Risk score means the stock carries\n"
"relatively low risk. Risk here captures how much professional short\n"
"sellers are betting against it, how erratically the price moves\n"
"day to day, and how closely its swings track the broader market.\n\n"
"Calculated from short interest as a share of the float, 30-day\n"
"realized price volatility, and how far the stock's beta deviates\n"
"from 1.0 (the market average)."
),
# ── Valuation ───────────────────────────────────────────────────────────
"Price": (
"The most recent closing market price of the stock in USD.\n\n"
"This is the price at which the stock last traded on the exchange.\n"
"It is used as the basis for all valuation ratios shown in this panel."
),
"Market Cap": (
"The total market value of the company — share price multiplied by\n"
"the total number of shares outstanding.\n\n"
"Market cap is the most widely used measure of company size. It tells\n"
"you how much the market currently thinks the entire business is worth."
),
"P/E Forward": (
"How much investors are paying per dollar of the company's expected\n"
"future earnings over the next 12 months.\n\n"
"A lower forward P/E generally means the stock is cheaper relative to\n"
"where analysts expect profits to go. It is calculated by dividing the\n"
"current stock price by the analyst consensus EPS estimate for the\n"
"coming year. Missing or negative when a loss is expected."
),
"P/E Trailing": (
"How much investors are paying per dollar of the company's actual\n"
"earnings over the past 12 months.\n\n"
"A lower trailing P/E suggests the stock is cheaper relative to what\n"
"it has already earned. It is calculated by dividing the current price\n"
"by actual reported earnings per share. Missing or negative when the\n"
"company posted a net loss."
),
"P/B Ratio": (
"How much investors are paying relative to the company's net asset value\n"
"as recorded on its balance sheet.\n\n"
"A ratio below 1.0 means the stock trades at less than the accounting\n"
"value of its assets minus liabilities — potentially a deep value signal.\n"
"Calculated by dividing the share price by book value per share."
),
"EV / EBITDA": (
"A valuation ratio that compares the company's total value — including\n"
"its debt — to its operating earnings before non-cash charges.\n\n"
"Because it accounts for debt, it is useful for comparing companies\n"
"with different capital structures. A lower ratio generally indicates\n"
"a cheaper business. Enterprise Value is market cap plus net debt;\n"
"EBITDA strips out interest, taxes, and depreciation."
),
"52W High": (
"The highest price the stock reached over the past 52 weeks.\n\n"
"Often used as a reference point for resistance. A stock trading far\n"
"below its 52-week high may be recovering from a selloff, while one\n"
"near its high may signal ongoing strength or potential overbought conditions."
),
"52W Low": (
"The lowest price the stock reached over the past 52 weeks.\n\n"
"Often used as a reference point for support. A stock trading near its\n"
"52-week low may be deeply out of favor — a potential value opportunity\n"
"or a sign of deteriorating fundamentals, depending on the context."
),
"Book Value": (
"The per-share net worth of the company as recorded on its balance sheet —\n"
"total assets minus total liabilities, divided by shares outstanding.\n\n"
"Book value represents what shareholders would theoretically receive\n"
"if the company were liquidated at accounting values. Comparing book\n"
"value to the share price gives the P/B ratio."
),
# ── Growth ──────────────────────────────────────────────────────────────
"Revenue Growth": (
"How much the company's total sales grew compared to the same period\n"
"a year ago, expressed as a percentage.\n\n"
"Revenue growth is the top-line measure of business expansion. Sustained\n"
"positive growth indicates the company is winning more customers or\n"
"selling more product, which is a key driver of long-term value creation."
),
"Earnings Growth": (
"How much the company's net profit grew compared to a year ago,\n"
"expressed as a percentage.\n\n"
"Earnings growth measures whether the bottom line is improving alongside\n"
"revenue. A company can grow revenue while earnings shrink if costs rise\n"
"faster — so earnings growth is the more important profitability signal."
),
"EPS Forward": (
"The analyst consensus estimate for earnings per share over the next\n"
"12 months.\n\n"
"Forward EPS reflects where Wall Street expects the company's profitability\n"
"to go. A rising forward EPS relative to trailing EPS signals that analysts\n"
"expect the business to become more profitable."
),
"EPS Trailing": (
"The actual earnings per share the company reported over the past\n"
"12 months (trailing twelve months).\n\n"
"This is a backward-looking measure of profitability. It anchors the\n"
"trailing P/E ratio and is compared to Forward EPS to assess whether\n"
"analysts expect earnings to accelerate or decline."
),
# ── Momentum ────────────────────────────────────────────────────────────
"1-Month Return": (
"The stock's total price return over approximately the past month.\n\n"
"This is the shortest lookback window and captures very recent price\n"
"action. It can be noisy but is useful for spotting sudden shifts in\n"
"market sentiment. It carries a small weight in the Momentum score."
),
"3-Month Return": (
"The stock's total price return over approximately the past three months.\n\n"
"A medium-term signal that smooths out short-term noise while still\n"
"capturing recent trends. Strong 3-month returns often reflect improving\n"
"fundamentals or a change in investor sentiment."
),
"6-Month Return": (
"The stock's total price return over approximately the past six months.\n\n"
"This is the most heavily weighted momentum window because research\n"
"consistently shows that 6-month returns are the most predictive of\n"
"near-term future performance. A strong 6-month return suggests the\n"
"stock has durable buying interest behind it."
),
"12-Month Return": (
"The stock's total price return over approximately the past year.\n\n"
"A long-term momentum signal that confirms sustained directional strength.\n"
"Stocks with strong 12-month returns have demonstrated lasting investor\n"
"conviction. This window helps distinguish true momentum from brief spikes."
),
# ── Quality & Profit ────────────────────────────────────────────────────
"ROE": (
"Return on Equity — how much profit the company generates for every\n"
"dollar of shareholders' equity.\n\n"
"A high ROE means management is efficiently deploying the capital that\n"
"shareholders have invested. It is a core measure of business quality\n"
"and competitive advantage. Calculated as net income divided by\n"
"total shareholders' equity."
),
"ROA": (
"Return on Assets — how much profit the company generates for every\n"
"dollar of total assets it holds.\n\n"
"ROA measures how efficiently a company uses everything it owns —\n"
"factories, inventory, cash — to produce earnings. A higher ROA\n"
"indicates a more asset-efficient, capital-light business. Calculated\n"
"as net income divided by total assets."
),
"Revenue": (
"Total revenue earned by the company over the trailing twelve months.\n\n"
"Revenue is the top-line figure — the total amount billed to customers\n"
"before any costs are subtracted. It shows the scale of the business\n"
"and is the starting point for measuring profitability."
),
"Net Income": (
"The company's bottom-line profit over the trailing twelve months —\n"
"what remains after all expenses, interest, and taxes are paid.\n\n"
"Net income is the most direct measure of overall profitability. A\n"
"growing net income means the business is becoming more profitable,\n"
"while a shrinking or negative figure signals financial pressure.\n"
"Derived from revenue multiplied by the net profit margin."
),
"Operating Income": (
"Profit from the company's core business operations over the trailing\n"
"twelve months, before interest payments and taxes are applied.\n\n"
"Operating income strips out the effects of how the company is financed\n"
"and its tax situation, making it a cleaner view of whether the\n"
"underlying business is profitable. Derived from revenue multiplied\n"
"by the operating margin."
),
"Total Debt": (
"The total amount the company owes to creditors — both short-term\n"
"obligations due within a year and long-term borrowings.\n\n"
"High debt amplifies risk: it must be serviced regardless of business\n"
"conditions and limits financial flexibility. Comparing total debt\n"
"to cash and equity gives a sense of leverage and solvency."
),
"Total Equity": (
"The total book value belonging to shareholders — what the company\n"
"owns minus what it owes, expressed in dollar terms.\n\n"
"Equity represents the cumulative net assets built up by the business\n"
"over time. It is the denominator in ROE and a key measure of the\n"
"financial cushion available to absorb losses."
),
"Total Cash": (
"The total cash and short-term liquid investments held by the company.\n\n"
"A strong cash position gives the company flexibility to invest, acquire,\n"
"pay dividends, or weather downturns without needing to borrow. When\n"
"cash exceeds total debt, the company is in a net-cash position —\n"
"a sign of financial strength."
),
"Current Ratio": (
"A measure of short-term financial health — how easily the company\n"
"can cover its near-term obligations with its liquid assets.\n\n"
"A ratio above 2.0 is generally considered strong; below 1.0 means\n"
"current liabilities exceed current assets, which can signal liquidity\n"
"risk. Calculated by dividing current assets by current liabilities."
),
"Profit Margin": (
"The percentage of revenue that the company keeps as net profit\n"
"after all expenses.\n\n"
"A higher profit margin means the company is more efficient at\n"
"converting sales into earnings. Wide margins often reflect pricing\n"
"power, scale advantages, or a differentiated product. Calculated\n"
"as net income divided by total revenue."
),
"Operating Margin": (
"The percentage of revenue that remains as profit from core operations,\n"
"before interest and taxes are factored in.\n\n"
"Operating margin isolates how profitable the actual business is,\n"
"independent of how it is financed or taxed. It is a key indicator\n"
"of operational efficiency and pricing power. Calculated as operating\n"
"income divided by total revenue."
),
"FCF Yield": (
"How much free cash the company generates relative to its market value,\n"
"expressed as a percentage.\n\n"
"Free cash flow is what's left after the company pays its operating\n"
"costs and capital expenditures — the cash it can use to pay dividends,\n"
"buy back shares, reduce debt, or reinvest. A high FCF yield means\n"
"you're getting a lot of real cash generation for the price you pay."
),
"Dividend Yield": (
"The annual dividend payment per share as a percentage of the current\n"
"stock price.\n\n"
"Dividend yield represents the income return you receive just from\n"
"holding the stock, separate from any price appreciation. Higher yields\n"
"can be attractive for income-focused investors, but a very high yield\n"
"may sometimes signal that the market doubts the dividend's sustainability."
),
# ── Analyst ─────────────────────────────────────────────────────────────
"Recommendation": (
"The aggregated buy/sell opinion of all professional analysts currently\n"
"covering the stock.\n\n"
"Analysts at investment banks and research firms rate stocks on a scale\n"
"from Strong Buy to Strong Sell based on in-depth financial modeling\n"
"and company access. The consensus shown here blends all active ratings\n"
"into a single summary view."
),
"# Analysts": (
"The number of professional analysts actively covering this stock with\n"
"ratings and/or price targets.\n\n"
"More analyst coverage generally means higher institutional interest\n"
"and greater confidence in the consensus view. A stock covered by\n"
"30+ analysts has a well-formed market opinion; one covered by only\n"
"12 analysts carries more uncertainty in the consensus."
),
"Price Target": (
"The average 12-month price target set by all analysts currently\n"
"covering the stock.\n\n"
"Each analyst publishes a target price representing where they think\n"
"the stock will trade in roughly one year. The figure shown here is\n"
"the mean of all active targets. Compare it to the current price\n"
"to see the implied upside or downside."
),
"Upside to Target": (
"The percentage gain implied by the analyst consensus price target\n"
"relative to the current share price.\n\n"
"A positive number means analysts collectively expect the stock to\n"
"appreciate from here; a negative number means their target is below\n"
"the current price. This is one of the strongest inputs into the\n"
"Analyst score."
),
"News Sentiment": (
"A summary score of how positive or negative recent news coverage\n"
"about the company has been, ranging from 1 (very negative) to +1.\n\n"
"Recent headlines are analyzed by an AI language model that assigns\n"
"each article a sentiment score based on tone and word choice. Older\n"
"articles are weighted less than recent ones. The final number reflects\n"
"the overall media narrative around the stock right now."
),
"Short Interest": (
"The percentage of the stock's freely tradable shares that are currently\n"
"sold short by investors betting the price will fall.\n\n"
"High short interest (above 10% of the float) signals significant\n"
"institutional skepticism about the company's prospects. It can also\n"
"set up a short squeeze if the stock rallies and short sellers rush\n"
"to cover their positions."
),
"Beta": (
"A measure of how much the stock tends to move relative to the\n"
"broader market.\n\n"
"A beta of 1.0 means the stock moves in line with the market. Above 1.0\n"
"means it amplifies market swings — higher potential reward but more\n"
"volatility. Below 1.0 means it moves less than the market — more\n"
"stability but typically lower upside in bull markets."
),
"30D Volatility": (
"How much the stock's price has fluctuated on a day-to-day basis over\n"
"the past 30 trading days, scaled to an annual rate.\n\n"
"Higher volatility means larger and more unpredictable price swings,\n"
"which increases the risk of short-term losses. It is calculated from\n"
"the standard deviation of daily price returns, then annualized so\n"
"it can be compared across different stocks."
),
}
# ---------------------------------------------------------------------------
# Main application
# ---------------------------------------------------------------------------
class ScreenerApp(tk.Tk):
def __init__(self):
super().__init__()
# ── DPI font calibration ────────────────────────────────────────────
# _S is already computed at module level via GetDeviceCaps (before any
# Tk window existed). Here we only sync Tcl/Tk's scaling factor so
# font *point* sizes are converted to the correct physical pixel count.
try:
self.tk.call('tk', 'scaling', (_S * 96.0) / 72.0)
except Exception:
pass
self.title(_WINDOW_TITLE)
self.minsize(900, 560)
self.state("zoomed")
self.configure(bg=T["bg"])
self._q: queue.Queue = queue.Queue()
self._df = None
self._stop_event = threading.Event()
self._run_id = 0
self._sort_col = None
self._sort_asc = False
self._df_raw = None # unscored raw data — kept for re-scoring on strategy change
self._sector_stats = {} # cached sector stats — loaded once per screen run
self._rescoring = False # True while a strategy re-score thread is running
self._rescore_id = 0 # incremented on each re-score to discard stale results
self._info_popup = None
self._selected_ticker = None
self._score_bars: dict = {}
# Filter variables (used by popup and _apply_filters)
self._filter_price_min = tk.StringVar()
self._filter_price_max = tk.StringVar()
self._filter_mktcap = tk.StringVar(value="Any")
self._filter_score_min = tk.StringVar()
self._filter_ret_period = tk.StringVar(value="Any")
self._filter_ret_min = tk.StringVar()
self._filter_upside_min = tk.StringVar()
self._filter_sort_by = tk.StringVar(value="Score ↓")
self._search_var = tk.StringVar()
self._custom_weights = None
self._filters_window = None
self._apply_styles()
self._build_tab_bar()
self._build_toolbar()
self._build_progress_bar()
self._build_main_pane()
self._build_status_bar()
self.after(100, self._poll_queue)
# ------------------------------------------------------------------ #
# Styles #
# ------------------------------------------------------------------ #
def _apply_styles(self):
s = ttk.Style(self)
s.theme_use("clam")
s.configure(".", background=T["bg"], foreground=T["fg"], font=T["font_ui"],
bordercolor=T["border"], darkcolor=T["bg"], lightcolor=T["bg"],
troughcolor=T["bg3"], selectbackground=T["sel"],
selectforeground=T["accent"])
# ── Treeview ────────────────────────────────────────────────────
s.configure("Treeview",
background=T["bg"], foreground=T["fg"],
fieldbackground=T["bg"], rowheight=_s(30),
borderwidth=0, font=T["font_mono"])
s.configure("Treeview.Heading",
background=T["bg"], foreground=T["fg2"],
font=T["font_sec"], relief="flat", padding=(6, 6))
s.map("Treeview",
background=[("selected", T["sel"])],
foreground=[("selected", T["accent"])])
s.map("Treeview.Heading",
background=[("active", T["bg3"])],
foreground=[("active", T["accent"])])
# ── Scrollbar ────────────────────────────────────────────────────
s.configure("TScrollbar",
background=T["bg3"], troughcolor=T["bg"],
borderwidth=0, arrowsize=12, relief="flat",
arrowcolor=T["bg3"])
s.map("TScrollbar",
background=[("active", T["fg2"])])
# ── Buttons ──────────────────────────────────────────────────────
s.configure("Accent.TButton",
background=T["accent"], foreground="#000000",
font=("Segoe UI", 10, "bold"), padding=(16, 7), relief="flat", borderwidth=0)
s.map("Accent.TButton",
background=[("active", "#00c060")],
foreground=[("active", "#000000")])
# ── Combobox ─────────────────────────────────────────────────────
s.configure("TSpinbox",
background=T["accent"], foreground="#000000",
fieldbackground=T["accent"], insertcolor="#000000",
arrowcolor="#000000", bordercolor=T["bg"], borderwidth=0)
s.configure("TCheckbutton",
background=T["bg"], foreground=T["fg2"], font=T["font_small"])
s.configure("TCombobox",
background=T["accent"], foreground="#000000",
fieldbackground=T["accent"], selectbackground=T["accent"],
selectforeground="#000000", insertcolor="#000000",
arrowcolor="#000000", bordercolor=T["bg"],
borderwidth=0, relief="flat", padding=0, font=T["font_small"])
s.map("TCombobox",
fieldbackground=[("readonly", T["accent"])],
foreground=[("readonly", "#000000")],
selectbackground=[("readonly", T["accent"])],
background=[("active", "#00c060")],
arrowcolor=[("active", "#000000")])
# Strip the field border drawn by the clam theme
try:
s.layout("TCombobox", [
("Combobox.field", {"sticky": "nswe", "children": [
("Combobox.downarrow", {"side": "right", "sticky": "ns"}),
("Combobox.padding", {"expand": "1", "sticky": "nswe", "children": [
("Combobox.textarea", {"sticky": "nswe"})
]})
]})
])
except Exception:
pass
# ── Progress bar ─────────────────────────────────────────────────
s.configure("Progress.Horizontal.TProgressbar",
troughcolor=T["bg3"], background=T["accent"],
borderwidth=0, thickness=3)
# ── Notebook (tabs) ──────────────────────────────────────────────
s.configure("TNotebook",
background=T["bg"], borderwidth=0, tabmargins=(0, 0, 0, 0))
s.configure("TNotebook.Tab",
background=T["bg"], foreground=T["fg2"],
padding=(18, 8), font=("Segoe UI", 10), borderwidth=0)
s.map("TNotebook.Tab",
background=[("selected", T["bg"]), ("active", T["bg3"])],
foreground=[("selected", T["accent"]), ("active", T["fg"])])
# Remove notebook content area border
try:
s.layout("TNotebook", [("Notebook.client", {"sticky": "nswe"})])
except Exception:
pass
s.configure("TFrame", background=T["bg"])
# Minimal scrollbar — thin and unobtrusive
s.configure("TScrollbar", width=6, arrowsize=0,
background=T["bg3"], troughcolor=T["bg"],
borderwidth=0, relief="flat", arrowcolor=T["bg"])
# ------------------------------------------------------------------ #
# Toolbar #
# ------------------------------------------------------------------ #
def _build_toolbar(self):
outer = tk.Frame(self, bg=T["bg"])
outer.pack(fill="x", side="top")
self._toolbar_frame = outer
bar = tk.Frame(outer, bg=T["bg"], pady=_s(9))
bar.pack(fill="x", padx=_s(12))
tk.Frame(outer, bg=T["border"], height=1).pack(fill="x")
self._run_btn = _RoundedButton(bar, "▶ Run", self._on_run,
width=_s(100), height=_s(34), radius=_s(10))
self._run_btn.pack(side="left", padx=(0, _s(6)))
self._stop_btn = _RoundedButton(bar, "■ Stop", self._on_stop,
width=_s(100), height=_s(34), radius=_s(10),
bg=T["bg"], fg=T["red"], border=T["red"])
self._stop_btn.pack(side="left", padx=(0, _s(4)))
self._stop_btn.set_state("disabled")
def _sep():
tk.Frame(bar, bg=T["border"], width=1, height=_s(20)).pack(
side="left", padx=_s(10), fill="y")
_sep()
tk.Label(bar, text="Show", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
self._top_var = tk.StringVar(value="100")
_RoundedDropdown(bar, self._top_var, ["100", "200", "300", "400", "500", "All"],
width=_s(68), height=_s(28),
on_change=self._apply_filters
).pack(side="left", padx=(_s(6), 0))
_sep()
tk.Label(bar, text="Index", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
self._index_var = tk.StringVar(value="All")
_RoundedDropdown(bar, self._index_var, INDEX_CHOICES,
width=_s(110), height=_s(28)
).pack(side="left", padx=(_s(6), 0))
_sep()
tk.Label(bar, text="Sector", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
self._sector_var = tk.StringVar(value="All Sectors")
_RoundedDropdown(bar, self._sector_var, SECTOR_CHOICES,
width=_s(120), height=_s(28),
on_change=self._apply_filters
).pack(side="left", padx=(_s(6), 0))
_sep()
tk.Label(bar, text="Strategy", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
self._strategy_var = tk.StringVar(value="Balanced (Default)")
_RoundedDropdown(bar, self._strategy_var,
list(STRATEGY_PRESETS.keys()) + ["Custom..."],
width=_s(160), height=_s(28),
on_change=self._on_strategy_change,
tooltips=STRATEGY_DESCRIPTIONS,
).pack(side="left", padx=(_s(6), 0))
_sep()
_RoundedButton(bar, "⚙ Filters", self._open_filters_dialog,
width=_s(100), height=_s(30), radius=_s(8)
).pack(side="left", padx=(0, _s(4)))
self._lastrun_var = tk.StringVar(value="")
tk.Label(bar, textvariable=self._lastrun_var, bg=T["bg"], fg=T["fg2"],
font=("Segoe UI", 9)).pack(side="right")
# ------------------------------------------------------------------ #
# Tab bar #
# ------------------------------------------------------------------ #
def _build_tab_bar(self):
outer = tk.Frame(self, bg=T["bg"])
outer.pack(fill="x", side="top")
bar = tk.Frame(outer, bg=T["bg"])
bar.pack(fill="x", padx=_s(12), pady=0)
tk.Frame(outer, bg=T["border"], height=1).pack(fill="x")
# Help button — always visible, flush right
_RoundedButton(bar, "?", self._open_about_dialog,
width=_s(30), height=_s(30), radius=_s(8),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"]
).pack(side="right", pady=_s(8), padx=(_s(4), 0))
# App title / version / channel — always visible on the left
tk.Label(bar, text=f" {_APP_NAME.upper()}", bg=T["bg"], fg=T["accent"],
font=("Segoe UI", 13, "bold")).pack(side="left", padx=(0, _s(4)))
tk.Label(bar, text=f"v{_APP_VERSION}", bg=T["bg"], fg=T["fg2"],
font=("Segoe UI", 9)).pack(side="left", padx=(0, _s(6)))
_channel_badge_color = T["yellow"] if _CHANNEL == "beta" else T["accent"]
tk.Label(bar, text=_channel_label.upper(), bg=_channel_badge_color, fg="#000000",
font=("Segoe UI", 8, "bold"), padx=_s(6), pady=_s(2)
).pack(side="left", padx=(0, _s(14)))
self._tab_btn_refs = {}
_tab_cmds = {
"Screener": self._show_screener_tab,
"Search": self._show_search_tab,
"Tracking": self._show_tracking_tab,
}
tabs = [("Screener", True), ("Search", False), ("Tracking", False)]
for name, active in tabs:
cmd = _tab_cmds[name]
btn = _RoundedButton(bar, name, cmd,
width=_s(100), height=_s(30), radius=_s(8),
bg=T["accent"] if active else T["bg3"],
fg="#000000" if active else T["fg2"],
border=T["accent"] if active else T["bg3"])
btn.pack(side="left", pady=_s(8), padx=(0, _s(6)))
self._tab_btn_refs[name] = btn
def _update_tab_active(self, active_name: str):
for name, btn in self._tab_btn_refs.items():
is_active = (name == active_name)
btn._bg = T["accent"] if is_active else T["bg3"]
btn._fg = "#000000" if is_active else T["fg2"]
btn._border = T["accent"] if is_active else T["bg3"]
btn.itemconfig(btn._rect, fill=btn._bg, outline=btn._border)
btn.itemconfig(btn._lbl, fill=btn._fg)
def _show_screener_tab(self):
if hasattr(self, "_search_view") and self._search_view is not None:
self._search_view.pack_forget()
if hasattr(self, "_tracking_view") and self._tracking_view is not None:
self._tracking_view.pack_forget()
self._toolbar_frame.pack(fill="x", side="top", before=self._progress_bar)
self._main_frame.pack(fill="both", expand=True, padx=10, pady=(6, 10))
self._update_tab_active("Screener")
def _show_search_tab(self):
self._toolbar_frame.pack_forget()
self._main_frame.pack_forget()
if hasattr(self, "_tracking_view") and self._tracking_view is not None:
self._tracking_view.pack_forget()
if not hasattr(self, "_search_view") or self._search_view is None:
self._search_view = _SearchTabView(
self, df=self._df, df_raw=self._df_raw,
get_weights=self._get_current_weights,
get_sector_stats=lambda: self._sector_stats,
)
else:
self._search_view.update_df(self._df, self._df_raw)
self._search_view.pack(fill="both", expand=True)
self._update_tab_active("Search")
def _show_tracking_tab(self):
self._toolbar_frame.pack_forget()
self._main_frame.pack_forget()
if hasattr(self, "_search_view") and self._search_view is not None:
self._search_view.pack_forget()
if not hasattr(self, "_tracking_view") or self._tracking_view is None:
self._tracking_view = _TrackingTabView(self)
self._tracking_view.pack(fill="both", expand=True)
self._tracking_view.on_show()
self._update_tab_active("Tracking")
# ------------------------------------------------------------------ #
# About popup #
# ------------------------------------------------------------------ #
def _open_about_dialog(self):
"""Open a popup showing build version, channel, and latest release notes."""
win = tk.Toplevel(self)
win.title("About")
win.configure(bg=T["bg"])
win.resizable(False, False)
win.geometry(f"{_s(440)}x{_s(340)}")
win.transient(self)
win.grab_set()
tk.Label(win, text=_APP_NAME, bg=T["bg"], fg=T["accent"],
font=("Segoe UI", 14, "bold")).pack(pady=(22, 4))
tk.Label(win, text=f"Version {_APP_VERSION}{_channel_label} Build",
bg=T["bg"], fg=T["fg2"],
font=("Segoe UI", 10)).pack(pady=(0, 14))
tk.Frame(win, bg=T["border"], height=1).pack(fill="x", padx=20)
tk.Label(win, text="Release Notes", bg=T["bg"], fg=T["fg"],
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(10, 4))
notes_frame = tk.Frame(win, bg=T["bg3"], padx=1, pady=1)
notes_frame.pack(fill="both", expand=True, padx=20, pady=(0, 14))
notes_text = tk.Text(notes_frame, bg=T["bg3"], fg=T["fg"],
font=("Segoe UI", 9), wrap="word",
relief="flat", borderwidth=0, padx=10, pady=8,
state="disabled", height=8)
notes_text.pack(fill="both", expand=True)
def _set_notes(text: str):
notes_text.config(state="normal")
notes_text.delete("1.0", "end")
notes_text.insert("1.0", text)
notes_text.config(state="disabled")
_set_notes("Loading…")
def _fetch():
try:
from updater import get_release_notes
_, notes = get_release_notes()
except Exception:
notes = "Unable to fetch release notes."
win.after(0, lambda: _set_notes(notes))
threading.Thread(target=_fetch, daemon=True).start()
_RoundedButton(win, "Close", win.destroy,
width=_s(80), height=_s(30), radius=_s(8),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"]).pack(pady=(0, _s(18)))
# ------------------------------------------------------------------ #
# Filters popup #
# ------------------------------------------------------------------ #
def _open_filters_dialog(self):
"""Open the post-screen filters panel (non-modal, stays on top)."""
if self._filters_window is not None:
try:
self._filters_window.lift()
self._filters_window.focus_set()
return
except tk.TclError:
pass
win = tk.Toplevel(self)
self._filters_window = win
win.title("Filters")
win.configure(bg=T["bg"])
win.resizable(False, False)
win.attributes("-topmost", True)
def _on_close():
self._filters_window = None
win.destroy()
win.protocol("WM_DELETE_WINDOW", _on_close)
def _entry(parent, var, width=10):
pe = _PillEntry(parent, var, width_chars=width, height=26)
pe.entry.bind("<FocusOut>", self._apply_filters)
pe.entry.bind("<Return>", self._apply_filters)
return pe
def _cb(parent, var, values, width=16):
px_w = max(_s(80), width * _s(7) + _s(24))
return _RoundedDropdown(parent, var, values,
width=px_w, height=_s(26),
on_change=self._apply_filters)
def _row_lbl(text, r):
tk.Label(win, text=text, bg=T["bg"], fg=T["fg2"],
font=T["font_small"], anchor="w", width=11
).grid(row=r, column=0, padx=(18, 6), pady=5, sticky="w")
def _hsep(r):
tk.Frame(win, bg=T["border"], height=1).grid(
row=r, column=0, columnspan=3, sticky="ew", padx=14, pady=(4, 2))
# ── Title ──────────────────────────────────────────────────────────
tk.Label(win, text="Post-Screen Filters", bg=T["bg"], fg=T["accent"],
font=T["font_head"], pady=10
).grid(row=0, column=0, columnspan=3, padx=18, sticky="w")
r = 1
# ── Price ──────────────────────────────────────────────────────────
_row_lbl("Price $", r)
pf = tk.Frame(win, bg=T["bg"])
pf.grid(row=r, column=1, columnspan=2, padx=(0, 18), pady=5, sticky="w")
_entry(pf, self._filter_price_min, 8).pack(side="left")
tk.Label(pf, text="", bg=T["bg"], fg=T["accent"],
font=T["font_small"]).pack(side="left", padx=5)
_entry(pf, self._filter_price_max, 8).pack(side="left")
r += 1
# ── Sort by ────────────────────────────────────────────────────────
_row_lbl("Sort by", r)
_cb(win, self._filter_sort_by,
["Score ↓", "Price ↓", "Price ↑", "Upside ↓"], width=12
).grid(row=r, column=1, padx=(0, 18), pady=5, sticky="w")
r += 1
_hsep(r); r += 1
# ── Market cap ─────────────────────────────────────────────────────
_row_lbl("Market Cap", r)
_cb(win, self._filter_mktcap,
["Any", "Mega (>$100B)", "Large ($10B$100B)", "Mid ($2B$10B)", "Small (<$2B)"],
width=16
).grid(row=r, column=1, padx=(0, 18), pady=5, sticky="w")
r += 1
# ── Min score ──────────────────────────────────────────────────────
_row_lbl("Min Score", r)
_entry(win, self._filter_score_min, 8
).grid(row=r, column=1, padx=(0, 18), pady=5, sticky="w")
r += 1
_hsep(r); r += 1
# ── Return filter ──────────────────────────────────────────────────
_row_lbl("Return", r)
rf = tk.Frame(win, bg=T["bg"])
rf.grid(row=r, column=1, columnspan=2, padx=(0, 18), pady=5, sticky="w")
_cb(rf, self._filter_ret_period, ["Any", "1M", "3M", "6M", "12M"], width=4
).pack(side="left")
tk.Label(rf, text="", bg=T["bg"], fg=T["accent"],
font=T["font_small"]).pack(side="left", padx=(8, 3))
_entry(rf, self._filter_ret_min, 6).pack(side="left")
tk.Label(rf, text="%", bg=T["bg"], fg=T["accent"],
font=T["font_small"]).pack(side="left", padx=(3, 0))
r += 1
_hsep(r); r += 1
# ── Upside to target ───────────────────────────────────────────────
_row_lbl("Min Upside", r)
uf = tk.Frame(win, bg=T["bg"])
uf.grid(row=r, column=1, columnspan=2, padx=(0, 18), pady=5, sticky="w")
_entry(uf, self._filter_upside_min, 6).pack(side="left")
tk.Label(uf, text="% to target", bg=T["bg"], fg=T["accent"],
font=T["font_small"]).pack(side="left", padx=(3, 0))
r += 1
_hsep(r); r += 1
# ── Search ─────────────────────────────────────────────────────────
_row_lbl("Search", r)
se = _entry(win, self._search_var, 18)
se.grid(row=r, column=1, columnspan=2, padx=(0, 18), pady=5, sticky="w")
se.entry.bind("<KeyRelease>", self._apply_filters)
r += 1
# ── Close button ───────────────────────────────────────────────────
_hsep(r); r += 1
close_wrap = tk.Frame(win, bg=T["bg"])
close_wrap.grid(row=r, column=0, columnspan=3, pady=(_s(6), _s(14)))
_RoundedButton(close_wrap, "Close", _on_close,
width=_s(90), height=_s(30), radius=_s(8)).pack()
# Clicking any non-interactive surface defocuses the active entry,
# which fires <FocusOut> → _apply_filters automatically.
# Guard: do NOT steal focus from Entry widgets or _RoundedDropdown canvases —
# the latter also opens a popup whose own grab must not be pre-empted.
def _defocus(e):
if isinstance(e.widget, (tk.Entry, _RoundedDropdown)):
return
win.focus_set()
def _bind_defocus(widget):
if isinstance(widget, (tk.Label, tk.Frame)):
widget.bind("<Button-1>", _defocus, add="+")
for child in widget.winfo_children():
_bind_defocus(child)
win.bind("<Button-1>", _defocus, add="+")
_bind_defocus(win)
win.update_idletasks()
px = self.winfo_x() + self.winfo_width() - win.winfo_reqwidth() - 20
py = self.winfo_y() + 60
win.geometry(f"+{px}+{py}")
# ------------------------------------------------------------------ #
# Progress bar #
# ------------------------------------------------------------------ #
def _build_progress_bar(self):
self._progress_var = tk.DoubleVar(value=0)
self._progress_bar = ttk.Progressbar(self, variable=self._progress_var, maximum=100,
style="Progress.Horizontal.TProgressbar",
mode="determinate")
self._progress_bar.pack(fill="x", side="top")
# ------------------------------------------------------------------ #
# Main pane — horizontal split: ticker list | tabbed detail #
# ------------------------------------------------------------------ #
def _build_main_pane(self):
self._main_frame = tk.Frame(self, bg=T["bg"])
self._main_frame.pack(fill="both", expand=True, padx=_s(10), pady=(_s(6), _s(10)))
outer = self._main_frame
pane = tk.PanedWindow(outer, orient="horizontal",
bg=T["bg"], sashwidth=_s(10),
sashrelief="flat", handlesize=0)
pane.pack(fill="both", expand=True)
list_frame = tk.Frame(pane, bg=T["bg"],
highlightthickness=0)
detail_frame = tk.Frame(pane, bg=T["bg"],
highlightthickness=0)
pane.add(list_frame, minsize=_s(220))
pane.add(detail_frame, minsize=_s(400))
_pane_initialized = [False]
def _on_pane_resize(event):
if not _pane_initialized[0] and event.width > 1:
_pane_initialized[0] = True
pane.sash_place(0, event.width // 2, 0)
pane.bind("<Configure>", _on_pane_resize)
self._build_table(list_frame)
self._build_detail(detail_frame)
# ------------------------------------------------------------------ #
# Ticker list (left panel) #
# ------------------------------------------------------------------ #
_COLS = [
("#", 48, "center"),
("Ticker", 72, "center"),
("Name", 180, "w"),
("Score", 60, "center"),
("Sector", 140, "w"),
]
def _get_top_n(self):
v = self._top_var.get()
return None if v == "All" else int(v)
def _on_strategy_change(self, _event=None):
"""Re-score the fetched data with the selected strategy's weights in a background thread."""
if self._rescoring:
return # Ignore if a rescore is already running
strategy = self._strategy_var.get()
if strategy == "Custom...":
self._open_custom_strategy_dialog()
return
if self._df_raw is not None:
weights = STRATEGY_PRESETS.get(strategy)
self._rescoring = True
self._rescore_id += 1
current_id = self._rescore_id
self._status_var.set(f" Rescoring with strategy: {strategy}")
def _rescore():
try:
result = score_stocks(
self._df_raw, weights=weights,
sector_stats=self._sector_stats,
)
except Exception:
self.after(0, lambda: setattr(self, "_rescoring", False))
return
def _done():
self._rescoring = False
if self._rescore_id != current_id:
return # Superseded by a newer rescore — discard
self._df = result
self._apply_filters()
self._status_var.set(
f" Strategy: {strategy} — rescored {len(self._df)} stocks.")
selected = getattr(self, "_selected_ticker", None)
if selected is not None and self._df is not None:
match = self._df[self._df["ticker"] == selected]
if not match.empty:
self._update_detail(match.iloc[0])
sv = getattr(self, "_search_view", None)
if sv is not None:
sv.update_df(self._df, self._df_raw)
sv.refresh_current_result()
self.after(0, _done)
threading.Thread(target=_rescore, daemon=True).start()
else:
sv = getattr(self, "_search_view", None)
if sv is not None:
sv.update_df(self._df, self._df_raw)
sv.refresh_current_result()
def _get_current_weights(self):
"""Return the weight dict for the currently selected strategy."""
strategy = self._strategy_var.get()
if strategy == "Custom..." and self._custom_weights:
return self._custom_weights
return STRATEGY_PRESETS.get(strategy) # None → default WEIGHTS
def _apply_filters(self, _event=None):
"""
Filter self._df by sector, price, market cap, score, and return,
then repopulate the table.
"""
if self._df is None:
return
df = self._df
# --- Sector ---
rule = _SECTOR_FILTER_MAP.get(self._sector_var.get())
if rule is not None:
col, val = rule
if col in df.columns:
df = df[df[col] == val]
# --- Search (ticker or name) ---
search_q = self._search_var.get().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]
# --- Price range ---
def _to_float(sv):
try:
return float(sv.get().replace("$", "").replace(",", "").strip())
except ValueError:
return None
# Coerce filter columns to numeric — guards against any stray string
# values that yfinance may have returned for edge-case NYSE securities.
import pandas as _pd
for _col in ("price", "market_cap", "composite_score",
"ret_1m", "ret_3m", "ret_6m", "ret_12m", "analyst_upside"):
if _col in df.columns:
df = df.copy()
df[_col] = _pd.to_numeric(df[_col], errors="coerce")
price_min = _to_float(self._filter_price_min)
price_max = _to_float(self._filter_price_max)
if price_min is not None:
df = df[df["price"].notna() & (df["price"] >= price_min)]
if price_max is not None:
df = df[df["price"].notna() & (df["price"] <= price_max)]
# --- Market cap category ---
mktcap_cat = self._filter_mktcap.get()
if mktcap_cat != "Any" and "market_cap" in df.columns:
if mktcap_cat.startswith("Mega"):
df = df[df["market_cap"].fillna(0) >= 100e9]
elif mktcap_cat.startswith("Large"):
df = df[(df["market_cap"].fillna(0) >= 10e9) &
(df["market_cap"].fillna(0) < 100e9)]
elif mktcap_cat.startswith("Mid"):
df = df[(df["market_cap"].fillna(0) >= 2e9) &
(df["market_cap"].fillna(0) < 10e9)]
elif mktcap_cat.startswith("Small"):
df = df[df["market_cap"].fillna(0) < 2e9]
# --- Minimum composite score ---
score_min = _to_float(self._filter_score_min)
if score_min is not None and "composite_score" in df.columns:
df = df[df["composite_score"].fillna(0) >= score_min]
# --- Return filter ---
ret_period = self._filter_ret_period.get()
ret_min = _to_float(self._filter_ret_min)
_ret_col_map = {"1M": "ret_1m", "3M": "ret_3m", "6M": "ret_6m", "12M": "ret_12m"}
if ret_period != "Any" and ret_min is not None:
col = _ret_col_map.get(ret_period)
if col and col in df.columns:
df = df[df[col].fillna(-999) >= ret_min / 100.0]
# --- Upside to target filter ---
upside_min = _to_float(self._filter_upside_min)
if upside_min is not None and "analyst_upside" in df.columns:
df = df[df["analyst_upside"].fillna(-999) >= upside_min / 100.0]
# --- Sort ---
sort_by = self._filter_sort_by.get()
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")
# "Score ↓" keeps the composite_score order from score_stocks
top_n = self._get_top_n()
self._populate_table(df if top_n is None else df.head(top_n))
# Update status
n_shown = len(df) if top_n is None else min(len(df), top_n)
total = len(df)
parts = []
sector = self._sector_var.get()
if sector != "All Sectors":
parts.append(sector)
if price_min is not None or price_max is not None:
lo = f"${price_min:.0f}" if price_min is not None else "$0"
hi = f"${price_max:.0f}" if price_max is not None else ""
parts.append(f"Price {lo}{hi}")
if mktcap_cat != "Any":
parts.append(mktcap_cat.split(" ")[0])
if score_min is not None:
parts.append(f"Score≥{score_min:.0f}")
if ret_period != "Any" and ret_min is not None:
parts.append(f"{ret_period}{ret_min:.0f}%")
if upside_min is not None:
parts.append(f"Upside≥{upside_min:.0f}%")
if sort_by != "Score ↓":
parts.append(f"Sort: {sort_by}")
suffix = "" + ", ".join(parts) if parts else ""
self._status_var.set(
f" Showing {n_shown} of {total} matching stocks{suffix}. "
"Click a ticker to see details.")
# If a stock is currently displayed, keep it live even after filtering
selected = getattr(self, "_selected_ticker", None)
if selected is not None and self._df is not None:
match = self._df[self._df["ticker"] == selected]
if not match.empty:
self._update_detail(match.iloc[0])
def _build_table(self, parent):
# Card header
tk.Label(parent, text="Results", bg=T["bg"], fg=T["accent"],
font=T["font_head"], anchor="w", padx=16, pady=10,
).grid(row=0, column=0, columnspan=2, sticky="ew")
tk.Frame(parent, bg=T["border"], height=1,
).grid(row=1, column=0, columnspan=2, sticky="ew")
cols = [c[0] for c in self._COLS]
self._tree = ttk.Treeview(parent, columns=cols, show="headings",
selectmode="browse")
for col, width, anchor in self._COLS:
self._tree.heading(col, text=col,
command=lambda c=col: self._sort_column(c))
self._tree.column(col, width=_s(width), anchor=anchor,
stretch=(col == "Name"))
self._tree.tag_configure("odd", background=T["row_odd"], foreground=T["fg"])
self._tree.tag_configure("even", background=T["row_even"], foreground=T["fg"])
vsb = ttk.Scrollbar(parent, orient="vertical", command=self._tree.yview)
hsb = ttk.Scrollbar(parent, orient="horizontal", command=self._tree.xview)
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._tree.grid(row=2, column=0, sticky="nsew")
vsb.grid(row=2, column=1, sticky="ns")
hsb.grid(row=3, column=0, sticky="ew")
parent.grid_rowconfigure(2, weight=1)
parent.grid_columnconfigure(0, weight=1)
self._tree.bind("<<TreeviewSelect>>", self._on_row_select)
def _populate_table(self, df):
self._tree.delete(*self._tree.get_children())
for i, (_, row) in enumerate(df.iterrows()):
tag = "odd" if i % 2 else "even"
self._tree.insert("", "end", iid=str(i), tags=(tag,), values=(
i + 1,
row["ticker"],
str(row.get("name", ""))[:30],
_score(row.get("composite_score")),
str(row.get("sector", ""))[:22],
))
def _sort_column(self, col: str):
if self._sort_col == col:
self._sort_asc = not self._sort_asc
else:
self._sort_col = col
self._sort_asc = False
data = [(self._tree.set(iid, col), iid)
for iid in self._tree.get_children("")]
def _key(v):
s = v.replace("%", "").replace("$", "").replace(",", "").strip()
try:
if s.endswith("T"): return (0, float(s[:-1]) * 1e12)
if s.endswith("B"): return (0, float(s[:-1]) * 1e9)
if s.endswith("M"): return (0, float(s[:-1]) * 1e6)
return (0, float(s))
except (ValueError, AttributeError):
return (1, s.lower())
data.sort(key=lambda x: _key(x[0]), reverse=not self._sort_asc)
for i, (_, iid) in enumerate(data):
self._tree.move(iid, "", i)
self._tree.item(iid, tags=("odd" if i % 2 else "even",))
arrow = "" if self._sort_asc else ""
for c, _, _ in self._COLS:
self._tree.heading(c, text=(c + arrow if c == col else c))
# ------------------------------------------------------------------ #
# Tabbed detail panel (right panel) #
# ------------------------------------------------------------------ #
def _build_detail(self, parent):
# Stock title bar
self._detail_title = tk.Label(
parent,
text=" ← Select a stock from the list",
bg=T["bg"], fg=T["fg2"], font=T["font_head"],
anchor="w", pady=12, padx=16)
self._detail_title.pack(fill="x", side="top")
tk.Frame(parent, bg=T["border"], height=1).pack(fill="x")
# No-data notice — shown when _data_source == "none"
self._no_data_notice = tk.Label(
parent,
text=" ⚠ No fundamental data found for this ticker — scores based on price & momentum only.",
bg="#1a1200", fg=T["yellow"], font=T["font_small"],
anchor="w", pady=6, padx=16)
# Notebook — hidden until a ticker is selected
self._notebook = _CustomNotebook(parent)
self._notebook.pack(fill="both", expand=True, padx=0, pady=0)
self._notebook.pack_forget()
self._detail_labels: dict = {}
# Scores tab — animated bar widgets
scores_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(scores_tab, text=" Scores ")
self._build_scores_tab_content(scores_tab)
for title, fields in [
("Valuation", self._VAL_FIELDS),
("Growth", self._GROWTH_FIELDS),
("Momentum", self._MOM_FIELDS),
("Quality & Profit", self._QUAL_FIELDS),
]:
tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(tab, text=f" {title} ")
labels = self._build_tab_content(tab, fields)
self._detail_labels.update(labels)
# Analyst tab — key-value metrics on top, commentary box below
analyst_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(analyst_tab, text=" Analyst ")
self._build_analyst_tab(analyst_tab)
# Chart tab
self._chart_ticker = None
chart_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(chart_tab, text=" Chart ")
self._build_chart_tab(chart_tab)
def _build_tab_content(self, parent, fields) -> dict:
"""
Scrollable two-column key/value grid inside a notebook tab.
Both scrollbars appear only when needed; content is scroll-locked otherwise.
"""
# Both scrollbars are dynamic — only shown when content overflows
hsb = ttk.Scrollbar(parent, orient="horizontal")
vsb = ttk.Scrollbar(parent, orient="vertical")
canvas = tk.Canvas(parent, bg=T["bg"], highlightthickness=0, bd=0,
xscrollcommand=hsb.set, yscrollcommand=vsb.set)
canvas.pack(side="left", fill="both", expand=True)
hsb.config(command=canvas.xview)
vsb.config(command=canvas.yview)
inner = tk.Frame(canvas, bg=T["bg"])
win_id = canvas.create_window((0, 0), window=inner, anchor="nw")
labels = {}
for i, (label, key) in enumerate(fields):
r = i * 2
# Key label
key_lbl = tk.Label(inner, text=label, bg=T["bg"], fg=T["fg2"],
font=T["font_small"], anchor="w", padx=16, pady=6,
width=18)
key_lbl.grid(row=r, column=0, sticky="w")
# Info button (column 1)
has_tip = label in _METRIC_TOOLTIPS or label == "Composite Score"
if has_tip:
btn = tk.Button(
inner, text="",
bg=T["bg"], fg=T["accent"],
activebackground=T["bg"], activeforeground=T["fg"],
font=("Segoe UI", 9), bd=0, relief="flat",
cursor="hand2", padx=2, pady=0,
)
btn.configure(command=lambda b=btn, l=label: self._show_info_popup(
b, self._get_tooltip_text(l)))
btn.grid(row=r, column=1, sticky="w", pady=6)
# Value label (column 2)
val_lbl = tk.Label(inner, text="", bg=T["bg"], fg=T["fg"],
font=("Consolas", 12), anchor="w", padx=8, pady=6)
val_lbl.grid(row=r, column=2, sticky="w")
labels[key] = val_lbl
# Separator
tk.Frame(inner, bg=T["border"], height=1
).grid(row=r + 1, column=0, columnspan=3,
sticky="ew", padx=12, pady=0)
inner.grid_columnconfigure(2, weight=1)
def _update_scrollbars(cw, ch):
iw = inner.winfo_reqwidth()
ih = inner.winfo_reqheight()
canvas.configure(scrollregion=(0, 0, iw, ih))
if ih > ch:
vsb.pack(side="right", fill="y", before=canvas)
else:
vsb.pack_forget()
canvas.yview_moveto(0)
if iw > cw:
hsb.pack(side="bottom", fill="x", before=canvas)
else:
hsb.pack_forget()
canvas.xview_moveto(0)
def _on_inner_cfg(event):
_update_scrollbars(canvas.winfo_width(), canvas.winfo_height())
def _on_canvas_cfg(event):
canvas.itemconfigure(win_id, width=max(event.width,
inner.winfo_reqwidth()))
_update_scrollbars(event.width, event.height)
inner.bind("<Configure>", _on_inner_cfg)
canvas.bind("<Configure>", _on_canvas_cfg)
def _scroll_y(e):
if inner.winfo_reqheight() > canvas.winfo_height():
canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
def _scroll_x(e):
if inner.winfo_reqwidth() > canvas.winfo_width():
canvas.xview_scroll(int(-1 * (e.delta / 120)), "units")
canvas.bind("<MouseWheel>", _scroll_y)
inner.bind("<MouseWheel>", _scroll_y)
canvas.bind("<Shift-MouseWheel>", _scroll_x)
inner.bind("<Shift-MouseWheel>", _scroll_x)
return labels
def _build_scores_tab_content(self, parent) -> None:
"""
Build the Scores tab with animated _ScoreBar widgets instead of text labels.
Bars are stored in self._score_bars (not in _detail_labels).
"""
hsb = ttk.Scrollbar(parent, orient="horizontal")
vsb = ttk.Scrollbar(parent, orient="vertical")
canvas = tk.Canvas(parent, bg=T["bg"], highlightthickness=0, bd=0,
xscrollcommand=hsb.set, yscrollcommand=vsb.set)
canvas.pack(side="left", fill="both", expand=True)
hsb.config(command=canvas.xview)
vsb.config(command=canvas.yview)
inner = tk.Frame(canvas, bg=T["bg"])
win_id = canvas.create_window((0, 0), window=inner, anchor="nw")
for i, (label, key) in enumerate(self._SCORE_FIELDS):
r = i * 2
# Label column
tk.Label(inner, text=label, bg=T["bg"], fg=T["fg2"],
font=T["font_small"], anchor="w", padx=16, pady=6,
width=18).grid(row=r, column=0, sticky="w")
# Info button column
has_tip = label in _METRIC_TOOLTIPS or label == "Composite Score"
if has_tip:
btn = tk.Button(
inner, text="",
bg=T["bg"], fg=T["accent"],
activebackground=T["bg"], activeforeground=T["fg"],
font=("Segoe UI", 9), bd=0, relief="flat",
cursor="hand2", padx=2, pady=0,
)
btn.configure(command=lambda b=btn, l=label: self._show_info_popup(
b, self._get_tooltip_text(l)))
btn.grid(row=r, column=1, sticky="w", pady=6)
# Score bar column
bar = _ScoreBar(inner, bg=T["bg"])
bar.grid(row=r, column=2, sticky="w", padx=(8, 24), pady=4)
self._score_bars[key] = bar
# Separator
tk.Frame(inner, bg=T["border"], height=1
).grid(row=r + 1, column=0, columnspan=3,
sticky="ew", padx=12, pady=0)
inner.grid_columnconfigure(2, weight=1)
def _update_scrollbars_scores(cw, ch):
iw = inner.winfo_reqwidth()
ih = inner.winfo_reqheight()
canvas.configure(scrollregion=(0, 0, iw, ih))
if ih > ch:
vsb.pack(side="right", fill="y", before=canvas)
else:
vsb.pack_forget()
canvas.yview_moveto(0)
if iw > cw:
hsb.pack(side="bottom", fill="x", before=canvas)
else:
hsb.pack_forget()
canvas.xview_moveto(0)
def _on_inner_cfg(event):
_update_scrollbars_scores(canvas.winfo_width(), canvas.winfo_height())
def _on_canvas_cfg(event):
canvas.itemconfigure(win_id, width=max(event.width,
inner.winfo_reqwidth()))
_update_scrollbars_scores(event.width, event.height)
inner.bind("<Configure>", _on_inner_cfg)
canvas.bind("<Configure>", _on_canvas_cfg)
def _scroll_y(e):
if inner.winfo_reqheight() > canvas.winfo_height():
canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
canvas.bind("<MouseWheel>", _scroll_y)
inner.bind("<MouseWheel>", _scroll_y)
def _update_score_bars(self, row) -> None:
"""Animate all score bars to the values in row. Resets first."""
import math
for key, bar in self._score_bars.items():
val = row.get(key)
try:
if val is None or (isinstance(val, float) and math.isnan(val)):
bar.reset()
else:
bar.animate_to(float(val))
except (TypeError, ValueError):
bar.reset()
def _build_analyst_tab(self, parent):
"""
Analyst tab: key-value metrics (top third) + commentary box (bottom two-thirds).
"""
# ---- Key-value metrics (top) ----
metrics_frame = tk.Frame(parent, bg=T["bg"])
metrics_frame.pack(fill="x", side="top")
labels = self._build_tab_content(metrics_frame, self._ANLST_FIELDS)
self._detail_labels.update(labels)
# ---- Divider ----
tk.Frame(parent, bg=T["border"], height=1).pack(fill="x", pady=(4, 0))
# ---- Commentary header ----
hdr = tk.Frame(parent, bg=T["bg"])
hdr.pack(fill="x")
tk.Label(hdr, text=" Analyst Commentary", bg=T["bg"], fg=T["accent"],
font=T["font_sec"], anchor="w", pady=5
).pack(side="left", padx=4)
tk.Label(hdr, text="auto-generated from market data",
bg=T["bg"], fg=T["fg2"], font=T["font_small"],
anchor="w").pack(side="left")
# ---- Commentary text box ----
txt_frame = tk.Frame(parent, bg=T["bg"])
txt_frame.pack(fill="both", expand=True, padx=6, pady=6)
self._commentary_box = tk.Text(
txt_frame,
bg=T["bg3"], fg=T["fg"],
font=("Consolas", 11),
wrap="word",
relief="flat",
bd=0,
highlightthickness=0,
padx=14, pady=10,
state="disabled",
cursor="arrow",
)
txt_vsb = ttk.Scrollbar(txt_frame, orient="vertical",
command=self._commentary_box.yview)
self._commentary_box.configure(yscrollcommand=txt_vsb.set)
self._commentary_box.pack(side="left", fill="both", expand=True)
txt_vsb.pack(side="right", fill="y")
# Text colour tags
self._commentary_box.tag_configure("heading",
foreground=T["accent"], font=("Segoe UI", 11, "bold"))
self._commentary_box.tag_configure("bullet",
foreground=T["fg"], font=("Consolas", 11))
self._commentary_box.tag_configure("positive",
foreground=T["green"], font=("Consolas", 11))
self._commentary_box.tag_configure("negative",
foreground=T["red"], font=("Consolas", 11))
self._commentary_box.tag_configure("neutral",
foreground=T["yellow"],font=("Consolas", 11))
self._commentary_box.tag_configure("verdict_buy",
foreground=T["green"], font=("Segoe UI", 12, "bold"))
self._commentary_box.tag_configure("verdict_hold",
foreground=T["yellow"],font=("Segoe UI", 12, "bold"))
self._commentary_box.tag_configure("verdict_sell",
foreground=T["red"], font=("Segoe UI", 12, "bold"))
self._commentary_box.tag_configure("dim",
foreground=T["fg2"], font=("Segoe UI", 9))
self._commentary_box.tag_configure("description",
foreground=T["fg2"], font=("Segoe UI", 10),
spacing1=2, spacing3=4)
# ------------------------------------------------------------------ #
# Chart tab #
# ------------------------------------------------------------------ #
def _build_chart_tab(self, parent, notebook=None, chart_attr="_chart_canvas",
period_attr="_chart_period", ticker_attr="_chart_ticker"):
"""Build the price-chart tab UI inside `parent`."""
self._chart_parent_frame = parent
# Period selector row
ctrl = tk.Frame(parent, bg=T["bg"])
ctrl.pack(fill="x", side="top", padx=0, pady=0)
tk.Frame(ctrl, bg=T["bg"], width=_s(12)).pack(side="left")
periods = ["1mo", "3mo", "6mo", "1y", "2y", "5y"]
labels = ["1M", "3M", "6M", "1Y", "2Y", "5Y"]
period_var = tk.StringVar(value="6mo")
setattr(self, period_attr, period_var)
for val, lbl in zip(periods, labels):
b = tk.Radiobutton(
ctrl, text=lbl, variable=period_var, value=val,
bg=T["bg"],
fg=T["fg2"],
selectcolor=T["accent"],
activebackground=T["bg"],
activeforeground=T["accent"],
font=("Segoe UI", 9, "bold"),
indicatoron=False,
relief="flat", overrelief="flat",
padx=_s(10), pady=_s(5), bd=0,
cursor="hand2",
command=lambda: self._trigger_chart_update(
ticker_attr, period_attr, chart_attr, parent),
)
b.pack(side="left", padx=1, pady=(_s(4), 0))
tk.Frame(ctrl, bg=T["border"], height=1).pack(fill="x", side="bottom")
# Canvas placeholder
placeholder = tk.Label(
parent,
text="Select a stock to view its price chart.",
bg=T["bg"], fg=T["fg2"], font=T["font_small"],
)
placeholder.pack(expand=True)
setattr(self, chart_attr + "_placeholder", placeholder)
setattr(self, chart_attr, None)
def _trigger_chart_update(self, ticker_attr, period_attr, chart_attr, parent):
ticker = getattr(self, ticker_attr, None)
period = getattr(self, period_attr).get()
if ticker:
threading.Thread(
target=self._fetch_and_render_chart,
args=(ticker, period, chart_attr, parent),
daemon=True,
).start()
def _set_chart_placeholder_text(self, chart_attr: str, text: str):
"""Update the placeholder label text (called from main thread via after())."""
ph = getattr(self, chart_attr + "_placeholder", None)
if ph:
try:
ph.config(text=text)
ph.pack(expand=True)
except Exception:
pass
def _fetch_and_render_chart(self, ticker: str, period: str,
chart_attr: str, parent: tk.Widget):
"""Fetch OHLCV from yfinance and schedule chart render on main thread."""
if not _MATPLOTLIB_OK:
msg = f"Chart unavailable: matplotlib not loaded. {_MATPLOTLIB_ERR}"
self.after(0, self._set_chart_placeholder_text, chart_attr, msg)
return
if not _YF_OK:
self.after(0, self._set_chart_placeholder_text, chart_attr,
"Chart unavailable: yfinance not loaded.")
return
try:
self.after(0, self._set_chart_placeholder_text, chart_attr,
f"Loading chart for {ticker}")
hist = yf.Ticker(ticker).history(period=period, auto_adjust=True)
if hist is None or hist.empty:
self.after(0, self._set_chart_placeholder_text, chart_attr,
f"No price history found for {ticker}.")
return
self.after(0, self._render_chart, hist, ticker, chart_attr, parent)
except Exception as e:
self.after(0, self._set_chart_placeholder_text, chart_attr,
f"Chart error: {e}")
def _render_chart(self, hist, ticker: str, chart_attr: str, parent: tk.Widget):
"""Draw OHLCV chart inside parent with interactive crosshair tooltip."""
if not _MATPLOTLIB_OK:
return
# Destroy previous canvas
old = getattr(self, chart_attr, None)
if old is not None:
try:
old.get_tk_widget().destroy()
except Exception:
pass
# Hide placeholder
ph = getattr(self, chart_attr + "_placeholder", None)
if ph:
try:
ph.pack_forget()
except Exception:
pass
import pandas as pd
import numpy as np
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
# Convert index to plain datetime for reliable hover snapping
dates = pd.to_datetime(close.index).tz_localize(None) if close.index.tz else pd.to_datetime(close.index)
prices = close.values
# Use the actual screen DPI so matplotlib's mouse-event coordinate
# system aligns with physical pixels reported by SetProcessDpiAwareness.
# Without this, event.inaxes is always None on high-DPI laptops and
# the hover info box never appears.
try:
_chart_dpi = max(72, min(300, int(parent.winfo_fpixels('1i'))))
except Exception:
_chart_dpi = 96
fig = Figure(figsize=(5, 3.8), dpi=_chart_dpi, facecolor=T["bg"])
if volume is not None:
ax_price = fig.add_axes([0.09, 0.30, 0.87, 0.62], facecolor=T["bg"])
ax_volume = fig.add_axes([0.09, 0.06, 0.87, 0.20], facecolor=T["bg"],
sharex=ax_price)
else:
ax_price = fig.add_axes([0.09, 0.08, 0.87, 0.86], facecolor=T["bg"])
ax_volume = None
# Price line
ax_price.plot(dates, prices, color=T["accent"], linewidth=1.6, label="Close")
if ma20 is not None:
ax_price.plot(dates, ma20.values, color="#ffd740",
linewidth=1.0, linestyle="--", label="20-Day Moving Average")
if ma50 is not None:
ax_price.plot(dates, ma50.values, color="#888888",
linewidth=1.0, linestyle="--", label="50-Day Moving Average")
ax_price.set_title(f"{ticker} — Price History", color=T["accent"],
fontsize=10, pad=6)
ax_price.tick_params(colors=T["fg2"], labelsize=8)
for spine in ax_price.spines.values():
spine.set_edgecolor(T["border"])
ax_price.yaxis.label.set_color(T["fg2"])
ax_price.grid(color="#111111", linewidth=0.5, linestyle="-")
ax_price.legend(facecolor=T["bg"], edgecolor=T["border"],
labelcolor=T["fg"], fontsize=8, loc="upper left")
ax_price.tick_params(axis="x", labelbottom=(ax_volume is None))
# Volume bars
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 = [T["green"] if i == 0 or prices[i] >= prices[i - 1]
else T["red"] 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=T["fg2"], labelsize=7)
for spine in ax_volume.spines.values():
spine.set_edgecolor(T["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=T["border"], linewidth=0.4, linestyle="-")
# 20-day average volume for relative context in hover box
_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
else:
vol_ma20_arr = None
# Date formatting on x-axis
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(T["fg2"])
# ── Crosshair elements ──────────────────────────────────────────
vline_p = ax_price.axvline(x=dates[0], color=T["fg2"],
linewidth=0.8, linestyle="--", visible=False)
hline_p = ax_price.axhline(y=prices[0], color=T["fg2"],
linewidth=0.8, linestyle="--", visible=False)
dot_p = ax_price.plot([], [], "o", color=T["accent"],
markersize=5, zorder=5)[0]
# Hover info box (top-right corner of price axes, inside the plot)
info_box = ax_price.text(
0.99, 0.97, "",
transform=ax_price.transAxes,
ha="right", va="top",
fontsize=8,
color=T["accent"],
bbox=dict(boxstyle="round,pad=0.4", facecolor=T["bg"],
edgecolor=T["accent"], alpha=0.96),
visible=False,
zorder=10,
)
vline_v = (ax_volume.axvline(x=dates[0], color=T["fg2"],
linewidth=0.8, linestyle="--", visible=False)
if ax_volume is not None else None)
hline_v = (ax_volume.axhline(y=0, color=T["fg2"],
linewidth=0.8, linestyle="--", visible=False)
if ax_volume is not None else None)
# Hover info box for the volume panel (bottom-right corner)
info_box_v = (
ax_volume.text(
0.99, 0.97, "",
transform=ax_volume.transAxes,
ha="right", va="top",
fontsize=7.5,
color=T["accent"],
bbox=dict(boxstyle="round,pad=0.35", facecolor=T["bg"],
edgecolor=T["accent"], alpha=0.96),
visible=False,
zorder=10,
) if ax_volume is not None else None
)
# numeric date array for fast nearest-index search
date_nums = np.array([d.timestamp() for d in dates])
def _on_move(event):
# Only respond when cursor is inside the price or volume axes
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 Exception:
pass
return
try:
# Snap to nearest data point
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])
# Daily % change vs previous close
if idx > 0:
prev = prices[idx - 1]
pct = (snap_price - prev) / prev * 100 if prev != 0 else 0.0
sign = "+" if pct >= 0 else ""
chg_str = f"\nChange: {sign}{pct:.2f}%"
else:
pct = None
chg_str = ""
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')}\n"
f"Price: ${snap_price:,.2f}"
f"{chg_str}"
f"{vol_str}"
)
info_box.set_visible(True)
if vline_v:
vline_v.set_xdata([snap_x, snap_x])
vline_v.set_visible(True)
# ── Volume panel hover info ────────────────────────────────
if (info_box_v is not None 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):,}")
# Buy / sell pressure label
if pct is not None:
if pct >= 0:
direction = f"\u2191 Buying pressure {pct:+.2f}%"
else:
direction = f"\u2193 Selling pressure {pct:+.2f}%"
else:
direction = ""
# Volume vs 20-day average
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:
ratio = v / avg
avg_fmt = (f"{avg/1e6:.2f}M" if avg >= 1e6
else f"{int(avg):,}")
avg_str = f"\nvs 20D Avg: {ratio:.2f}× ({avg_fmt})"
info_box_v.set_text(
f"{snap_date.strftime('%b %d, %Y')}\n"
f"Volume: {vol_fmt}"
+ (f"\n{direction}" if direction else "")
+ avg_str
)
info_box_v.set_visible(True)
if hline_v:
hline_v.set_ydata([v, v])
hline_v.set_visible(True)
elif info_box_v is not None:
info_box_v.set_visible(False)
if hline_v:
hline_v.set_visible(False)
canvas.draw_idle()
except Exception:
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 Exception:
pass
try:
canvas = FigureCanvasTkAgg(fig, master=parent)
canvas.draw()
canvas.get_tk_widget().pack(fill="both", expand=True, padx=4, pady=(0, 4))
canvas.get_tk_widget().config(cursor="crosshair")
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)
setattr(self, chart_attr, canvas)
except Exception as e:
self._set_chart_placeholder_text(chart_attr, f"Render error: {e}")
def _update_commentary(self, row):
"""Generate and insert analyst commentary into the text box."""
box = self._commentary_box
box.configure(state="normal")
box.delete("1.0", "end")
def ins(text, tag="bullet"):
box.insert("end", text, tag)
ticker = row["ticker"]
name = str(row.get("name", ticker))
sector = str(row.get("sector", "N/A"))
score = row.get("composite_score")
# Trim longBusinessSummary to one tight paragraph
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 ""))
# ── SUMMARY ──────────────────────────────────────────────────────
ins("" * 56 + "\n", "heading")
ins(" SUMMARY\n", "heading")
ins("" * 56 + "\n", "heading")
if not _nan(score):
if score >= 72: tier, tier_tag = "Strong Buy candidate", "verdict_buy"
elif score >= 62: tier, tier_tag = "Buy / above-average opportunity", "verdict_buy"
elif score >= 52: tier, tier_tag = "Neutral — Hold / Watch", "verdict_hold"
elif score >= 42: tier, tier_tag = "Underperform — caution advised","verdict_sell"
else: tier, tier_tag = "Avoid — significant headwinds","verdict_sell"
ins(f"\n{name} ({ticker})\n", "heading")
ins(f"Sector: {sector} ", "bullet")
ins(f"Composite Score: {score:.1f} / 100\n", "bullet")
ins(f"Verdict: ", "bullet")
ins(f"{tier}\n\n", tier_tag)
else:
ins(f"\n{name} ({ticker}) — {sector}\n", "heading")
ins("Insufficient data for full evaluation.\n\n", "neutral")
if desc:
ins(desc + "\n\n", "description")
# ── INVESTMENT THESIS ─────────────────────────────────────────────
ins("" * 56 + "\n", "heading")
ins(" INVESTMENT THESIS\n", "heading")
ins("" * 56 + "\n\n", "heading")
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(("positive",
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(("positive", 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(("positive", 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(("positive",
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(("positive", 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(("positive", f"Sentiment: Recent news coverage is broadly positive{s_str}."))
if not _nan(sa) and sa >= 62:
parts = []
if rec and rec not in ("nan", "N/A"):
parts.append(rec.replace("_", " ").title())
if not _nan(upside) and upside > 0:
parts.append(f"{upside*100:.1f}% upside to consensus target")
a_str = "" + ", ".join(parts) if parts else ""
strengths.append(("positive", f"Analyst Consensus: Bullish institutional view{a_str}."))
if not _nan(sr) and sr >= 62:
strengths.append(("positive",
"Risk Profile: Low short interest and stable volatility — favourable risk/reward."))
if strengths:
for tag, text in strengths:
ins(f"{text}\n", tag)
else:
ins(" No strong positive catalysts identified at current levels.\n", "neutral")
ins("\n", "bullet")
# ── RISK FACTORS ──────────────────────────────────────────────────
ins("" * 56 + "\n", "heading")
ins(" RISK FACTORS\n", "heading")
ins("" * 56 + "\n\n", "heading")
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(("negative",
f"Valuation Risk: Elevated multiples relative to peers{pe_str}. Leaves little margin for error."))
if not _nan(sg) and sg <= 38:
risks.append(("negative",
"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(("negative", f"Momentum Risk: Negative price trend{m_str}. Downtrend may continue."))
if not _nan(sq) and sq <= 38:
risks.append(("negative",
"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(("negative",
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(("negative",
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(("negative",
f"Short Interest: {lvl} at {short_pct*100:.1f}% of float — "
"signals institutional bearish conviction. Potential for forced selling."))
if not _nan(beta):
if beta > 1.8:
risks.append(("negative",
f"Volatility Risk: High beta ({beta:.2f}x) significantly amplifies "
"both market upside and downside moves."))
elif beta < 0:
risks.append(("negative",
f"Unusual Beta: Negative beta ({beta:.2f}) — moves inversely to the "
"market, which may not be suitable for all portfolios."))
if not _nan(vol) and vol > 0.40:
risks.append(("negative",
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(("negative",
f"Leverage Risk: Debt/equity ratio of {debt_eq:.0f}% indicates heavy debt load. "
"Rising interest rates or revenue shortfalls could strain cash flows."))
if risks:
for tag, text in risks:
ins(f"{text}\n", tag)
else:
ins(" No major risk flags identified at this time.\n", "positive")
ins("\n", "bullet")
# ── ADDITIONAL CONTEXT ────────────────────────────────────────────
ins("" * 56 + "\n", "heading")
ins(" ADDITIONAL CONTEXT\n", "heading")
ins("" * 56 + "\n\n", "heading")
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")
if not _nan(div) and div > 0:
ins(f" • Dividend yield of {div*100:.2f}% provides income support.\n", "positive")
if not _nan(curr) and curr >= 2.0:
ins(f" • Current ratio of {curr:.1f}x indicates strong short-term liquidity.\n", "positive")
elif not _nan(curr) and curr < 1.0:
ins(f" • Current ratio of {curr:.1f}x raises near-term liquidity concerns.\n", "negative")
if not _nan(fcf) and fcf > 0.03:
ins(f" • FCF yield of {fcf*100:.1f}% suggests the business generates "
"meaningful free cash flow relative to its market cap.\n", "positive")
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
ins(f" • Current price is {abs(pct_from_high)*100:.1f}% below 52-week high "
f"and {pct_from_low*100:.1f}% above 52-week low.\n", "bullet")
if not _nan(beta) and 0.7 <= beta <= 1.3:
ins(f" • Beta of {beta:.2f} closely tracks the broader market — suitable "
"for balanced portfolios.\n", "bullet")
ins("\n", "bullet")
# ── NEWS HIGHLIGHTS ───────────────────────────────────────────────
headlines = row.get("news_headlines") or []
if not headlines and not row.get("_news_fetched"):
# Headlines not yet fetched (bulk screener path skips live fetch).
# Show a placeholder and fetch in the background; re-render on done.
ins("" * 56 + "\n", "heading")
ins(" NEWS HIGHLIGHTS\n", "heading")
ins("" * 56 + "\n\n", "heading")
ins(" Fetching recent news...\n\n", "dim")
_ticker = row.get("ticker", "")
_name = row.get("name", "")
if _ticker:
def _fetch_news(_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
try:
self.after(0, lambda: self._update_commentary(_r))
except Exception:
pass
threading.Thread(target=_fetch_news, daemon=True).start()
if headlines:
ins("" * 56 + "\n", "heading")
ins(" NEWS HIGHLIGHTS\n", "heading")
ins("" * 56 + "\n\n", "heading")
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 and may re-rate the stock.",
("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: str, title: str = "",
max_sentences: int = 2) -> str:
"""
Return up to max_sentences clean sentences from a summary string.
Strips residual HTML tags/entities and skips text that just
echoes the headline.
"""
# Final-pass clean: unescape entities, strip tags, collapse spaces
text = _html.unescape(text or "")
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
if not text:
return ""
# Skip if the summary is just the headline repeated
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 ""
parts = re.split(r'(?<=[.!?])\s+', text)
trimmed = " ".join(parts[:max_sentences]).strip()
if trimmed and trimmed[-1] not in ".!?":
trimmed += "."
return trimmed
for idx, item in enumerate(headlines):
# Support both old 3-tuple and new 5-tuple format gracefully
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_tag, h_label = "positive", "Positive"
implication = _IMPL.get((category, True), _IMPL[("general", True)])
elif h_score <= -0.05:
h_tag, h_label = "negative", "Negative"
implication = _IMPL.get((category, False), _IMPL[("general", False)])
else:
h_tag, h_label = "neutral", "Neutral"
implication = "Neutral coverage — monitor for shifts in tone as the story develops."
# Precise relative time
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"
# Build 2-3 sentence recap: article summary + implication
short_summary = _trim_summary(summary, title=title, max_sentences=2)
if short_summary:
recap = f"{short_summary} {implication}"
else:
recap = implication
ins(f" [{h_label}] ", h_tag)
# Headline — hyperlinked if a URL is available
if url:
link_tag = f"_newslink_{idx}"
box.tag_configure(link_tag,
foreground=T["accent"],
underline=True,
font=("Segoe UI", 10))
box.tag_bind(link_tag, "<Enter>",
lambda e, b=box: b.config(cursor="hand2"))
box.tag_bind(link_tag, "<Leave>",
lambda e, b=box: b.config(cursor=""))
box.tag_bind(link_tag, "<Button-1>",
lambda e, u=url: webbrowser.open(u))
box.insert("end", f"{title}\n", ("bullet", link_tag))
else:
ins(f"{title}\n", "bullet")
ins(f" {age_str} · {recap}\n\n", "dim")
ins("\n", "bullet")
# ── RECOMMENDATION ────────────────────────────────────────────────
ins("" * 56 + "\n", "heading")
ins(" RECOMMENDATION\n", "heading")
ins("" * 56 + "\n\n", "heading")
# Derive recommendation and timeframe from the data
n_strengths = len(strengths)
n_risks = len(risks)
# Determine investment timeframe based on dominant factor pattern
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
if not _nan(score):
if score >= 72 and n_strengths >= 3 and n_risks == 0:
rec_label = "STRONG BUY"
rec_tag = "verdict_buy"
if hi_val and hi_qual:
timeframe = "1236 months (fundamental thesis)"
tf_reason = "Strong valuation discount and balance sheet quality support a longer holding period."
elif hi_mom:
timeframe = "312 months (momentum-driven)"
tf_reason = "Price trend is strong — re-evaluate if momentum fades."
else:
timeframe = "618 months"
tf_reason = "Multiple factors aligned. Monitor quarterly earnings for confirmation."
elif score >= 62 and n_strengths >= 2:
rec_label = "BUY"
rec_tag = "verdict_buy"
if hi_val and not hi_mom:
timeframe = "1224 months (value unlock)"
tf_reason = "Undervalued on fundamentals — patience required for market to re-rate."
elif hi_mom and not hi_val:
timeframe = "16 months (trend play)"
tf_reason = "Momentum-driven opportunity. Set a stop-loss and monitor price action."
elif hi_growth:
timeframe = "618 months (growth compounding)"
tf_reason = "Strong growth trajectory. Suitable while earnings acceleration continues."
else:
timeframe = "612 months"
tf_reason = "Favorable setup. Review if fundamentals or sentiment deteriorate."
elif score >= 52:
rec_label = "HOLD / WATCH"
rec_tag = "verdict_hold"
timeframe = "Reassess in 13 months"
tf_reason = "Mixed signals. No compelling entry at current levels — monitor for improvement."
elif score >= 42:
rec_label = "UNDERPERFORM"
rec_tag = "verdict_sell"
timeframe = "Avoid new positions"
tf_reason = "Below-average score with notable risk factors. Wait for a clearer signal."
else:
rec_label = "AVOID"
rec_tag = "verdict_sell"
timeframe = "Do not initiate"
tf_reason = "Significant headwinds across multiple factors. Risk outweighs reward."
# Override with caution flags
if hi_short:
rec_label = f"{rec_label} — CAUTION (High Short Interest)"
rec_tag = "verdict_sell" if rec_tag == "verdict_hold" else rec_tag
tf_reason += " High short interest warns of institutional bearish conviction."
if hi_vol:
tf_reason += " Elevated volatility increases risk; size positions accordingly."
ins(f" Verdict: ", "bullet")
ins(f"{rec_label}\n", rec_tag)
ins(f" Timeframe: {timeframe}\n", "bullet")
ins(f"\n Reasoning: {tf_reason}\n", "neutral")
# Key supporting metrics
ins("\n Key metrics driving this view:\n", "bullet")
metric_lines = []
if not _nan(sv):
metric_lines.append(f" • Value score {sv:.0f}/100" +
(f" — P/E {pe:.1f}x" if not _nan(pe) and pe > 0 else ""))
if not _nan(sg):
metric_lines.append(f" • Growth score {sg:.0f}/100" +
(f" — Rev growth {rev_g*100:+.1f}%" if not _nan(rev_g) else ""))
if not _nan(sm):
metric_lines.append(f" • Momentum score {sm:.0f}/100" +
(f" — 6M return {r6*100:+.1f}%" if not _nan(r6) else ""))
if not _nan(sa):
metric_lines.append(f" • Analyst score {sa:.0f}/100" +
(f"{upside*100:.1f}% upside to target" if not _nan(upside) and upside > 0 else ""))
for ml in metric_lines:
ins(ml + "\n", "bullet")
else:
ins(" Insufficient data to generate a recommendation.\n", "neutral")
ins("\n", "bullet")
# ── DISCLAIMER ────────────────────────────────────────────────────
ins("" * 56 + "\n", "dim")
ins("This commentary is generated algorithmically from public market data.\n"
"It is for informational purposes only and does not constitute financial\n"
"advice. Always conduct your own due diligence before investing.\n", "dim")
box.configure(state="disabled")
# ------------------------------------------------------------------ #
# 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"),
]
# ------------------------------------------------------------------ #
# Detail update #
# ------------------------------------------------------------------ #
def _update_detail(self, row):
ticker = row["ticker"]
name = str(row.get("name", ticker))
sector = str(row.get("sector", "N/A"))
self._detail_title.config(
text=f" {ticker}{name} · {sector}",
fg=T["fg"])
# Derived: balance sheet figures
total_debt = row.get("total_debt")
total_cash = row.get("total_cash")
de_ratio = row.get("debt_to_equity")
revenue = row.get("revenue")
pm = row.get("profit_margin")
om = row.get("operating_margin")
# Total Equity (derived from debt and D/E ratio)
if not _nan(total_debt) and not _nan(de_ratio) and de_ratio != 0:
_equity_val = total_debt / (de_ratio / 100.0)
equity_fmt = _mcap(_equity_val)
else:
equity_fmt = ""
# Revenue
revenue_fmt = _mcap(revenue) if not _nan(revenue) else ""
# Net Income = Revenue × Profit Margin
if not _nan(revenue) and not _nan(pm):
net_income_fmt = _mcap(revenue * pm)
else:
net_income_fmt = ""
# Operating Income = Revenue × Operating Margin
if not _nan(revenue) and not _nan(om):
op_income_fmt = _mcap(revenue * om)
else:
op_income_fmt = ""
# Profit Margin: percent + dollar equivalent
if not _nan(pm):
_pm_pct = f"{pm * 100:.1f}%"
profit_margin_fmt = (f"{_pm_pct} ({_mcap(revenue * pm)})"
if not _nan(revenue) else _pm_pct)
else:
profit_margin_fmt = ""
# Operating Margin: percent + dollar equivalent
if not _nan(om):
_om_pct = f"{om * 100:.1f}%"
op_margin_fmt = (f"{_om_pct} ({_mcap(revenue * om)})"
if not _nan(revenue) else _om_pct)
else:
op_margin_fmt = ""
# Derived: Sentiment label
ns = row.get("news_sentiment")
if _nan(ns):
sentiment_fmt = "— (no data)"
elif ns >= 0.05:
sentiment_fmt = f"Positive ({ns:+.3f})"
elif ns <= -0.05:
sentiment_fmt = f"Negative ({ns:+.3f})"
else:
sentiment_fmt = f"Neutral ({ns:+.3f})"
_derived = {
"_revenue_fmt": revenue_fmt,
"_net_income_fmt": net_income_fmt,
"_op_income_fmt": op_income_fmt,
"_equity_fmt": equity_fmt,
"_profit_margin_fmt": profit_margin_fmt,
"_op_margin_fmt": op_margin_fmt,
}
def _rec_fmt(v):
return "" if not v or str(v) in ("nan", "N/A", "None") \
else str(v).replace("_", " ").title()
def _count_fmt(v):
return "" if _nan(v) else str(int(v))
_FORMATTERS = {
"composite_score": lambda v: _score(v) + " / 100",
"score_value": lambda v: _score(v) + " / 100",
"score_growth": lambda v: _score(v) + " / 100",
"score_momentum": lambda v: _score(v) + " / 100",
"score_quality": lambda v: _score(v) + " / 100",
"score_profitability":lambda v: _score(v) + " / 100",
"score_sentiment": lambda v: _score(v) + " / 100",
"score_analyst": lambda v: _score(v) + " / 100",
"score_risk": lambda v: _score(v) + " / 100",
"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": _count_fmt,
"analyst_target": _price,
"analyst_upside": _pct,
"news_sentiment": lambda v: sentiment_fmt,
"short_percent": _pct,
"beta": _flt,
"volatility_30d": _pct,
}
_GREEN_KEYS = {
"ret_1m", "ret_3m", "ret_6m", "ret_12m", "revenue_growth",
"earnings_growth", "roe", "roa", "profit_margin",
"operating_margin", "fcf_yield", "analyst_upside", "dividend_yield",
}
_ANALYST_KEYS = {
"pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
"analyst_count", "analyst_target", "recommendation",
}
_FUND_KEYS = {
"market_cap", "pe_trailing", "pb_ratio", "book_value", "ev_ebitda",
"ev_revenue", "eps_trailing", "revenue", "revenue_growth",
"earnings_growth", "roe", "roa", "debt_to_equity", "total_debt",
"total_cash", "current_ratio", "profit_margin", "operating_margin",
"fcf_yield", "shares_outstanding",
}
def _metric_reason(key: str) -> str:
if key in _ANALYST_KEYS:
count = row.get("analyst_count")
if _nan(count):
return "no analyst coverage"
elif key in _FUND_KEYS:
filer = row.get("filer_type")
if filer in (None, "none", "finra-only"):
return "no EDGAR data"
return ""
for key, lbl in self._detail_labels.items():
fmt = _FORMATTERS.get(key, lambda v: _flt(v))
val = _derived.get(key, row.get(key))
text = fmt(val)
if text == "":
reason = _metric_reason(key)
if reason:
text = f"— ({reason})"
fg = T["fg"]
if key in _GREEN_KEYS and not _nan(val):
fg = T["green"] if val >= 0 else T["red"]
elif key == "news_sentiment" and not _nan(ns):
fg = T["green"] if ns >= 0.05 else (T["red"] if ns <= -0.05 else T["yellow"])
elif key in ("short_percent", "volatility_30d") and not _nan(val):
fg = T["red"] if val > 0.10 else T["green"]
elif key == "total_debt" and not _nan(val):
fg = T["red"] if val > 0 else T["fg"]
elif key == "total_cash" and not _nan(val):
fg = T["green"] if val > 0 else T["fg"]
elif key == "_net_income_fmt" and not _nan(pm):
fg = T["green"] if pm >= 0 else T["red"]
elif key == "_op_income_fmt" and not _nan(om):
fg = T["green"] if om >= 0 else T["red"]
elif key == "_profit_margin_fmt" and not _nan(pm):
fg = T["green"] if pm >= 0 else T["red"]
elif key == "_op_margin_fmt" and not _nan(om):
fg = T["green"] if om >= 0 else T["red"]
lbl.config(text=text, fg=fg)
self._update_score_bars(row)
self._update_commentary(row)
# Show/hide the no-data banner
if str(row.get("_data_source", "")) == "none":
self._no_data_notice.pack(fill="x", side="top",
before=self._notebook)
else:
self._no_data_notice.pack_forget()
# Trigger chart update when ticker changes
new_ticker = row["ticker"]
if new_ticker != self._chart_ticker:
self._chart_ticker = new_ticker
period = self._chart_period.get()
threading.Thread(
target=self._fetch_and_render_chart,
args=(new_ticker, period, "_chart_canvas", self._chart_parent_frame),
daemon=True,
).start()
# ------------------------------------------------------------------ #
# Status bar #
# ------------------------------------------------------------------ #
def _get_tooltip_text(self, label: str) -> str:
"""Return tooltip text for a metric label.
For Composite Score the formula weights reflect the current strategy."""
if label == "Composite Score":
strategy = self._strategy_var.get()
if strategy == "Custom..." and self._custom_weights:
w = self._custom_weights
elif strategy in STRATEGY_PRESETS:
w = STRATEGY_PRESETS[strategy]
else:
w = WEIGHTS
return (
"Weighted average of all 8 factor scores (each ranked 0100 across "
"all screened stocks).\n\n"
f"Formula [{strategy}]:\n"
f" {w['value']*100:.0f}% × Value\n"
f" {w['growth']*100:.0f}% × Growth\n"
f" {w['momentum']*100:.0f}% × Momentum\n"
f" {w['quality']*100:.0f}% × Quality\n"
f" {w['profitability']*100:.0f}% × Profitability\n"
f" {w['sentiment']*100:.0f}% × Sentiment\n"
f" {w['analyst']*100:.0f}% × Analyst\n"
f" {w['risk']*100:.0f}% × Risk"
)
return _METRIC_TOOLTIPS.get(label, "")
def _open_custom_strategy_dialog(self):
"""Open a dialog for the user to define custom factor weights."""
# Revert combobox to previous selection if user cancels
prev_strategy = next(
(s for s in list(STRATEGY_PRESETS.keys()) + ["Custom..."]
if self._df_raw is None or s == "Custom..."),
"Balanced (Default)"
)
dlg = tk.Toplevel(self)
dlg.title("Custom Strategy Weights")
dlg.configure(bg=T["bg"])
dlg.resizable(False, False)
dlg.grab_set()
FACTORS = [
("value", "Value"),
("growth", "Growth"),
("momentum", "Momentum"),
("quality", "Quality"),
("profitability", "Profitability"),
("sentiment", "Sentiment"),
("analyst", "Analyst"),
("risk", "Risk"),
]
# Use existing custom weights or the current preset as starting point
strategy = next(
(s for s in STRATEGY_PRESETS if s == self._strategy_var.get()),
"Balanced (Default)"
)
base = self._custom_weights or STRATEGY_PRESETS.get(strategy, WEIGHTS)
tk.Label(dlg, text="Set factor weights (must sum to 100%)",
bg=T["bg"], fg=T["accent"], font=T["font_sec"],
pady=10).grid(row=0, column=0, columnspan=3, padx=16)
vars_: dict[str, tk.StringVar] = {}
for i, (key, lbl) in enumerate(FACTORS, start=1):
tk.Label(dlg, text=lbl, bg=T["bg"], fg=T["fg2"],
font=T["font_small"], width=14, anchor="w"
).grid(row=i, column=0, padx=(16, 4), pady=3, sticky="w")
v = tk.StringVar(value=f"{base[key] * 100:.1f}")
_PillEntry(dlg, v, width_chars=7, height=26
).grid(row=i, column=1, padx=(0, 2), pady=3)
tk.Label(dlg, text="%", bg=T["bg"], fg=T["accent"],
font=T["font_small"]).grid(row=i, column=2, sticky="w")
vars_[key] = v
sum_var = tk.StringVar(value="Sum: 100.0%")
sum_lbl = tk.Label(dlg, textvariable=sum_var, bg=T["bg"],
fg=T["accent"], font=T["font_small"])
sum_lbl.grid(row=len(FACTORS) + 1, column=0, columnspan=3, pady=(8, 0))
def _update_sum(*_):
total = 0.0
for v in vars_.values():
try: total += float(v.get())
except ValueError: pass
color = T["accent"] if abs(total - 100) < 0.5 else T["red"]
sum_var.set(f"Sum: {total:.1f}%")
sum_lbl.configure(fg=color)
for v in vars_.values():
v.trace_add("write", _update_sum)
def _apply():
w: dict[str, float] = {}
for key, v in vars_.items():
try:
w[key] = float(v.get()) / 100.0
except ValueError:
return # invalid input — do nothing
total = sum(w.values())
if total <= 0:
return
# Normalise so weights always sum to exactly 1.0
w = {k: val / total for k, val in w.items()}
self._custom_weights = w
self._strategy_var.set("Custom...")
dlg.destroy()
if self._df_raw is not None:
self._df = score_stocks(self._df_raw, weights=w,
sector_stats=self._sector_stats)
self._apply_filters()
self._status_var.set(
f" Strategy: Custom — rescored {len(self._df)} stocks.")
def _cancel():
# Restore combobox to what it was before opening the dialog
if self._custom_weights:
self._strategy_var.set("Custom...")
else:
self._strategy_var.set("Balanced (Default)")
dlg.destroy()
btn_frame = tk.Frame(dlg, bg=T["bg"])
btn_frame.grid(row=len(FACTORS) + 2, column=0, columnspan=3, pady=(_s(12), _s(16)))
_RoundedButton(btn_frame, "Apply", _apply, width=_s(90), height=_s(32),
radius=_s(8)).pack(side="left", padx=_s(4))
_RoundedButton(btn_frame, "Cancel", _cancel, width=_s(90), height=_s(32),
radius=_s(8)).pack(side="left", padx=_s(4))
dlg.protocol("WM_DELETE_WINDOW", _cancel)
dlg.update_idletasks()
px = self.winfo_x() + (self.winfo_width() - dlg.winfo_reqwidth()) // 2
py = self.winfo_y() + (self.winfo_height() - dlg.winfo_reqheight()) // 2
dlg.geometry(f"+{px}+{py}")
def _show_info_popup(self, widget, text):
"""Show a small info popup near the clicked (i) button."""
if self._info_popup is not None:
try:
self._info_popup.destroy()
except tk.TclError:
pass
self._info_popup = None
popup = tk.Toplevel(self)
self._info_popup = popup
popup.overrideredirect(True)
popup.configure(bg=T["bg3"])
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
# Max popup width = 40% of screen; compute wraplength from that
max_popup_w = int(sw * 0.40)
wrap_px = max_popup_w - 28 # subtract padx margins
# Count visible lines to set height dynamically
_font_spec = ("Segoe UI", 9)
lines = text.split("\n")
# Estimate wrapped line count
char_px = 6 # ~6px per char at 9pt Segoe UI
n_lines = sum(
max(1, int(len(l) * char_px / wrap_px) + 1) if l.strip() else 1
for l in lines
)
n_lines = max(4, min(n_lines, int(sh * 0.55 / 18))) # cap height
txt = tk.Text(
popup,
bg=T["bg3"], fg=T["fg"],
font=_font_spec,
wrap="word",
relief="flat", bd=0,
padx=12, pady=8,
highlightthickness=0,
state="normal", cursor="arrow",
width=wrap_px // char_px,
height=n_lines,
)
txt.insert("1.0", text)
txt.configure(state="disabled")
txt.pack()
popup.update_idletasks()
pw = popup.winfo_reqwidth()
ph = popup.winfo_reqheight()
rx = widget.winfo_rootx()
ry = widget.winfo_rooty() + widget.winfo_height() + 4
if rx + pw > sw - 10:
rx = sw - pw - 10
if ry + ph > sh - 10:
ry = widget.winfo_rooty() - ph - 4
popup.geometry(f"+{rx}+{ry}")
popup.lift()
def _close(e=None):
try:
popup.destroy()
except tk.TclError:
pass
self._info_popup = None
popup.bind("<FocusOut>", _close)
popup.bind("<Button-1>", _close)
for child in popup.winfo_children():
child.bind("<Button-1>", _close)
popup.focus_set()
# ------------------------------------------------------------------ #
# Status bar #
# ------------------------------------------------------------------ #
def _build_status_bar(self):
tk.Frame(self, bg=T["border"], height=1).pack(fill="x", side="bottom")
bar = tk.Frame(self, bg=T["bg"], pady=4)
bar.pack(fill="x", side="bottom")
self._status_var = tk.StringVar(value="Ready — press Run to start a scan")
tk.Label(bar, textvariable=self._status_var,
bg=T["bg"], fg=T["fg2"], font=("Segoe UI", 9),
anchor="w", padx=12).pack(side="left")
# Right-aligned persistent cache status (updated each time a screen runs)
self._cache_lbl_var = tk.StringVar(value="")
self._cache_lbl = tk.Label(bar, textvariable=self._cache_lbl_var,
bg=T["bg"], fg=T["fg2"],
font=("Segoe UI", 9), anchor="e", padx=12)
self._cache_lbl.pack(side="right")
# ------------------------------------------------------------------ #
# Event handlers #
# ------------------------------------------------------------------ #
def _on_row_select(self, _event=None):
sel = self._tree.selection()
if not sel or self._df is None:
return
ticker = self._tree.item(sel[0], "values")[1]
match = self._df[self._df["ticker"] == ticker]
if not match.empty:
self._selected_ticker = ticker
self._notebook.pack(fill="both", expand=True, padx=0, pady=0)
self._update_detail(match.iloc[0])
def _on_run(self):
self._run_id += 1
self._stop_event.clear()
self._run_btn.set_state("disabled")
self._stop_btn.set_state("normal")
self._progress_var.set(0)
self._status_var.set("Starting…")
strategy = self._strategy_var.get()
if strategy == "Custom..." and self._custom_weights:
run_weights = self._custom_weights
else:
run_weights = STRATEGY_PRESETS.get(strategy) # None = default WEIGHTS
# ── Build screen-time filters from the current UI state ───────────
sector_filter = _SECTOR_FILTER_MAP.get(self._sector_var.get()) # None = all
mktcap_cat = self._filter_mktcap.get()
mktcap_range = None
if mktcap_cat.startswith("Mega"):
mktcap_range = (100e9, None)
elif mktcap_cat.startswith("Large"):
mktcap_range = (10e9, 100e9)
elif mktcap_cat.startswith("Mid"):
mktcap_range = (2e9, 10e9)
elif mktcap_cat.startswith("Small"):
mktcap_range = (None, 2e9)
screen_filters = {
"sector_filter": sector_filter,
"mktcap_range": mktcap_range,
}
settings = {
"top_n": self._get_top_n(),
"workers": 10,
"index": self._index_var.get(),
"weights": run_weights,
"screen_filters": screen_filters,
}
threading.Thread(target=self._worker, args=(settings, self._run_id), daemon=True).start()
def _on_stop(self):
self._stop_event.set()
self._run_id += 1 # invalidate any queued messages from the current worker
# drain any already-queued messages so stale progress/done don't appear
while not self._q.empty():
try:
self._q.get_nowait()
except queue.Empty:
break
# reset UI immediately — don't wait for the worker to check the stop flag
self._run_btn.set_state("normal")
self._stop_btn.set_state("disabled")
self._progress_var.set(0)
self._status_var.set(" Stopped.")
# ------------------------------------------------------------------ #
# Worker thread #
# ------------------------------------------------------------------ #
def _worker(self, settings: dict, run_id: int):
redirector = _StdoutRedirector(self._q, run_id)
old_stdout = sys.stdout
sys.stdout = redirector
try:
self._q.put({"type": "status", "run_id": run_id, "text": "Fetching ticker lists…"})
tickers = collect_tickers(index=settings["index"])
if not tickers:
self._q.put({"type": "error", "run_id": run_id,
"text": "Could not retrieve any ticker lists."})
return
if self._stop_event.is_set():
self._q.put({"type": "stopped", "run_id": run_id})
return
sf = settings.get("screen_filters", {})
parts = []
idx_lbl = settings.get("index", "All")
if idx_lbl and idx_lbl != "All":
parts.append(idx_lbl)
if sf.get("sector_filter"):
parts.append(sf["sector_filter"][1])
if sf.get("mktcap_range"):
lo, hi = sf["mktcap_range"]
if lo and hi:
parts.append(f"${int(lo//1e9)}B${int(hi//1e9)}B")
elif lo:
parts.append(f">${int(lo//1e9)}B")
elif hi:
parts.append(f"<${int(hi//1e9)}B")
label = " · ".join(parts)
status_txt = f"Screening {len(tickers)} stocks" + (f" ({label})" if label else "") + ""
self._q.put({"type": "status", "run_id": run_id, "text": status_txt})
df_raw = fetch_all(tickers, max_workers=settings["workers"],
screen_filters=sf)
if self._stop_event.is_set():
self._q.put({"type": "stopped", "run_id": run_id})
return
if df_raw.empty:
self._q.put({"type": "error", "run_id": run_id,
"text": "No data returned from yfinance."})
return
self._q.put({"type": "status", "run_id": run_id,
"text": "Loading sector stats…"})
sector_stats = _load_sector_stats()
self._q.put({"type": "status", "run_id": run_id,
"text": f"Computing scores for {len(df_raw)} stocks…"})
df_scored = score_stocks(df_raw, weights=settings.get("weights"),
sector_stats=sector_stats)
self._q.put({"type": "done", "run_id": run_id,
"df_raw": df_raw,
"df_scored": df_scored,
"sector_stats": sector_stats,
"total_scored": len(df_scored)})
except Exception as exc:
self._q.put({"type": "error", "run_id": run_id, "text": str(exc)})
finally:
sys.stdout = old_stdout
# ------------------------------------------------------------------ #
# Queue polling #
# ------------------------------------------------------------------ #
def _poll_queue(self):
try:
while True:
msg = self._q.get_nowait()
# discard messages from a previously-stopped run
if msg.get("run_id") != self._run_id:
continue
mtype = msg["type"]
if mtype == "progress":
total = msg["total"]
if total:
self._progress_var.set(msg["done"] / total * 100)
self._status_var.set(
f" {msg['done']}/{total} ok={msg['ok']} "
f"skip={msg['skip']} ETA {msg['eta']:.0f}s")
elif mtype == "status":
text = msg["text"]
self._status_var.set(" " + text)
# Latch analyst-cache load result to the persistent right label
tl = text.lower()
if "analyst cache" in tl:
clean = text.strip().lstrip("[WARN]").strip(" -")
if "empty" in tl or "unavailable" in tl or \
"skipped" in tl or "could not" in tl:
self._cache_lbl_var.set(clean)
self._cache_lbl.config(fg=T["red"])
elif "[warn]" in text.lower():
self._cache_lbl_var.set(clean)
self._cache_lbl.config(fg=T["yellow"])
else:
self._cache_lbl_var.set(clean)
self._cache_lbl.config(fg=T["accent"])
elif mtype == "done":
self._df_raw = msg["df_raw"]
self._df = msg["df_scored"]
self._sector_stats = msg.get("sector_stats", {})
self._progress_var.set(100)
n = msg["total_scored"]
self._status_var.set(
f" Done — {n} stocks scored. Applying filters…")
self._apply_filters()
from datetime import datetime
self._lastrun_var.set(
"Last run: " + datetime.now().strftime("%H:%M:%S"))
self._run_btn.set_state("normal")
self._stop_btn.set_state("disabled")
break
elif mtype in ("error", "stopped"):
self._status_var.set(" " + msg.get("text", "Stopped."))
self._progress_var.set(0)
self._run_btn.set_state("normal")
self._stop_btn.set_state("disabled")
break
except queue.Empty:
pass
self.after(100, self._poll_queue)
# ------------------------------------------------------------------ #
# Stock lookup window #
# ------------------------------------------------------------------ #
def _open_lookup_window(self, _event=None):
_StockLookupWindow(self, df=self._df)
# ---------------------------------------------------------------------------
# Stock Lookup Window
# ---------------------------------------------------------------------------
class _StockLookupWindow(tk.Toplevel):
"""
Independent stock search window.
Accepts a ticker symbol or company name, checks loaded screener data first,
then fetches live from yfinance. Displays the full detail panel + chart.
"""
_SCORE_FIELDS = ScreenerApp._SCORE_FIELDS if hasattr(ScreenerApp, "_SCORE_FIELDS") else []
_VAL_FIELDS = ScreenerApp._VAL_FIELDS if hasattr(ScreenerApp, "_VAL_FIELDS") else []
_GROWTH_FIELDS= ScreenerApp._GROWTH_FIELDS if hasattr(ScreenerApp, "_GROWTH_FIELDS") else []
_MOM_FIELDS = ScreenerApp._MOM_FIELDS if hasattr(ScreenerApp, "_MOM_FIELDS") else []
_QUAL_FIELDS = ScreenerApp._QUAL_FIELDS if hasattr(ScreenerApp, "_QUAL_FIELDS") else []
_ANLST_FIELDS = ScreenerApp._ANLST_FIELDS if hasattr(ScreenerApp, "_ANLST_FIELDS") else []
def __init__(self, parent, df=None):
super().__init__(parent)
self.title(f"{_APP_NAME} — Stock Lookup")
self.configure(bg=T["bg"])
# Size the window to fit any screen: cap at 90 % of screen dimensions
# so the chart at the bottom is always fully visible.
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
w = min(_s(820), int(sw * 0.90))
h = min(_s(940), int(sh * 0.90))
self.geometry(f"{w}x{h}")
self.minsize(_s(700), _s(550))
self.resizable(True, True)
self._df = df # loaded screener DataFrame (may be None)
self._detail_labels: dict = {}
self._score_bars: dict = {}
self._chart_ticker = None
self._chart_canvas = None
self._info_popup = None
self._build_search_bar()
self._build_detail_panel()
self.grab_set()
# ---- Search bar -------------------------------------------------------
def _build_search_bar(self):
outer = tk.Frame(self, bg=T["bg"])
outer.pack(fill="x", side="top")
bar = tk.Frame(outer, bg=T["bg"], pady=_s(10))
bar.pack(fill="x", padx=_s(14))
tk.Frame(outer, bg=T["border"], height=1).pack(fill="x")
tk.Label(bar, text=" Ticker / Company:", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
self._search_var = tk.StringVar()
_se = _PillEntry(bar, self._search_var, width_chars=22, height=32)
_se.pack(side="left", padx=_s(6))
_se.entry.bind("<Return>", lambda _e: self._do_search())
_se.entry.focus_set()
_RoundedButton(bar, "Search", self._do_search,
width=_s(80), height=_s(30), radius=_s(8)
).pack(side="left", padx=(0, _s(8)))
self._search_status = tk.StringVar(value="")
tk.Label(bar, textvariable=self._search_status,
bg=T["bg"], fg=T["fg2"], font=T["font_small"]).pack(side="left", padx=8)
# ---- Detail panel (mirrors ScreenerApp) --------------------------------
def _build_detail_panel(self):
panel = tk.Frame(self, bg=T["bg"])
panel.pack(fill="both", expand=True)
self._detail_title = tk.Label(
panel,
text=" Enter a ticker symbol or company name above",
bg=T["bg"], fg=T["fg2"], font=T["font_head"],
anchor="w", pady=8, padx=12)
self._detail_title.pack(fill="x", side="top")
tk.Frame(panel, bg=T["border"], height=1).pack(fill="x")
# No-data notice — shown when _data_source == "none"
self._no_data_notice = tk.Label(
panel,
text=" ⚠ No fundamental data found for this ticker — scores based on price & momentum only.",
bg="#1a1200", fg=T["yellow"], font=T["font_small"],
anchor="w", pady=6, padx=16)
self._notebook = _CustomNotebook(panel)
self._notebook.pack(fill="both", expand=True)
# Scores tab — animated bar widgets
scores_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(scores_tab, text=" Scores ")
self._build_scores_tab_content(scores_tab)
for title, fields in [
("Valuation", self._VAL_FIELDS),
("Growth", self._GROWTH_FIELDS),
("Momentum", self._MOM_FIELDS),
("Quality & Profit", self._QUAL_FIELDS),
]:
tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(tab, text=f" {title} ")
labels = self._build_tab_content(tab, fields)
self._detail_labels.update(labels)
# Analyst tab — full version with metrics + commentary
analyst_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(analyst_tab, text=" Analyst ")
self._build_analyst_tab(analyst_tab)
# Chart tab
chart_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(chart_tab, text=" Chart ")
self._chart_period = tk.StringVar(value="6mo")
self._chart_parent_frame = chart_tab
self._build_chart_tab_simple(chart_tab)
def _build_chart_tab_simple(self, parent):
"""Minimal chart tab for the lookup window."""
ctrl = tk.Frame(parent, bg=T["bg"])
ctrl.pack(fill="x", side="top")
tk.Frame(ctrl, bg=T["bg"], width=12).pack(side="left")
for val, lbl in [("1mo","1M"),("3mo","3M"),("6mo","6M"),
("1y","1Y"),("2y","2Y"),("5y","5Y")]:
b = tk.Radiobutton(
ctrl, text=lbl, variable=self._chart_period, value=val,
bg=T["bg"], fg=T["fg2"], selectcolor=T["accent"],
activebackground=T["bg"], activeforeground=T["accent"],
font=("Segoe UI", 9, "bold"), indicatoron=False,
relief="flat", overrelief="flat", padx=10, pady=5, bd=0,
cursor="hand2",
command=self._refresh_chart,
)
b.pack(side="left", padx=1, pady=(4, 0))
tk.Frame(ctrl, bg=T["border"], height=1).pack(fill="x", side="bottom")
self._chart_canvas_placeholder = tk.Label(
parent,
text="Search for a stock to view its price chart.",
bg=T["bg"], fg=T["fg2"], font=T["font_small"],
)
self._chart_canvas_placeholder.pack(expand=True)
def _refresh_chart(self):
if self._chart_ticker:
threading.Thread(
target=self._fetch_and_render_chart,
args=(self._chart_ticker, self._chart_period.get(),
"_chart_canvas", self._chart_parent_frame),
daemon=True,
).start()
# Delegate shared rendering logic from ScreenerApp
_build_tab_content = ScreenerApp._build_tab_content
_build_scores_tab_content = ScreenerApp._build_scores_tab_content
_update_score_bars = ScreenerApp._update_score_bars
_build_analyst_tab = ScreenerApp._build_analyst_tab
_update_commentary = ScreenerApp._update_commentary
_show_info_popup = ScreenerApp._show_info_popup
_fetch_and_render_chart = ScreenerApp._fetch_and_render_chart
_render_chart = ScreenerApp._render_chart
_set_chart_placeholder_text = ScreenerApp._set_chart_placeholder_text
def _get_tooltip_text(self, label: str) -> str:
"""Static tooltip lookup — no strategy context in the lookup window."""
return _METRIC_TOOLTIPS.get(label, "")
# ---- Search logic -------------------------------------------------------
def _do_search(self):
query = self._search_var.get().strip().lstrip("$")
if not query:
return
self._search_status.set("Searching…")
self.update_idletasks()
threading.Thread(target=self._search_worker, args=(query,), daemon=True).start()
def _search_worker(self, query: str):
row = self._find_in_df(query)
if row is not None:
self.after(0, self._populate, row, f"Found in screener data: {row['ticker']}")
return
# Live fetch from yfinance
live_row = self._fetch_live(query)
if live_row is not None:
self.after(0, self._populate, live_row, f"Live data: {live_row['ticker']}")
else:
self.after(0, self._search_status.set,
f"No results for '{query}'. Try the exact ticker symbol.")
def _find_in_df(self, query: str):
"""Check loaded DataFrame for ticker or name match."""
if self._df is None or self._df.empty:
return None
import pandas as pd
q = query.upper().strip().lstrip("$")
# Ticker exact match
mask = self._df["ticker"].str.upper() == q
if mask.any():
return self._df[mask].iloc[0]
# Name partial match (case-insensitive)
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: str):
"""Fetch a single ticker live from yfinance and build a row dict."""
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 searching by name via yfinance search
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 Exception:
pass
if not info or not isinstance(info, dict) or len(info) < 3:
return None
# Build a minimal row compatible with _update_detail
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 Exception:
_news_agg, _news_headlines = None, []
row = {
"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
),
# Scores not available for live lookup
"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 "",
}
return row
except Exception:
return None
def _populate(self, row, status_msg: str):
"""Populate the detail panel with data from a row dict or Series."""
import pandas as pd
if isinstance(row, pd.Series):
row = row.to_dict()
self._search_status.set(status_msg)
ticker = row.get("ticker", "?")
name = str(row.get("name", ticker))
sector = str(row.get("sector", "N/A"))
self._detail_title.config(
text=f" {ticker}{name} · {sector}", fg=T["fg"])
# Derived fields
total_debt = row.get("total_debt")
total_cash = row.get("total_cash")
de_ratio = row.get("debt_to_equity")
revenue = row.get("revenue")
pm = row.get("profit_margin")
om = row.get("operating_margin")
# Total Equity
if not _nan(total_debt) and not _nan(de_ratio) and de_ratio != 0:
row["_equity_fmt"] = _mcap(total_debt / (de_ratio / 100.0))
else:
row["_equity_fmt"] = ""
# Revenue, Net Income, Operating Income
row["_revenue_fmt"] = _mcap(revenue) if not _nan(revenue) else ""
row["_net_income_fmt"] = (_mcap(revenue * pm)
if not _nan(revenue) and not _nan(pm) else "")
row["_op_income_fmt"] = (_mcap(revenue * om)
if not _nan(revenue) and not _nan(om) else "")
# Profit Margin: percent + dollar
if not _nan(pm):
_pm_pct = f"{pm * 100:.1f}%"
row["_profit_margin_fmt"] = (f"{_pm_pct} ({_mcap(revenue * pm)})"
if not _nan(revenue) else _pm_pct)
else:
row["_profit_margin_fmt"] = ""
# Operating Margin: percent + dollar
if not _nan(om):
_om_pct = f"{om * 100:.1f}%"
row["_op_margin_fmt"] = (f"{_om_pct} ({_mcap(revenue * om)})"
if not _nan(revenue) else _om_pct)
else:
row["_op_margin_fmt"] = ""
ns = row.get("news_sentiment")
if _nan(ns):
row["_sentiment_fmt"] = "— (no data)"
elif ns >= 0.05:
row["_sentiment_fmt"] = f"Positive ({ns:+.3f})"
elif ns <= -0.05:
row["_sentiment_fmt"] = f"Negative ({ns:+.3f})"
else:
row["_sentiment_fmt"] = f"Neutral ({ns:+.3f})"
_FORMATTERS = {
"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": lambda v: ("" if not v or str(v) in ("nan","N/A","None")
else str(v).replace("_"," ").title()),
"analyst_count": lambda v: "" if _nan(v) else str(int(v)),
"analyst_target": _price,
"analyst_upside": _pct,
"news_sentiment": lambda v: row.get("_sentiment_fmt", ""),
"short_percent": _pct,
"beta": _flt,
"volatility_30d": _pct,
}
_GREEN_KEYS = {
"ret_1m", "ret_3m", "ret_6m", "ret_12m", "revenue_growth",
"earnings_growth", "roe", "roa", "profit_margin",
"operating_margin", "fcf_yield", "analyst_upside", "dividend_yield",
}
for key, lbl in self._detail_labels.items():
fmt = _FORMATTERS.get(key, lambda v: _flt(v))
val = row.get(key)
text = fmt(val)
fg = T["fg"]
if key in _GREEN_KEYS and not _nan(val):
fg = T["green"] if val >= 0 else T["red"]
elif key == "news_sentiment" and not _nan(ns):
fg = T["green"] if ns >= 0.05 else (T["red"] if ns <= -0.05 else T["yellow"])
elif key in ("short_percent", "volatility_30d") and not _nan(val):
fg = T["red"] if val > 0.10 else T["green"]
elif key == "total_debt" and not _nan(val):
fg = T["red"] if val > 0 else T["fg"]
elif key == "total_cash" and not _nan(val):
fg = T["green"] if val > 0 else T["fg"]
elif key == "_net_income_fmt" and not _nan(pm):
fg = T["green"] if pm >= 0 else T["red"]
elif key == "_op_income_fmt" and not _nan(om):
fg = T["green"] if om >= 0 else T["red"]
elif key == "_profit_margin_fmt" and not _nan(pm):
fg = T["green"] if pm >= 0 else T["red"]
elif key == "_op_margin_fmt" and not _nan(om):
fg = T["green"] if om >= 0 else T["red"]
lbl.config(text=text, fg=fg)
self._update_score_bars(row)
# Analyst commentary
self._update_commentary(row)
# Show/hide the no-data banner
if str(row.get("_data_source", "")) == "none":
self._no_data_notice.pack(fill="x", side="top",
before=self._notebook)
else:
self._no_data_notice.pack_forget()
# Trigger chart
new_ticker = row.get("ticker")
if new_ticker and new_ticker != self._chart_ticker:
self._chart_ticker = new_ticker
threading.Thread(
target=self._fetch_and_render_chart,
args=(new_ticker, self._chart_period.get(),
"_chart_canvas", self._chart_parent_frame),
daemon=True,
).start()
# ---------------------------------------------------------------------------
# Rounded UI helpers
# ---------------------------------------------------------------------------
def _pill_pts(w, h, r=10):
"""Polygon points for a rounded rectangle (pill shape)."""
return [r,0, w-r,0, w,0, w,r, w,h-r, w,h, w-r,h, r,h, 0,h, 0,h-r, 0,r, 0,0]
class _RoundedDropdown(tk.Canvas):
"""
Pill-shaped dropdown matching the _RoundedButton tab style.
Solid green fill, black text, no border. Opens a styled tk.Menu on click.
Pass on_change=callable to be notified when the selection changes.
"""
def __init__(self, parent, textvariable, values,
width=140, height=30, radius=8,
bg=None, fg="#000000", on_change=None, tooltips=None, **kw):
if bg is None:
bg = T["accent"]
try:
parent_bg = parent.cget("bg")
except Exception:
parent_bg = T["bg"]
super().__init__(parent, width=width, height=height,
highlightthickness=0, bd=0, bg=parent_bg, **kw)
self._bg = bg
self._fg = fg
self._var = textvariable
self._values = list(values)
self._on_change = on_change
self._tooltips = tooltips
self._dim_w = width
self._dim_h = height
pts = _pill_pts(width, height, radius)
self._rect = self.create_polygon(pts, smooth=1,
fill=bg, outline=bg, width=0)
# Value label (left-aligned)
self._lbl = self.create_text(_s(12), height // 2,
text=self._trunc(textvariable.get()),
fill=fg, font=("Segoe UI", 10),
anchor="w")
# Dropdown arrow (right-aligned)
self._arw = self.create_text(width - _s(10), height // 2,
text="", fill=fg,
font=("Segoe UI", 9, "bold"),
anchor="e")
self.config(cursor="hand2")
self.bind("<Button-1>", self._open)
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
textvariable.trace_add("write", self._sync)
def _trunc(self, val):
max_ch = max(3, (self._dim_w - _s(32)) // max(1, _s(7)))
return val[:max_ch] + "" if len(val) > max_ch else val
def _sync(self, *_):
try:
self.itemconfig(self._lbl, text=self._trunc(self._var.get()))
except tk.TclError:
pass
def _on_enter(self, _e=None):
self.itemconfig(self._rect, fill="#00c060")
def _on_leave(self, _e=None):
self.itemconfig(self._rect, fill=self._bg)
def _open(self, event=None):
if self._tooltips:
self._open_tooltip_popup()
else:
self._open_plain_menu()
def _open_plain_menu(self):
popup = tk.Toplevel(self)
popup.overrideredirect(True)
popup.configure(bg="#0d0d0d")
popup.wm_attributes("-topmost", True)
tk.Frame(popup, bg=T["border"], height=1).pack(fill="x")
for val in self._values:
lbl = tk.Label(popup, text=val,
bg="#0d0d0d", fg=T["fg"],
font=("Segoe UI", 10),
padx=14, pady=7, anchor="w", cursor="hand2",
width=max(self._dim_w // _s(7), 8))
lbl.pack(fill="x")
tk.Frame(popup, bg=T["border"], height=1).pack(fill="x")
def _enter(e, l=lbl):
l.configure(bg=T["accent"], fg="#000000")
def _leave(e, l=lbl):
l.configure(bg="#0d0d0d", fg=T["fg"])
def _click(e, v=val):
try:
popup.grab_release()
popup.destroy()
except Exception:
pass
self._pick(v)
lbl.bind("<Enter>", _enter)
lbl.bind("<Leave>", _leave)
lbl.bind("<Button-1>", _click)
popup.update_idletasks()
rx = self.winfo_rootx()
ry = self.winfo_rooty() + self._dim_h
sw = popup.winfo_screenwidth()
sh = popup.winfo_screenheight()
pw = popup.winfo_reqwidth()
ph = popup.winfo_reqheight()
px = min(rx, sw - pw - 4)
py = min(ry, sh - ph - 4)
popup.geometry(f"+{px}+{py}")
popup.grab_set()
popup.focus_set()
def _check_outside(e):
try:
ex, ey = e.x_root, e.y_root
px2 = popup.winfo_x(); py2 = popup.winfo_y()
pw2 = popup.winfo_width(); ph2 = popup.winfo_height()
if not (px2 <= ex <= px2 + pw2 and py2 <= ey <= py2 + ph2):
popup.grab_release()
popup.destroy()
except Exception:
pass
popup.bind("<Button-1>", _check_outside, add="+")
popup.bind("<Escape>", lambda e: [popup.grab_release(), popup.destroy()])
def _open_tooltip_popup(self):
popup = tk.Toplevel(self)
popup.overrideredirect(True)
popup.configure(bg=T["border"])
popup.wm_attributes("-topmost", True)
inner = tk.Frame(popup, bg="#0d0d0d")
inner.pack(padx=1, pady=1)
item_col = tk.Frame(inner, bg="#0d0d0d")
item_col.pack(side="left", fill="y")
tk.Frame(inner, bg=T["border"], width=1).pack(side="left", fill="y")
tip_col = tk.Frame(inner, bg=T["bg3"], width=270)
tip_col.pack(side="left", fill="y")
tip_col.pack_propagate(False)
tk.Label(tip_col, text="Strategy Info", bg=T["bg3"], fg=T["accent"],
font=("Segoe UI", 9, "bold"),
padx=10, pady=6, anchor="w").pack(fill="x")
tk.Frame(tip_col, bg=T["border"], height=1).pack(fill="x")
tip_body = tk.Label(tip_col,
text="Hover over a strategy\nto see its description.",
bg=T["bg3"], fg=T["fg2"],
font=("Segoe UI", 9), wraplength=250,
justify="left", padx=10, pady=8, anchor="nw")
tip_body.pack(fill="both", expand=True)
tk.Frame(item_col, bg=T["border"], height=1).pack(fill="x")
for val in self._values:
lbl = tk.Label(item_col, text=val,
bg="#0d0d0d", fg=T["fg"],
font=("Segoe UI", 10),
padx=14, pady=7, anchor="w", cursor="hand2")
lbl.pack(fill="x")
tk.Frame(item_col, bg=T["border"], height=1).pack(fill="x")
def _enter(e, v=val, l=lbl):
l.configure(bg=T["accent"], fg="#000000")
tip_body.configure(text=(self._tooltips or {}).get(v, ""))
def _leave(e, l=lbl):
l.configure(bg="#0d0d0d", fg=T["fg"])
def _click(e, v=val):
try:
popup.grab_release()
popup.destroy()
except Exception:
pass
self._pick(v)
lbl.bind("<Enter>", _enter)
lbl.bind("<Leave>", _leave)
lbl.bind("<Button-1>", _click)
popup.update_idletasks()
rx = self.winfo_rootx()
ry = self.winfo_rooty() + self._dim_h
sw = popup.winfo_screenwidth()
sh = popup.winfo_screenheight()
pw = popup.winfo_reqwidth()
ph = popup.winfo_reqheight()
px = min(rx, sw - pw - 4)
py = min(ry, sh - ph - 4)
popup.geometry(f"+{px}+{py}")
popup.grab_set()
popup.focus_set()
def _check_outside(e):
try:
ex, ey = e.x_root, e.y_root
px2 = popup.winfo_x(); py2 = popup.winfo_y()
pw2 = popup.winfo_width(); ph2 = popup.winfo_height()
if not (px2 <= ex <= px2 + pw2 and py2 <= ey <= py2 + ph2):
popup.grab_release()
popup.destroy()
except Exception:
pass
popup.bind("<Button-1>", _check_outside, add="+")
popup.bind("<Escape>", lambda e: [popup.grab_release(), popup.destroy()])
def _pick(self, val):
self._var.set(val)
if self._on_change:
self._on_change()
def update_values(self, new_values):
self._values = list(new_values)
class _PillEntry(tk.Frame):
"""
Compact pill-shaped entry matching the green-pill style.
`entry` attribute exposes the inner tk.Entry for event binding.
"""
def __init__(self, parent, textvariable, width_chars=10, height=28,
radius=None, **kw):
try:
parent_bg = parent.cget("bg")
except Exception:
parent_bg = T["bg"]
super().__init__(parent, bg=parent_bg, bd=0,
highlightthickness=0, **kw)
char_px = _s(7)
px_w = width_chars * char_px + _s(20)
px_h = _s(height)
if radius is None:
radius = px_h // 2
cv = tk.Canvas(self, width=px_w, height=px_h,
highlightthickness=0, bd=0, bg=parent_bg)
cv.pack()
pts = _pill_pts(px_w, px_h, radius)
cv.create_polygon(pts, smooth=1,
fill=T["accent"], outline=T["accent"], width=0)
inner = tk.Frame(cv, bg=T["accent"], bd=0, highlightthickness=0)
cv.create_window(px_w // 2, px_h // 2, window=inner,
width=px_w - _s(8), height=px_h - _s(6))
self.entry = tk.Entry(
inner, textvariable=textvariable, width=width_chars,
bg=T["accent"], fg="#000000", insertbackground="#000000",
font=T["font_small"], relief="flat", bd=0,
highlightthickness=0,
)
self.entry.pack(fill="x", expand=True, padx=4)
class _RoundedButton(tk.Canvas):
"""A canvas-drawn button with rounded corners. Supports set_state / set_active."""
def __init__(self, parent, text, command, width=110, height=36,
bg=T["accent"], fg="#000000", border=None, radius=10, **kw):
super().__init__(parent, width=width, height=height,
highlightthickness=0, bd=0,
bg=parent.cget("bg"), **kw)
self._bg = bg
self._fg = fg
self._border = border or bg
self._enabled = True
self._command = command
pts = _pill_pts(width, height, radius)
self._rect = self.create_polygon(pts, smooth=1,
fill=bg, outline=bg, width=0)
self._lbl = self.create_text(width // 2, height // 2, text=text,
fill=fg, font=("Segoe UI", 10, "bold"))
self.config(cursor="hand2")
self.bind("<Button-1>", self._on_click)
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
def _on_click(self, _e=None):
if self._enabled:
self._command()
def _on_enter(self, _e=None):
if self._enabled:
# If accent-filled, darken; otherwise show slight highlight
self.itemconfig(self._rect, fill="#00c060" if self._bg == T["accent"] else T["bg3"])
def _on_leave(self, _e=None):
if self._enabled:
self.itemconfig(self._rect, fill=self._bg)
def set_state(self, state: str):
"""Enable or disable the button ('normal' / 'disabled')."""
self._enabled = (state == "normal")
if self._enabled:
self.itemconfig(self._rect, fill=self._bg, outline=self._bg)
self.itemconfig(self._lbl, fill=self._fg)
self.config(cursor="hand2")
else:
self.itemconfig(self._rect, fill=T["bg3"], outline=T["bg3"])
self.itemconfig(self._lbl, fill=T["fg2"])
self.config(cursor="")
def set_active(self, active: bool):
"""Highlight the button as the active tab (accent text only, no outline)."""
if active:
self.itemconfig(self._lbl, fill=T["accent"])
else:
self.itemconfig(self._rect, outline=self._border)
self.itemconfig(self._lbl, fill=self._fg)
class _CustomNotebook(tk.Frame):
"""
Borderless drop-in replacement for ttk.Notebook.
Uses _RoundedButton tab buttons; content frames are shown/hidden via pack.
Public API: add(frame, text) — same signature as ttk.Notebook.add.
pack() / pack_forget() are inherited from tk.Frame.
"""
def __init__(self, parent, **kw):
bg = kw.pop("bg", T["bg"])
super().__init__(parent, bg=bg, **kw)
self._bg = bg
self._tabs: list = [] # (label, frame, _RoundedButton)
self._active_frame = None
# Row of tab buttons
self._tab_bar = tk.Frame(self, bg=bg)
self._tab_bar.pack(side="top", fill="x", padx=_s(6), pady=(_s(4), _s(2)))
# Thin separator between tab bar and content area
self._sep = tk.Frame(self, bg=T["border"], height=1)
self._sep.pack(side="top", fill="x")
def add(self, frame: tk.Frame, text: str):
"""Register a content frame as a new tab."""
label = text.strip()
w = max(_s(55), len(label) * _s(7) + _s(16))
btn = _RoundedButton(
self._tab_bar, label,
lambda l=label: self._show_tab(l),
width=w, height=_s(26), radius=_s(6),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"],
)
btn.pack(side="left", padx=(_s(2), 0), pady=_s(2))
self._tabs.append((label, frame, btn))
# Show the very first tab immediately
if len(self._tabs) == 1:
frame.pack(fill="both", expand=True)
btn.set_active(True)
self._active_frame = frame
def _show_tab(self, label: str):
"""Switch the visible content frame to the one with the given label."""
for lbl, frame, btn in self._tabs:
if lbl == label:
if self._active_frame is not None and self._active_frame is not frame:
self._active_frame.pack_forget()
frame.pack(fill="both", expand=True)
btn.set_active(True)
self._active_frame = frame
else:
btn.set_active(False)
class _ScoreBar(tk.Frame):
"""
Animated horizontal score bar (0100) with a rounded pill outline.
Matches the top progress-bar colour (green fill, border outline).
Call animate_to(value) to animate from 0 to that value.
Call reset() to blank it back to zero.
"""
BAR_W = 200 # canvas width (px)
BAR_H = 16 # canvas height (px)
PAD = 2 # gap between canvas edge and the pill track
def __init__(self, parent, **kw):
bg = kw.pop("bg", T["bg2"])
super().__init__(parent, bg=bg, **kw)
self._bg = bg
self._current = 0.0
self._target = 0.0
self._after_id = None
# Scale canvas dimensions to match the display DPI
self._bar_w = _s(self.BAR_W)
self._bar_h = _s(self.BAR_H)
self._pad = _s(self.PAD)
self._cv = tk.Canvas(self, width=self._bar_w, height=self._bar_h,
bg=bg, highlightthickness=0, bd=0)
self._cv.pack(side="left")
self._lbl = tk.Label(self, text="", bg=bg, fg=T["accent"],
font=("Segoe UI", 10, "bold"),
width=4, anchor="w")
self._lbl.pack(side="left", padx=(_s(10), 0))
self._draw_track()
def _draw_track(self):
W, H, P = self._bar_w, self._bar_h, self._pad
inner_w = W - 2 * P
inner_h = H - 2 * P
r = inner_h // 2
pts = [v + P for v in _pill_pts(inner_w, inner_h, r=r)]
self._cv.create_polygon(pts, smooth=1,
fill="#0d0d0d", outline="#111111",
width=1, tags="track")
def _draw_fill(self, pct: float):
self._cv.delete("fill")
if pct <= 0:
return
W, H, P = self._bar_w, self._bar_h, self._pad
inner_w = W - 2 * P
inner_h = H - 2 * P
r = inner_h // 2 # = 6
fill_px = max(inner_h, int(inner_w * pct / 100.0))
fill_px = min(fill_px, inner_w)
color = T["accent"]
x0, y0 = P, P
# Left circle (left rounded cap)
self._cv.create_oval(x0, y0, x0 + inner_h, y0 + inner_h,
fill=color, outline="", tags="fill")
# Middle rectangle
if fill_px > inner_h:
self._cv.create_rectangle(x0 + r, y0,
x0 + fill_px - r, y0 + inner_h,
fill=color, outline="", tags="fill")
# Right circle (right rounded cap)
if fill_px >= inner_h:
self._cv.create_oval(x0 + fill_px - inner_h, y0,
x0 + fill_px, y0 + inner_h,
fill=color, outline="", tags="fill")
def reset(self):
if self._after_id:
self.after_cancel(self._after_id)
self._after_id = None
self._current = 0.0
self._target = 0.0
self._draw_fill(0)
self._lbl.config(text="")
def animate_to(self, target: float):
"""Animate the bar filling from 0 to target (0100). Ease-out curve."""
if self._after_id:
self.after_cancel(self._after_id)
self._after_id = None
self._current = 0.0
self._target = max(0.0, min(100.0, float(target)))
self._lbl.config(text=f"{round(self._target)}%")
self._tick()
def _tick(self):
if not self.winfo_exists():
return
remaining = self._target - self._current
if abs(remaining) < 0.4:
self._current = self._target
self._draw_fill(self._current)
return
# Ease-out: step = remaining × 0.15, minimum 1.0 per frame
self._current += max(1.0, remaining * 0.15)
self._current = min(self._current, self._target)
self._draw_fill(self._current)
self._after_id = self.after(16, self._tick) # ~60 fps
class _RoundedEntry(tk.Frame):
"""Entry widget wrapped in a rounded-border canvas frame."""
def __init__(self, parent, textvariable, width_px=420, height_px=46,
font=("Segoe UI", 14), radius=10, icon="", **kw):
super().__init__(parent, bg=parent.cget("bg"), bd=0, highlightthickness=0)
c = tk.Canvas(self, width=width_px, height=height_px,
highlightthickness=0, bd=0, bg=parent.cget("bg"))
c.pack()
pts = _pill_pts(width_px, height_px, radius)
c.create_polygon(pts, smooth=1, fill=T["accent"],
outline=T["accent"], width=1)
inner = tk.Frame(c, bg=T["accent"], bd=0, highlightthickness=0)
c.create_window(width_px // 2, height_px // 2, window=inner,
width=width_px - 4, height=height_px - 4)
if icon:
tk.Label(inner, text=icon, bg=T["accent"], fg="#000000",
font=("Segoe UI", 15), padx=_s(12)).pack(side="left")
self.entry = tk.Entry(inner, textvariable=textvariable,
bg=T["accent"], fg="#000000", insertbackground="#000000",
font=font, relief="flat", bd=0,
highlightthickness=0, **kw)
self.entry.pack(side="left", fill="x", expand=True, ipady=0, padx=(0, _s(12)))
# ---------------------------------------------------------------------------
# Search Tab View
# ---------------------------------------------------------------------------
class _SearchTabView(tk.Frame):
"""
Google-style stock search view embedded as a tab in the main app.
Hero section (centered search bar) transitions to a detail panel
below once a result is found.
"""
_SCORE_FIELDS = ScreenerApp._SCORE_FIELDS
_VAL_FIELDS = ScreenerApp._VAL_FIELDS
_GROWTH_FIELDS = ScreenerApp._GROWTH_FIELDS
_MOM_FIELDS = ScreenerApp._MOM_FIELDS
_QUAL_FIELDS = ScreenerApp._QUAL_FIELDS
_ANLST_FIELDS = ScreenerApp._ANLST_FIELDS
def __init__(self, parent, df=None, df_raw=None, get_weights=None, get_sector_stats=None):
super().__init__(parent, bg=T["bg"])
self._df = df
self._df_raw = df_raw
self._get_weights = get_weights or (lambda: None)
self._get_sector_stats = get_sector_stats or (lambda: {})
self._last_row_dict = None # last displayed row (raw, pre-scored fields preserved)
self._last_row_live = False # True = live fetch, False = from df
self._detail_labels: dict = {}
self._score_bars: dict = {}
self._chart_ticker = None
self._chart_canvas = None
self._info_popup = None
self._results_shown = False
self._build_hero()
self._build_results()
def update_df(self, df, df_raw=None):
self._df = df
self._df_raw = df_raw
def refresh_current_result(self):
"""Re-score and re-display the current result when the strategy changes."""
if self._last_row_dict is None or not self._results_shown:
return
if not self._last_row_live and self._df is not None:
# For df-sourced results, look up the freshly re-scored row
ticker = self._last_row_dict.get("ticker", "")
mask = self._df["ticker"].str.upper() == ticker.upper()
if mask.any():
import pandas as pd
fresh = self._df[mask].iloc[0]
self._last_row_dict = fresh.to_dict()
self.after(0, lambda: self._populate(
self._last_row_dict,
f"Found in screener data: {ticker}"))
return
# For live-fetched results, re-score with new weights
try:
import pandas as pd
row = dict(self._last_row_dict)
_weights = self._get_weights()
scored = score_stocks(pd.DataFrame([row]), weights=_weights,
sector_stats=self._get_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:
row[col] = s[col]
self._last_row_dict = row
self.after(0, lambda r=row: self._populate(
r, f"Live data: {r['ticker']}"))
except Exception:
pass
# ── Hero (Google-style centered search) ───────────────────────────── #
def _build_hero(self):
self._hero_outer = tk.Frame(self, bg=T["bg"])
self._hero_outer.pack(fill="both", expand=True)
hero = tk.Frame(self._hero_outer, bg=T["bg"])
hero.place(relx=0.5, rely=0.42, anchor="center")
# App name
tk.Label(hero, text=_APP_NAME.upper(), bg=T["bg"], fg=T["accent"],
font=("Segoe UI", 24, "bold")).pack(pady=(0, 28))
# Rounded search bar
self._search_var = tk.StringVar()
re_widget = _RoundedEntry(hero, textvariable=self._search_var,
width_px=_s(440), height_px=_s(48), radius=_s(12))
re_widget.pack(pady=(0, _s(4)))
re_widget.entry.bind("<Return>", lambda _e: self._do_search())
re_widget.entry.focus_set()
# Rounded search button
btn_row = tk.Frame(hero, bg=T["bg"])
btn_row.pack(pady=_s(14))
_RoundedButton(btn_row, "Search Stock", self._do_search,
width=_s(140), height=_s(38), radius=_s(10)
).pack(side="left", padx=_s(6))
# Status label
self._search_status = tk.StringVar()
tk.Label(hero, textvariable=self._search_status,
bg=T["bg"], fg=T["fg2"], font=T["font_small"]).pack(pady=(4, 0))
# ── Results panel (hidden until search succeeds) ──────────────────── #
def _build_results(self):
self._results_frame = tk.Frame(self, bg=T["bg"],
highlightthickness=0)
# Header row: title + Back button
hdr = tk.Frame(self._results_frame, bg=T["bg"])
hdr.pack(fill="x", side="top")
self._detail_title = tk.Label(
hdr, text="", bg=T["bg"], fg=T["accent"], font=T["font_head"],
anchor="w", pady=12, padx=16)
self._detail_title.pack(side="left", fill="x", expand=True)
back_wrap = tk.Frame(hdr, bg=T["bg"])
back_wrap.pack(side="right", padx=_s(12), pady=_s(6))
_RoundedButton(back_wrap, "← Back", self._go_back,
width=_s(90), height=_s(32), radius=_s(8)).pack()
tk.Frame(self._results_frame, bg=T["border"], height=1).pack(fill="x")
self._notebook = _CustomNotebook(self._results_frame)
self._notebook.pack(fill="both", expand=True)
# Scores tab — animated bar widgets
scores_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(scores_tab, text=" Scores ")
self._build_scores_tab_content(scores_tab)
for title, fields in [
("Valuation", self._VAL_FIELDS),
("Growth", self._GROWTH_FIELDS),
("Momentum", self._MOM_FIELDS),
("Quality & Profit", self._QUAL_FIELDS),
]:
tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(tab, text=f" {title} ")
self._detail_labels.update(self._build_tab_content(tab, fields))
analyst_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(analyst_tab, text=" Analyst ")
self._build_analyst_tab(analyst_tab)
self._chart_ticker = None
chart_tab = tk.Frame(self._notebook, bg=T["bg"])
self._notebook.add(chart_tab, text=" Chart ")
self._chart_period = tk.StringVar(value="6mo")
self._chart_parent_frame = chart_tab
self._build_chart_tab_simple(chart_tab)
def _show_results(self):
if not self._results_shown:
self._hero_outer.pack_forget()
self._results_frame.pack(fill="both", expand=True, padx=10, pady=(6, 10))
self._results_shown = True
def _go_back(self):
"""Return to the centered hero search view."""
self._results_frame.pack_forget()
self._hero_outer.pack_forget()
self._hero_outer.pack(fill="both", expand=True)
self._results_shown = False
self._search_status.set("")
self._search_var.set("")
def _build_chart_tab_simple(self, parent):
ctrl = tk.Frame(parent, bg=T["bg"])
ctrl.pack(fill="x", side="top")
tk.Frame(ctrl, bg=T["bg"], width=_s(12)).pack(side="left")
for val, lbl in [("1mo","1M"),("3mo","3M"),("6mo","6M"),
("1y","1Y"),("2y","2Y"),("5y","5Y")]:
b = tk.Radiobutton(
ctrl, text=lbl, variable=self._chart_period, value=val,
bg=T["bg"], fg=T["fg2"], selectcolor=T["accent"],
activebackground=T["bg"], activeforeground=T["accent"],
font=("Segoe UI", 9, "bold"), indicatoron=False,
relief="flat", overrelief="flat", padx=_s(10), pady=_s(5), bd=0,
cursor="hand2", command=self._refresh_chart,
)
b.pack(side="left", padx=1, pady=(_s(4), 0))
tk.Frame(ctrl, bg=T["border"], height=1).pack(fill="x", side="bottom")
self._chart_canvas_placeholder = tk.Label(
parent, text="Search for a stock to view its price chart.",
bg=T["bg"], fg=T["fg2"], font=T["font_small"])
self._chart_canvas_placeholder.pack(expand=True)
def _refresh_chart(self):
if self._chart_ticker:
threading.Thread(
target=self._fetch_and_render_chart,
args=(self._chart_ticker, self._chart_period.get(),
"_chart_canvas", self._chart_parent_frame),
daemon=True,
).start()
# ── Search logic ──────────────────────────────────────────────────── #
def _do_search(self):
query = self._search_var.get().strip().lstrip("$")
if not query:
return
self._search_status.set("Searching…")
self.update_idletasks()
threading.Thread(target=self._search_worker, args=(query,), daemon=True).start()
def _search_worker(self, query: str):
try:
row = self._find_in_df(query)
if row is not None:
import pandas as pd
if isinstance(row, pd.Series):
_row_d = row.to_dict()
else:
_row_d = dict(row)
self._last_row_dict = _row_d
self._last_row_live = False
def _safe_populate_df():
try:
self._populate(_row_d, f"Found in screener data: {_row_d['ticker']}")
except Exception as exc:
self._search_status.set(f"Display error: {exc}")
self.after(0, _safe_populate_df)
return
# Run _fetch_live in a sub-thread so we can impose a timeout
self.after(0, self._search_status.set, "Fetching data…")
fetch_result = [None]
def _do_fetch():
fetch_result[0] = _StockLookupWindow._fetch_live(self, query)
fetch_thread = threading.Thread(target=_do_fetch, daemon=True)
fetch_thread.start()
fetch_thread.join(timeout=20)
if fetch_thread.is_alive():
self.after(0, self._search_status.set,
"Search timed out. Check your connection and try again.")
return
live_row = fetch_result[0]
if live_row is None:
self.after(0, self._search_status.set,
f"No results for '{query}'. Try the exact ticker symbol.")
return
# Score the live row using the current strategy's weights
try:
import pandas as pd
_weights = self._get_weights()
scored = score_stocks(pd.DataFrame([live_row]), weights=_weights,
sector_stats=self._get_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
self._last_row_dict = dict(live_row)
self._last_row_live = True
def _safe_populate_live():
try:
self._populate(live_row, f"Live data: {live_row['ticker']}")
except Exception as exc:
self._search_status.set(f"Display error: {exc}")
self.after(0, _safe_populate_live)
except Exception as exc:
self.after(0, self._search_status.set, f"Search error: {exc}")
def _find_in_df(self, query: str):
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 _populate(self, row, status_msg: str):
import pandas as pd
if isinstance(row, pd.Series):
row = row.to_dict()
self._show_results()
# Delegate to _StockLookupWindow._populate which sets _search_status
# and populates all detail labels, score bars, commentary, and chart.
_StockLookupWindow._populate(self, row, status_msg)
# Delegate shared rendering to ScreenerApp / _StockLookupWindow
_build_tab_content = ScreenerApp._build_tab_content
_build_scores_tab_content = ScreenerApp._build_scores_tab_content
_update_score_bars = ScreenerApp._update_score_bars
_build_analyst_tab = ScreenerApp._build_analyst_tab
_update_commentary = ScreenerApp._update_commentary
_show_info_popup = ScreenerApp._show_info_popup
_fetch_and_render_chart = ScreenerApp._fetch_and_render_chart
_render_chart = ScreenerApp._render_chart
_set_chart_placeholder_text = ScreenerApp._set_chart_placeholder_text
_fetch_live = _StockLookupWindow._fetch_live
def _get_tooltip_text(self, label: str) -> str:
return _METRIC_TOOLTIPS.get(label, "")
# ---------------------------------------------------------------------------
# Tracking Tab — SEC EDGAR Form 4 Insider Transactions
# ---------------------------------------------------------------------------
class _InsiderTradesView(tk.Frame):
"""
Finviz-style insider trading table backed by SEC EDGAR Form 4 filings.
Fetches data in a background thread; clicking a row opens the SEC filing.
"""
_COLS = [
("Date", 72, "center"),
("Ticker", 65, "center"),
("Company", 175, "w"),
("Insider", 155, "w"),
("Title", 125, "w"),
("Type", 90, "center"),
("Shares", 90, "e"),
("Price", 70, "e"),
("Value", 90, "e"),
("After", 95, "e"),
]
_PERIODS = [("Today", 1), ("3D", 3), ("1W", 7), ("2W", 14)]
_TYPES = [("All", "all"), ("Buys", "buy"), ("Sales", "sale")]
def __init__(self, parent):
super().__init__(parent, bg=T["bg"])
self._all_trades: list[dict] = []
self._iid_to_url: dict[str, str] = {}
self._loading = False
self._has_loaded = False
self._days_back = 3
self._type_filter = "all"
self._sort_asc_map: dict[str, bool] = {}
self._status_var = tk.StringVar(
value="Click ↻ Refresh to load insider trading data.")
self._period_btns: dict = {}
self._type_btns: dict = {}
self._build_controls()
self._build_table()
# ── Controls ─────────────────────────────────────────────────────────── #
def _build_controls(self):
outer = tk.Frame(self, bg=T["bg"])
outer.pack(fill="x", padx=_s(10), pady=(_s(8), 0))
tk.Label(outer, text="Insider Transactions", bg=T["bg"], fg=T["accent"],
font=("Segoe UI", 12, "bold")).pack(side="left",
padx=(0, _s(16)))
def _vsep():
tk.Frame(outer, bg=T["border"], width=1,
height=_s(20)).pack(side="left", padx=_s(10), fill="y")
# Period selector
tk.Label(outer, text="Period", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
for lbl, days in self._PERIODS:
w = max(_s(38), len(lbl) * _s(7) + _s(10))
b = _RoundedButton(outer, lbl, lambda d=days: self._set_period(d),
width=w, height=_s(26), radius=_s(6),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(_s(4), 0))
self._period_btns[days] = b
self._activate_btn(self._period_btns, self._days_back)
_vsep()
# Type filter
tk.Label(outer, text="Type", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
for lbl, val in self._TYPES:
w = max(_s(38), len(lbl) * _s(7) + _s(10))
b = _RoundedButton(outer, lbl, lambda v=val: self._set_type(v),
width=w, height=_s(26), radius=_s(6),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(_s(4), 0))
self._type_btns[val] = b
self._activate_btn(self._type_btns, self._type_filter)
_vsep()
self._refresh_btn = _RoundedButton(
outer, "\u21bb Refresh", self._refresh,
width=_s(100), height=_s(26), radius=_s(6))
self._refresh_btn.pack(side="left")
tk.Label(outer, textvariable=self._status_var, bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left", padx=(_s(14), 0))
tk.Frame(self, bg=T["border"], height=1).pack(
fill="x", padx=_s(10), pady=(_s(8), 0))
# ── Table ─────────────────────────────────────────────────────────────── #
def _build_table(self):
frame = tk.Frame(self, bg=T["bg"])
frame.pack(fill="both", expand=True, padx=_s(10), pady=(_s(6), _s(8)))
cols = [c[0] for c in self._COLS]
self._tree = ttk.Treeview(frame, columns=cols, show="headings",
selectmode="browse")
for col, w, anchor in self._COLS:
self._tree.heading(col, text=col,
command=lambda c=col: self._sort_column(c))
self._tree.column(col, width=_s(w), anchor=anchor,
stretch=(col == "Company"))
# Alternating backgrounds
self._tree.tag_configure("odd", background=T["row_odd"])
self._tree.tag_configure("even", background=T["row_even"])
# Transaction-type foreground colours
self._tree.tag_configure("c_buy", foreground=T["accent"])
self._tree.tag_configure("c_sale", foreground=T["red"])
self._tree.tag_configure("c_award", foreground=T["yellow"])
self._tree.tag_configure("c_other", foreground=T["fg2"])
vsb = ttk.Scrollbar(frame, orient="vertical", command=self._tree.yview)
hsb = ttk.Scrollbar(frame, orient="horizontal", command=self._tree.xview)
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
self._tree.bind("<<TreeviewSelect>>", self._on_row_select)
# ── Button group ──────────────────────────────────────────────────────── #
@staticmethod
def _activate_btn(btn_dict: dict, active_key):
for key, btn in btn_dict.items():
is_active = (key == active_key)
bg = T["accent"] if is_active else T["bg3"]
fg = "#000000" if is_active else T["fg2"]
btn._bg = bg; btn._fg = fg; btn._border = bg
btn.itemconfig(btn._rect, fill=bg, outline=bg)
btn.itemconfig(btn._lbl, fill=fg)
def _set_period(self, days: int):
self._days_back = days
self._activate_btn(self._period_btns, days)
self._refresh()
def _set_type(self, val: str):
self._type_filter = val
self._activate_btn(self._type_btns, val)
self._apply_filter()
# ── Data fetch ────────────────────────────────────────────────────────── #
def on_show(self):
"""Called each time the tab becomes visible; auto-fetches on first show."""
if not self._has_loaded:
self._refresh()
def _refresh(self):
if self._loading:
return
self._loading = True
self._refresh_btn.set_state("disabled")
self._status_var.set("Fetching from SEC EDGAR...")
days = self._days_back
def _progress(done, total):
self.after(0, lambda: self._status_var.set(
f"Loading... ({done}/{total} filings)"))
def _worker():
try:
trades = fetch_insider_trades(
days_back=days, progress_cb=_progress)
except Exception:
trades = []
self.after(0, lambda t=trades: self._on_fetched(t))
threading.Thread(target=_worker, daemon=True).start()
def _on_fetched(self, trades: list):
self._loading = False
self._has_loaded = True
self._all_trades = trades
self._refresh_btn.set_state("normal")
self._apply_filter()
n = len(trades)
if n:
self._status_var.set(
f"{n} transaction{'s' if n != 1 else ''} loaded.")
else:
self._status_var.set("No transactions found for this period.")
# ── Filter & populate ─────────────────────────────────────────────────── #
def _apply_filter(self):
trades = self._all_trades or []
f = self._type_filter
if f == "buy":
trades = [t for t in trades if t.get("transaction_code") == "P"]
elif f == "sale":
trades = [t for t in trades if t.get("transaction_code") == "S"]
self._populate_table(trades)
def _populate_table(self, trades: list):
self._tree.delete(*self._tree.get_children())
self._iid_to_url.clear()
for i, tx in enumerate(trades):
bg_tag = "odd" if i % 2 else "even"
tc = tx.get("transaction_code", "")
color_tag = {"P": "c_buy", "S": "c_sale",
"A": "c_award"}.get(tc, "c_other")
# Format date "YYYY-MM-DD" → "Mon DD"
date_str = tx.get("tx_date") or tx.get("filed_date") or ""
try:
import calendar as _cal
y, m, d = date_str[:10].split("-")
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
except Exception:
pass
iid = str(i)
self._iid_to_url[iid] = tx.get("url", "")
self._tree.insert("", "end", iid=iid,
tags=(bg_tag, color_tag),
values=(
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")),
))
# ── Sorting ───────────────────────────────────────────────────────────── #
def _sort_column(self, col: str):
data = [(self._tree.set(iid, col), iid)
for iid in self._tree.get_children("")]
def _key(pair):
s = pair[0].replace(",", "").replace("$", "").replace("", "").strip()
for sfx, mult in [("B", 1e9), ("M", 1e6), ("K", 1e3)]:
if s.endswith(sfx):
try: return (0, float(s[:-1]) * mult)
except: pass
try: return (0, float(s))
except: return (1, s.lower())
asc = self._sort_asc_map.get(col, False)
data.sort(key=_key, reverse=not asc)
self._sort_asc_map[col] = not asc
for idx, (_, iid) in enumerate(data):
self._tree.move(iid, "", idx)
tags = [t for t in self._tree.item(iid, "tags")
if t not in ("odd", "even")]
tags.append("odd" if idx % 2 else "even")
self._tree.item(iid, tags=tags)
# ── Row click ─────────────────────────────────────────────────────────── #
def _on_row_select(self, _event=None):
sel = self._tree.selection()
if not sel:
return
url = self._iid_to_url.get(sel[0], "")
if url:
webbrowser.open(url)
# ---------------------------------------------------------------------------
# Hedge Fund Holdings tab (13F-HR)
# ---------------------------------------------------------------------------
class _HedgeFundView(tk.Frame):
"""
Table of individual stock holdings parsed from SEC 13F-HR filings.
One row per holding per fund; clicking a row opens the SEC filing index.
"""
_HF_COLS = [
("Filed", 82, "center"),
("Fund", 195, "w"),
("Company", 185, "w"),
("Shares", 90, "e"),
("Value", 90, "e"),
("Class", 60, "center"),
("Opt", 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):
super().__init__(parent, bg=T["bg"])
self._all_holdings: list[dict] = []
self._iid_to_url: dict[str, str] = {}
self._loading = False
self._has_loaded = False
self._days_back = 90
self._min_val = 1_000_000
self._sort_asc_map: dict[str, bool] = {}
self._status_var = tk.StringVar(
value="Click ↻ Refresh to load hedge fund holdings.")
self._period_btns: dict = {}
self._minval_btns: dict = {}
self._build_controls()
self._build_table()
# ── Controls ─────────────────────────────────────────────────────────── #
def _build_controls(self):
outer = tk.Frame(self, bg=T["bg"])
outer.pack(fill="x", padx=_s(10), pady=(_s(8), 0))
tk.Label(outer, text="Hedge Fund Holdings (13F-HR)", bg=T["bg"],
fg=T["accent"],
font=("Segoe UI", 12, "bold")).pack(side="left",
padx=(0, _s(16)))
def _vsep():
tk.Frame(outer, bg=T["border"], width=1,
height=_s(20)).pack(side="left", padx=_s(10), fill="y")
# Period selector
tk.Label(outer, text="Period", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
for lbl, days in self._PERIODS:
w = max(_s(38), len(lbl) * _s(7) + _s(10))
b = _RoundedButton(outer, lbl, lambda d=days: self._set_period(d),
width=w, height=_s(26), radius=_s(6),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(_s(4), 0))
self._period_btns[days] = b
self._activate_btn(self._period_btns, self._days_back)
_vsep()
# Minimum value filter (applied client-side)
tk.Label(outer, text="Min Value", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
for lbl, val in self._MIN_VALS:
w = max(_s(42), len(lbl) * _s(7) + _s(10))
b = _RoundedButton(outer, lbl, lambda v=val: self._set_min_val(v),
width=w, height=_s(26), radius=_s(6),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(_s(4), 0))
self._minval_btns[val] = b
self._activate_btn(self._minval_btns, self._min_val)
_vsep()
self._refresh_btn = _RoundedButton(
outer, "\u21bb Refresh", self._refresh,
width=_s(100), height=_s(26), radius=_s(6))
self._refresh_btn.pack(side="left")
tk.Label(outer, textvariable=self._status_var, bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left", padx=(_s(14), 0))
tk.Frame(self, bg=T["border"], height=1).pack(
fill="x", padx=_s(10), pady=(_s(8), 0))
# ── Table ─────────────────────────────────────────────────────────────── #
def _build_table(self):
frame = tk.Frame(self, bg=T["bg"])
frame.pack(fill="both", expand=True, padx=_s(10), pady=(_s(6), _s(8)))
cols = [c[0] for c in self._HF_COLS]
self._tree = ttk.Treeview(frame, columns=cols, show="headings",
selectmode="browse")
for col, w, anchor in self._HF_COLS:
self._tree.heading(col, text=col,
command=lambda c=col: self._sort_column(c))
self._tree.column(col, width=_s(w), anchor=anchor,
stretch=(col in ("Fund", "Company")))
self._tree.tag_configure("odd", background=T["row_odd"])
self._tree.tag_configure("even", background=T["row_even"])
self._tree.tag_configure("c_hf", foreground=T["accent"])
self._tree.tag_configure("c_opt", foreground=T["yellow"])
vsb = ttk.Scrollbar(frame, orient="vertical", command=self._tree.yview)
hsb = ttk.Scrollbar(frame, orient="horizontal", command=self._tree.xview)
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
self._tree.bind("<<TreeviewSelect>>", self._on_row_select)
# ── Button groups ─────────────────────────────────────────────────────── #
@staticmethod
def _activate_btn(btn_dict: dict, active_key):
for key, btn in btn_dict.items():
is_active = (key == active_key)
bg = T["accent"] if is_active else T["bg3"]
fg = "#000000" if is_active else T["fg2"]
btn._bg = bg; btn._fg = fg; btn._border = bg
btn.itemconfig(btn._rect, fill=bg, outline=bg)
btn.itemconfig(btn._lbl, fill=fg)
def _set_period(self, days: int):
self._days_back = days
self._activate_btn(self._period_btns, days)
self._refresh()
def _set_min_val(self, val: int):
self._min_val = val
self._activate_btn(self._minval_btns, val)
self._apply_filter()
# ── Data fetch ────────────────────────────────────────────────────────── #
def on_show(self):
"""Called each time the sub-tab becomes visible; auto-fetches on first show."""
if not self._has_loaded:
self._refresh()
def _refresh(self):
if self._loading:
return
self._loading = True
self._refresh_btn.set_state("disabled")
self._status_var.set("Fetching from SEC EDGAR...")
days = self._days_back
def _progress(done, total):
self.after(0, lambda: self._status_var.set(
f"Loading... ({done}/{total} funds)"))
def _worker():
try:
holdings = fetch_hedge_fund_filings(
days_back=days, progress_cb=_progress)
except Exception as _hf_err:
import traceback
print(f"[HF ERROR] fetch_hedge_fund_filings raised an exception:\n"
f"{traceback.format_exc()}")
holdings = []
self.after(0, lambda h=holdings: self._on_fetched(h))
threading.Thread(target=_worker, daemon=True).start()
def _on_fetched(self, holdings: list):
self._loading = False
self._has_loaded = True
self._all_holdings = holdings
self._refresh_btn.set_state("normal")
self._apply_filter()
n = len(holdings)
if n:
self._status_var.set(
f"{n} holding{'s' if n != 1 else ''} loaded.")
else:
self._status_var.set("No holdings found for this period. Check console for details.")
# ── Filter & populate ─────────────────────────────────────────────────── #
def _apply_filter(self):
mv = self._min_val
if mv > 0:
rows = [h for h in self._all_holdings
if (h.get("value") or 0) >= mv]
else:
rows = list(self._all_holdings)
self._populate_table(rows)
def _populate_table(self, holdings: list):
self._tree.delete(*self._tree.get_children())
self._iid_to_url.clear()
for i, h in enumerate(holdings):
bg_tag = "odd" if i % 2 else "even"
opt = (h.get("put_call") or "").strip()
color_tag = "c_opt" if opt else "c_hf"
# Format filed date "YYYY-MM-DD" → "Mon DD"
date_str = h.get("filed_date", "")
try:
import calendar as _cal
y, m, d = date_str[:10].split("-")
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
except Exception:
pass
# Format reporting period → "Q1 '25"
period_str = h.get("period", "")
try:
import calendar as _cal # noqa: F811
py, pm, _ = period_str[:10].split("-")
period_str = f"Q{((int(pm) - 1) // 3) + 1} '{py[2:]}"
except Exception:
pass
iid = str(i)
self._iid_to_url[iid] = h.get("url", "")
self._tree.insert("", "end", iid=iid,
tags=(bg_tag, color_tag),
values=(
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 "",
))
# ── Sorting ───────────────────────────────────────────────────────────── #
def _sort_column(self, col: str):
data = [(self._tree.set(iid, col), iid)
for iid in self._tree.get_children("")]
def _key(pair):
s = pair[0].replace(",", "").replace("$", "").replace("", "").strip()
for sfx, mult in [("B", 1e9), ("M", 1e6), ("K", 1e3)]:
if s.endswith(sfx):
try: return (0, float(s[:-1]) * mult)
except: pass
try: return (0, float(s))
except: return (1, s.lower())
asc = self._sort_asc_map.get(col, False)
data.sort(key=_key, reverse=not asc)
self._sort_asc_map[col] = not asc
for idx, (_, iid) in enumerate(data):
self._tree.move(iid, "", idx)
tags = [t for t in self._tree.item(iid, "tags")
if t not in ("odd", "even")]
tags.append("odd" if idx % 2 else "even")
self._tree.item(iid, tags=tags)
# ── Row click ─────────────────────────────────────────────────────────── #
def _on_row_select(self, _event=None):
sel = self._tree.selection()
if not sel:
return
url = self._iid_to_url.get(sel[0], "")
if url:
webbrowser.open(url)
# ---------------------------------------------------------------------------
# Tracking section container — sub-tabs: Insider Trades | Hedge Funds
# ---------------------------------------------------------------------------
class _TrackingTabView(tk.Frame):
"""
Container for the Tracking section. Houses two sub-tabs:
• Insider Trades — SEC Form 4 filings
• Hedge Funds — SEC 13F-HR holdings
"""
def __init__(self, parent):
super().__init__(parent, bg=T["bg"])
self._sub_btns: dict = {}
self._active_sub = ""
self._build_sub_tab_bar()
self._insider_view = _InsiderTradesView(self)
self._hf_view = _HedgeFundView(self)
self._show_insider_sub()
# ── Sub-tab bar ───────────────────────────────────────────────────────── #
def _build_sub_tab_bar(self):
bar = tk.Frame(self, bg=T["bg"])
bar.pack(fill="x", padx=_s(10), pady=(_s(6), 0))
for name, cmd in [
("Insider Trades", self._show_insider_sub),
("Hedge Funds", self._show_hf_sub),
]:
w = max(_s(110), len(name) * _s(7) + _s(16))
b = _RoundedButton(bar, name, cmd,
width=w, height=_s(28), radius=_s(7),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(0, _s(6)))
self._sub_btns[name] = b
def _update_sub_active(self, name: str):
for n, btn in self._sub_btns.items():
is_active = (n == name)
bg = T["accent"] if is_active else T["bg3"]
fg = "#000000" if is_active else T["fg2"]
btn._bg = bg; btn._fg = fg; btn._border = bg
btn.itemconfig(btn._rect, fill=bg, outline=bg)
btn.itemconfig(btn._lbl, fill=fg)
# ── Sub-tab switching ─────────────────────────────────────────────────── #
def _show_insider_sub(self):
self._hf_view.pack_forget()
self._insider_view.pack(fill="both", expand=True)
self._active_sub = "insider"
self._update_sub_active("Insider Trades")
self._insider_view.on_show()
def _show_hf_sub(self):
self._insider_view.pack_forget()
self._hf_view.pack(fill="both", expand=True)
self._active_sub = "hf"
self._update_sub_active("Hedge Funds")
self._hf_view.on_show()
# ── Delegate on_show ─────────────────────────────────────────────────── #
def on_show(self):
"""Called by the main app each time the Tracking tab becomes visible."""
if self._active_sub == "hf":
self._hf_view.on_show()
else:
self._insider_view.on_show()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
app = ScreenerApp()
app.mainloop()
if __name__ == "__main__":
main()