5530 lines
242 KiB
Python
5530 lines
242 KiB
Python
"""
|
||
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 0–100.\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 0–100.\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 0–100.\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 0–100.\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 0–100.\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 0–100.\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 0–100.\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 0–100.\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 0–100, 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"
|
||
"1–2 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 = "12–36 months (fundamental thesis)"
|
||
tf_reason = "Strong valuation discount and balance sheet quality support a longer holding period."
|
||
elif hi_mom:
|
||
timeframe = "3–12 months (momentum-driven)"
|
||
tf_reason = "Price trend is strong — re-evaluate if momentum fades."
|
||
else:
|
||
timeframe = "6–18 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 = "12–24 months (value unlock)"
|
||
tf_reason = "Undervalued on fundamentals — patience required for market to re-rate."
|
||
elif hi_mom and not hi_val:
|
||
timeframe = "1–6 months (trend play)"
|
||
tf_reason = "Momentum-driven opportunity. Set a stop-loss and monitor price action."
|
||
elif hi_growth:
|
||
timeframe = "6–18 months (growth compounding)"
|
||
tf_reason = "Strong growth trajectory. Suitable while earnings acceleration continues."
|
||
else:
|
||
timeframe = "6–12 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 1–3 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 0–100 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 (0–100) 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 (0–100). 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()
|