diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6fb306e..fe33c0f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,25 @@ "Bash(ls /c/Python*)", "Read(//c/Users/Nolan/AppData/Local/Programs/**)", "Bash(ls \"/c/Users/Nolan/AppData/Local/Programs/Python\" 2>/dev/null || echo \"not found\"\nls \"/c/Users/Nolan/AppData/Local/Microsoft/WindowsApps/\"python* 2>/dev/null || echo \"not found\")", - "Bash(cd \"C:/Users/Nolan/Desktop/Stock Tool\" && python -c \"import tkinter; import numpy; import pandas; import yfinance; import requests; print\\('All imports OK'\\)\" 2>&1)" + "Bash(cd \"C:/Users/Nolan/Desktop/Stock Tool\" && python -c \"import tkinter; import numpy; import pandas; import yfinance; import requests; print\\('All imports OK'\\)\" 2>&1)", + "Skill(update-config)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "cd \"C:/Users/noach/Desktop/Stock Tool/Stock Tool\" && python -m py_compile dist/stock_screener.py dist/screener_gui.py cache_builder.py api/main.py 2>&1 && echo \"[hook] SYNTAX OK\" || echo \"[hook] SYNTAX ERROR — see above\"" + }, + { + "type": "command", + "command": "cd \"C:/Users/noach/Desktop/Stock Tool/Stock Tool\" && python -m pytest tests/test_scoring.py -q --tb=short 2>&1 | tail -20" + } + ] + } ] } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cdf7161 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# Stock Tool — Claude Context + +## Project Overview +A multi-factor stock screener that evaluates ~6,500 US-listed equities using a composite +score across 8 dimensions: Value, Growth, Momentum, Quality, Profitability, Sentiment, +Analyst, and Risk. Sold as a licensed desktop app (PySide6 GUI). + +## File Map + +| File | Purpose | +|------|---------| +| `dist/stock_screener.py` | All scoring logic, data fetching, index/ticker collection. Pure Python + pandas. | +| `dist/screener_gui.py` | PySide6 GUI. Imports from `stock_screener`. Main class: `ScreenerApp(QMainWindow)`. | +| `cache_builder.py` | Nightly job that populates PostgreSQL cache from EDGAR + yfinance. Runs on VPS via cron. | +| `api/main.py` | FastAPI server on VPS. Handles license auth, cache serving, version management. | +| `updater.py` | Client-side auto-updater. Checks `/update` endpoint, downloads new build. | +| `license_check.py` | HMAC + JWT license validation. Baked into app via PyArmor. | +| `push_update.py` | Dev tool: SCP new build to VPS + register version via `/admin/register-version`. | +| `launcher.py` | Thin launcher that checks license before starting GUI. | +| `health_monitor.py` | VPS health monitoring. | +| `discord_bot/bot.py` | Discord bot integration (separate process). | + +## Architecture (3-tier) + +``` +[cache_builder.py] → [PostgreSQL on VPS] → [API] → [Client app] + nightly 6 tables FastAPI screener_gui + stock_screener +``` + +- `cache_builder.py` runs nightly (2am stable / 3am beta) via cron on the VPS +- Data flows: EDGAR XBRL Frames API + yfinance → PostgreSQL tables → served by FastAPI +- Client fetches cached data at startup via `/cache/analyst`, `/cache/fundamentals`, `/cache/sector` +- Scoring runs entirely client-side in `stock_screener.py` — no server-side scoring + +## PostgreSQL Tables (database: `verimund`, user: `verimund_user`) + +| Table | Contents | +|-------|----------| +| `analyst_cache` | Price history, returns, sentiment, analyst ratings, short interest (~6,975 rows) | +| `fundamentals_cache` | EDGAR-sourced financials: revenue, margins, D/E, ROA/ROE, Piotroski, accruals | +| `sector_stats` | Per-sector median/MAD for each metric. Used by `_z_score()` for normalization. | +| `app_versions` | Version registry per channel (stable/beta). Read by `/admin/versions`. | +| `licenses` | License keys + HWIDs + expiry. Validated at auth time. | +| `auth_log` | Auth attempt log. | + +## Scoring Formula + +``` +Composite = 0.18*Value + 0.18*Growth + 0.14*Momentum + 0.11*Quality + + 0.08*Profitability + 0.13*Sentiment + 0.10*Analyst + 0.08*Risk +``` + +Each score is 0–100. Z-scores use sector median/MAD from `sector_stats` table. +If sector stats are missing for a metric, `_z_score()` returns neutral 50. + +## Profile System (important) + +`_classify_profile(row)` classifies each stock into one of: +`mature`, `high_growth`, `financial`, `capital_intensive`, `early_stage`, `turnaround` + +Profiles define sub-weights per scoring dimension in `_PROFILE_WEIGHTS`. +Weights are normalized in `_profile_score_row()` — they don't need to sum to 1.0 in the dict. + +**D/E handling**: yfinance returns `debtToEquity` as percentage (e.g. 150 = 1.5x). +Code divides by 100 at ingest (`stock_screener.py` line ~1356). +Negative D/E means negative book equity — replaced with 999.0 before z-scoring. + +## Key Patterns + +- `_z_series(series, metric, sectors, sector_stats)` — vectorized z-score using sector stats +- `_score_series(series, breakpoints)` — absolute breakpoint scoring (no sector normalization) +- `_profile_score_row(row, dimension, metric_scores)` — blend metric scores using profile weights +- All scoring functions signature: `compute_X_score(df, sector_stats=None) → pd.Series` + +## VPS Infrastructure + +- **Provider**: Hetzner CPX21, Ubuntu 22.04 +- **Domains**: api.verimundsolutions.com (API), git.verimundsolutions.com (Gitea) +- **API**: FastAPI via uvicorn, managed by PM2 +- **Secrets**: `/srv/api/.env` on VPS (DB_PASS, JWT_SECRET, HMAC_SECRET, ADMIN_KEY) +- **Backups**: nightly rclone → Cloudflare R2 bucket `verimund-backups` +- **Git**: Gitea at git.verimundsolutions.com, repo: ssz223/Stock-Tool, branches: main (stable) / beta +- **Website**: Taken down intentionally (2026-04-03), will be re-done under different brand + +## Running Tests + +```bash +# Fast tests (run automatically via hook on every edit): +python -m pytest tests/test_scoring.py tests/test_gui.py -q + +# Full suite including network (run before pushing): +python -m pytest tests/ -v + +# Network tests only: +python -m pytest tests/test_network.py -v + +# Skip network tests: +python -m pytest tests/ -m "not network" -v +``` + +## Known Pitfalls + +- `screener_gui.py` uses PySide6 (not PyQt5). Offscreen: `QT_QPA_PLATFORM=offscreen` +- `screener_gui.py` imports from `stock_screener` (same dir) — tests must `cd dist/` or add dist to sys.path +- EDGAR XBRL Frames API: one call per concept returns all US filers. Foreign/IFRS filers can have data 18 months old with no user-facing flag. +- Sector stats built from `analyst_cache` in Phase 5 of cache_builder. If Phase 1 partially fails, sector medians are biased. +- `_sum_frames` requires >= 4 quarters for valid TTM — returns None otherwise. +- Forward PE not in sector stats — `_z_score` returns neutral 50 for forward PE z-scoring. diff --git a/api/main.py b/api/main.py index 2ca2f17..ece3f41 100644 --- a/api/main.py +++ b/api/main.py @@ -20,7 +20,9 @@ app = FastAPI() DB_HOST = "127.0.0.1" DB_NAME = "verimund" DB_USER = "verimund_user" -DB_PASS = os.getenv("DB_PASS", "Shlevison2k17") +DB_PASS = os.getenv("DB_PASS") +if not DB_PASS: + raise RuntimeError("DB_PASS environment variable is not set") JWT_SECRET = os.getenv("JWT_SECRET", "") HMAC_SECRET = os.getenv("HMAC_SECRET", "") ADMIN_KEY = os.getenv("ADMIN_KEY", "") @@ -157,11 +159,14 @@ def check_update(payload=Depends(verify_jwt), db=Depends(get_db)): @app.get("/admin/versions") -def get_versions(_=Depends(verify_admin), db=Depends(get_db)): +def get_versions(channel: str = "stable", _=Depends(verify_admin), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute("SELECT * FROM app_versions ORDER BY released_at DESC LIMIT 1") + cur.execute( + "SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1", + (channel,) + ) version = cur.fetchone() - return {"version": version["version"] if version else "1.0.0"} + return {"version": version["version"] if version else "1.0.0", "channel": channel} @app.post("/admin/register-version") diff --git a/cache_builder.py b/cache_builder.py index 3f56b2a..1b0d091 100644 --- a/cache_builder.py +++ b/cache_builder.py @@ -474,7 +474,7 @@ _ANALYST_COLS = ( "news_headline_count", "sector", "industry", "short_percent", "short_ratio", "updated_at", ) _FUND_COLS = ( - "ticker", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing", + "ticker", "name", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing", "revenue", "revenue_growth", "earnings_growth", "roe", "roa", "debt_to_equity", "total_debt", "total_cash", "book_value", "current_ratio", "profit_margin", "operating_margin", "fcf_yield", "dividend_yield", @@ -747,7 +747,13 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]: cash.get(cik), lt_debt.get(cik), shares.get(cik), ) + try: + _info = yf.Ticker(ticker).info or {} + _name = _info.get("longName") or _info.get("shortName") or None + except Exception: + _name = None metrics["ticker"] = ticker + metrics["name"] = _name metrics["filer_type"] = "us-gaap-quarterly" metrics["updated_at"] = datetime.now(timezone.utc).isoformat() result[ticker] = metrics @@ -778,7 +784,13 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]: cash.get(cik), lt_debt.get(cik), shares.get(cik), ) + try: + _info = yf.Ticker(ticker).info or {} + _name = _info.get("longName") or _info.get("shortName") or None + except Exception: + _name = None metrics["ticker"] = ticker + metrics["name"] = _name metrics["filer_type"] = "us-gaap-quarterly" metrics["updated_at"] = datetime.now(timezone.utc).isoformat() result[ticker] = metrics @@ -915,7 +927,13 @@ def run_foreign_filers_phase() -> dict[str, dict]: raw.get("cash"), raw.get("lt_debt"), raw.get("shares"), ) + try: + _info = yf.Ticker(ticker).info or {} + _name = _info.get("longName") or _info.get("shortName") or None + except Exception: + _name = None metrics["ticker"] = ticker + metrics["name"] = _name metrics["filer_type"] = f"{taxonomy}-annual" metrics["updated_at"] = datetime.now(timezone.utc).isoformat() result[ticker] = metrics diff --git a/dist/screener_gui.py b/dist/screener_gui.py index b160ff8..bef1f12 100644 --- a/dist/screener_gui.py +++ b/dist/screener_gui.py @@ -1,102 +1,40 @@ """ -Ultimate Investment Tool GUI -============================ -Graphical interface for the multi-factor stock screener. - -Run: python screener_gui.py -Build exe: build.bat +Ultimate Investment Tool GUI — PySide6 rewrite +Part 1: imports, constants, formatters, tooltips, field defs, signals, redirector """ - -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 ctypes, html as _html, io, math, multiprocessing, re, sys, threading, webbrowser +from datetime import datetime import numpy as np +from PySide6.QtCore import * +from PySide6.QtWidgets import * +from PySide6.QtGui import * + _MATPLOTLIB_OK = False -_MATPLOTLIB_ERR = "" +_mpl_err = "" try: - import matplotlib - matplotlib.use("TkAgg") + import matplotlib; matplotlib.use("QtAgg") import matplotlib.ticker as mticker from matplotlib.figure import Figure - from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg import matplotlib.dates as mdates _MATPLOTLIB_OK = True except Exception as _e: - _MATPLOTLIB_ERR = str(_e) + _mpl_err = str(_e) _YF_OK = False try: - import yfinance as yf - _YF_OK = True + 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." - ), -} + collect_tickers, fetch_all, score_stocks, + _load_sector_stats, get_news_headlines, + fetch_insider_trades, fetch_hedge_fund_filings) 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() @@ -108,54 +46,118 @@ try: except Exception: _APP_VERSION = "?" -_APP_NAME = "Ultimate Investment Tool" +_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 +_C = { + "bg": "#0a0a0a", + "bg2": "#111111", + "bg3": "#181818", + "border": "#222222", + "fg": "#ffffff", + "fg2": "#888888", + "fg3": "#3a3a3a", + "positive": "#4ade80", + "negative": "#f87171", + "warning": "#fbbf24", +} + +_QSS = """ +QMainWindow, QWidget {{ background: {bg}; color: {fg}; font-family: 'Segoe UI'; font-size: 13px; }} +QSplitter::handle {{ background: {border}; width: 1px; }} +QScrollBar:vertical {{ background: {bg}; width: 6px; border: none; }} +QScrollBar::handle:vertical {{ background: {border}; border-radius: 3px; min-height: 20px; }} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} +QScrollBar:horizontal {{ background: {bg}; height: 6px; border: none; }} +QScrollBar::handle:horizontal {{ background: {border}; border-radius: 3px; }} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} +QTableView, QTableWidget {{ background: {bg}; gridline-color: {border}; border: none; outline: none; selection-background-color: {bg2}; selection-color: {fg}; }} +QTableView::item, QTableWidget::item {{ padding: 0 8px; border: none; }} +QHeaderView::section {{ background: {bg2}; color: {fg2}; padding: 6px 8px; border: none; border-bottom: 1px solid {border}; font-size: 11px; font-weight: 600; }} +QTabWidget::pane {{ border: none; background: {bg}; }} +QTabBar::tab {{ background: {bg}; color: {fg2}; padding: 8px 18px; font-size: 12px; border: none; border-bottom: 2px solid transparent; }} +QTabBar::tab:selected {{ color: {fg}; border-bottom: 2px solid {fg}; }} +QTabBar::tab:hover {{ color: {fg2}; background: {bg3}; }} +QLineEdit {{ background: {bg3}; color: {fg}; border: 1px solid {border}; border-radius: 6px; padding: 5px 10px; font-size: 12px; }} +QLineEdit:focus {{ border-color: {fg2}; }} +QComboBox {{ background: {bg3}; color: {fg}; border: 1px solid {border}; border-radius: 6px; padding: 5px 10px; font-size: 12px; min-width: 80px; }} +QComboBox::drop-down {{ border: none; width: 20px; }} +QComboBox QAbstractItemView {{ background: {bg2}; color: {fg}; selection-background-color: {bg3}; border: 1px solid {border}; }} +QPushButton {{ background: {bg3}; color: {fg2}; border: 1px solid {border}; border-radius: 7px; padding: 6px 14px; font-size: 12px; font-weight: 600; }} +QPushButton:hover {{ background: rgba(255,255,255,0.06); color: {fg}; }} +QPushButton#run-btn {{ background: {fg}; color: #000000; border-color: {fg}; font-weight: 700; }} +QPushButton#run-btn:hover {{ background: #e0e0e0; }} +QPushButton:disabled {{ color: {fg3}; background: {bg3}; border-color: {border}; }} +QProgressBar {{ background: {bg3}; border: none; border-radius: 0; max-height: 3px; }} +QProgressBar::chunk {{ background: {fg}; border-radius: 0; }} +QTextEdit {{ background: {bg3}; color: {fg}; border: none; font-family: 'Segoe UI'; font-size: 12px; padding: 4px; }} +QToolTip {{ background: #1c1c1c; color: {fg}; border: 1px solid #333333; padding: 8px 12px; font-size: 11px; }} +""".format(**_C) # --------------------------------------------------------------------------- -# 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. +# Formatters # --------------------------------------------------------------------------- -_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)) +def _nan(v): return v is None or (isinstance(v, float) and math.isnan(v)) +def _pct(v): return "—" if _nan(v) else f"{v * 100:.1f}%" +def _flt(v, d=2): return "—" if _nan(v) else f"{v:.{d}f}" +def _price(v): return "—" if _nan(v) else f"${v:,.2f}" +def _score(v): return "—" if _nan(v) else f"{v:.1f}" +def _mcap(v): + if _nan(v): return "—" + if v >= 1e12: return f"${v/1e12:.2f}T" + if v >= 1e9: return f"${v/1e9:.2f}B" + if v >= 1e6: return f"${v/1e6:.2f}M" + return f"${v:.0f}" +def _fmt_shares(v): return "—" if v is None else f"{int(v):,}" +def _fmt_value(v): + if v is None: return "—" + if v >= 1e9: return f"${v/1e9:.1f}B" + if v >= 1e6: return f"${v/1e6:.1f}M" + if v >= 1e3: return f"${v/1e3:.0f}K" + return f"${v:.0f}" # --------------------------------------------------------------------------- -# 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". +# Strategy descriptions +# --------------------------------------------------------------------------- + +STRATEGY_DESCRIPTIONS = { + "Balanced (Default)": ( + "Best for: General all-purpose screening.\n\n" + "Blends all 8 factors evenly — no strong directional bias." + ), + "High Growth Companies": ( + "Best for: High-velocity growth investing.\n\n" + "Heavily weights revenue/earnings growth (28%) and price momentum (25%)." + ), + "Conservative": ( + "Best for: Capital preservation and low volatility.\n\n" + "Prioritises balance sheet quality (25%) and profitability (18%)." + ), + "Recent Uptrends": ( + "Best for: Short-term technical trend-following.\n\n" + "Momentum dominates at 40% — driven by 6-month price return." + ), + "Buy and Hold Long Term": ( + "Best for: Value-focused buy-and-hold investors.\n\n" + "Screens for deeply undervalued stocks by P/E, P/B, and EV/EBITDA (30%)." + ), + "Low Risk, High Dividend Yield": ( + "Best for: Income-focused portfolios seeking stable cash flow.\n\n" + "Profitability (28%) and quality (22%) dominate." + ), + "High Risk High Reward": ( + "Best for: Contrarian bounce plays on oversold names.\n\n" + "Reverse momentum (35%) rewards beaten-down stocks." + ), + "Custom...": ( + "Define your own strategy.\n\n" + "Set custom weights for each of the 8 scoring factors." + ), +} + +# --------------------------------------------------------------------------- +# Sector filter map # --------------------------------------------------------------------------- _SECTOR_FILTER_MAP = { @@ -173,51 +175,154 @@ _SECTOR_FILTER_MAP = { "Communication": ("sector", "Communication Services"), "Utilities": ("sector", "Utilities"), } - SECTOR_CHOICES = list(_SECTOR_FILTER_MAP.keys()) - # --------------------------------------------------------------------------- -# Theme +# Metric tooltips # --------------------------------------------------------------------------- -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), +_METRIC_TOOLTIPS: dict = { + "Composite Score": ( + "The overall investment attractiveness of the stock, scored 0-100.\n\n" + "Calculated as a weighted blend of all 8 factor scores." + ), + "Value": "How cheaply the stock is priced relative to earnings, assets, and cash flow — scored 0-100.", + "Growth": "How fast the company is expanding its business — scored 0-100.", + "Momentum": "How strong the stock's recent price trend has been — scored 0-100.", + "Quality": "How financially sound and efficiently run the company is — scored 0-100.", + "Profitability": "How much of its revenue the company actually keeps as profit — scored 0-100.", + "Sentiment": "The overall tone of recent news coverage about the company — scored 0-100.", + "Analyst": "How bullish professional analysts are on the stock — scored 0-100.", + "Risk": "How low-risk the stock appears — scored 0-100, where higher = safer.", + "Price": "The most recent closing market price of the stock in USD.", + "Market Cap": "The total market value of the company — share price multiplied by shares outstanding.", + "P/E Forward": "How much investors are paying per dollar of the company's expected future earnings.", + "P/E Trailing": "How much investors are paying per dollar of actual earnings over the past 12 months.", + "P/B Ratio": "How much investors are paying relative to the company's net asset value.", + "EV / EBITDA": "Enterprise Value divided by operating earnings before non-cash charges.", + "52W High": "The highest price the stock reached over the past 52 weeks.", + "52W Low": "The lowest price the stock reached over the past 52 weeks.", + "Book Value": "Per-share net worth: total assets minus total liabilities divided by shares.", + "Revenue Growth": "How much total sales grew compared to the same period a year ago.", + "Earnings Growth": "How much net profit grew compared to a year ago.", + "EPS Forward": "Analyst consensus estimate for earnings per share over the next 12 months.", + "EPS Trailing": "Actual earnings per share reported over the past 12 months.", + "1-Month Return": "Stock's total price return over approximately the past month.", + "3-Month Return": "Stock's total price return over approximately the past three months.", + "6-Month Return": "Stock's total price return over approximately the past six months.", + "12-Month Return": "Stock's total price return over approximately the past year.", + "ROE": "Return on Equity — profit generated per dollar of shareholders' equity.", + "ROA": "Return on Assets — profit generated per dollar of total assets.", + "Revenue": "Total revenue earned by the company over the trailing twelve months.", + "Net Income": "The company's bottom-line profit over the trailing twelve months.", + "Operating Income": "Profit from core business operations before interest and taxes.", + "Total Debt": "Total amount the company owes to creditors.", + "Total Equity": "Total book value belonging to shareholders.", + "Total Cash": "Total cash and short-term liquid investments held by the company.", + "Current Ratio": "Current assets divided by current liabilities — measures short-term liquidity.", + "Profit Margin": "Percentage of revenue kept as net profit after all expenses.", + "Operating Margin": "Percentage of revenue remaining as profit from core operations.", + "FCF Yield": "Free cash flow generated relative to market value, as a percentage.", + "Dividend Yield": "Annual dividend per share as a percentage of the current stock price.", + "Recommendation": "Aggregated buy/sell opinion of all professional analysts currently covering the stock.", + "# Analysts": "Number of professional analysts actively covering this stock.", + "Price Target": "Average 12-month price target set by all analysts currently covering the stock.", + "Upside to Target": "Percentage gain implied by the analyst consensus price target.", + "News Sentiment": "Summary score of recent news coverage, from -1 (very negative) to +1 (very positive).", + "Short Interest": "Percentage of the stock's freely tradable shares currently sold short.", + "Beta": "How much the stock tends to move relative to the broader market.", + "30D Volatility": "Day-to-day price fluctuation over the past 30 trading days, annualised.", } +# --------------------------------------------------------------------------- +# Field definitions +# --------------------------------------------------------------------------- + +_SCORE_FIELDS = [ + ("Composite Score", "composite_score"), + ("Value", "score_value"), + ("Growth", "score_growth"), + ("Momentum", "score_momentum"), + ("Quality", "score_quality"), + ("Profitability", "score_profitability"), + ("Sentiment", "score_sentiment"), + ("Analyst", "score_analyst"), + ("Risk", "score_risk"), +] +_VAL_FIELDS = [ + ("Price", "price"), + ("Market Cap", "market_cap"), + ("P/E Forward", "pe_forward"), + ("P/E Trailing", "pe_trailing"), + ("P/B Ratio", "pb_ratio"), + ("Book Value", "book_value"), + ("EV / EBITDA", "ev_ebitda"), + ("52W High", "52w_high"), + ("52W Low", "52w_low"), +] +_GROWTH_FIELDS = [ + ("Revenue Growth", "revenue_growth"), + ("Earnings Growth", "earnings_growth"), + ("EPS Forward", "eps_forward"), + ("EPS Trailing", "eps_trailing"), +] +_MOM_FIELDS = [ + ("1-Month Return", "ret_1m"), + ("3-Month Return", "ret_3m"), + ("6-Month Return", "ret_6m"), + ("12-Month Return", "ret_12m"), +] +_QUAL_FIELDS = [ + ("Revenue", "_revenue_fmt"), + ("Net Income", "_net_income_fmt"), + ("Operating Income", "_op_income_fmt"), + ("ROE", "roe"), + ("ROA", "roa"), + ("Total Debt", "total_debt"), + ("Total Equity", "_equity_fmt"), + ("Total Cash", "total_cash"), + ("Current Ratio", "current_ratio"), + ("Profit Margin", "_profit_margin_fmt"), + ("Operating Margin", "_op_margin_fmt"), + ("FCF Yield", "fcf_yield"), + ("Dividend Yield", "dividend_yield"), +] +_ANLST_FIELDS = [ + ("Recommendation", "recommendation"), + ("# Analysts", "analyst_count"), + ("Price Target", "analyst_target"), + ("Upside to Target", "analyst_upside"), + ("News Sentiment", "news_sentiment"), + ("Short Interest", "short_percent"), + ("Beta", "beta"), + ("30D Volatility", "volatility_30d"), +] + +# --------------------------------------------------------------------------- +# Progress regex +# --------------------------------------------------------------------------- + +_PROGRESS_RE = re.compile(r"(\d+)/(\d+).*?ok=(\d+).*?skip=(\d+).*?ETA=(\d+(?:\.\d+)?)") +_PHASE1_RE = re.compile(r"(\d+)/(\d+)\s+scanned\s+valid=(\d+)") + +# --------------------------------------------------------------------------- +# Worker signals +# --------------------------------------------------------------------------- + +class _WorkerSignals(QObject): + progress = Signal(int, int, int, int, float) + status = Signal(str) + done = Signal(object, object, object, int) + error = Signal(str) + stopped = Signal() # --------------------------------------------------------------------------- # 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 __init__(self, signals: _WorkerSignals): + self._signals = signals def write(self, s: str) -> int: if not s or not s.strip(): @@ -225,3546 +330,2060 @@ class _StdoutRedirector(io.TextIOBase): 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)}) + self._signals.progress.emit(int(done), int(total), int(ok), int(skip), 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}) + self._signals.progress.emit(int(done), int(total), int(ok), 0, 0.0) else: - self._q.put({"type": "status", "run_id": self._run_id, - "text": s.strip()}) + self._signals.status.emit(s.strip()) return len(s) - def flush(self): - pass + def flush(self): pass +"""Part 2: ScoreBar, Sidebar, StockTableModel, StockTable, MetricCell, MetricsGrid""" # --------------------------------------------------------------------------- -# Formatters +# ScoreBar # --------------------------------------------------------------------------- -def _nan(v): - return v is None or (isinstance(v, float) and np.isnan(v)) +class ScoreBar(QWidget): + _value_changed = Signal(float) -def _pct(v): - return "—" if _nan(v) else f"{v * 100:.1f}%" + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(220, 18) + self._val = 0.0 + self._anim = None -def _flt(v, d=2): - return "—" if _nan(v) else f"{v:.{d}f}" + def get_value(self): return self._val + def set_value(self, v): + self._val = float(v) + self.update() + value = Property(float, get_value, set_value) -def _price(v): - return "—" if _nan(v) else f"${v:,.2f}" + def paintEvent(self, e): + from PySide6.QtGui import QPainter, QColor, QPainterPath + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + r = h / 2 + # Track + track = QPainterPath() + track.addRoundedRect(0, 0, w, h, r, r) + p.fillPath(track, QColor("#222222")) + # Fill + if self._val > 0: + fill_w = max(h, int(w * self._val / 100.0)) + fill_w = min(fill_w, w) + fill = QPainterPath() + fill.addRoundedRect(0, 0, fill_w, h, r, r) + p.fillPath(fill, QColor("#ffffff")) + p.end() -def _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}" + def animate_to(self, v): + if self._anim: + self._anim.stop() + self._anim = QPropertyAnimation(self, b"value", self) + self._anim.setDuration(500) + self._anim.setStartValue(0.0) + self._anim.setEndValue(float(max(0, min(100, v)))) + self._anim.setEasingCurve(QEasingCurve.OutCubic) + self._anim.start() + def reset(self): + if self._anim: + self._anim.stop() + self.set_value(0.0) # --------------------------------------------------------------------------- -# Tooltip +# Sidebar # --------------------------------------------------------------------------- -class _Tooltip: - """ - Attaches a dark-themed popup to a tk.Label. +class Sidebar(QWidget): + page_changed = Signal(int) - Strategy: - - → always show immediately (reliable trigger). - - → 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. - - → hide when the cursor leaves the widget entirely. + _NAV = [("⊞", "Screener"), ("⌕", "Search"), ("↗", "Tracking"), ("⚙", "Settings")] - font, padx, pady, anchor are passed explicitly at construction time so - no fragile runtime widget inspection is needed. - """ - _PAD = 8 + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedWidth(72) + self._active = 0 + self._btns = [] + self._build() - 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("", self._show, add="+") - widget.bind("", self._on_motion, add="+") - widget.bind("", self._hide, add="+") - widget.bind(" + + +
+ + +
+ + +
+
+ + +
+
⚙  Filters
+ Last run: 4 mins ago +
+ +
+ +
+ + +
+
+ Results + 87 stocks +
+
+ + + +
#TickerNameScore
+
+
+ + +
+
← Select a stock to view details
+ +
+
+ +
+ Showing 87 of 500 stocks · Click a ticker to see details + Cache: 4,200 stocks · updated 4 mins ago +
+ + + + + + +
+
+ + +
+ + +
+
+ Insider Transactions +
+
Today
1W
+
2W
1M
+
+
All
Buys
Sales
+ + 24 transactions loaded +
+
+ + + + + + + + + + + + + + + + + +
DateTickerCompanyInsiderTitleTypeSharesPriceValueAfter
Mar 28NVDANvidia CorpJensen HuangCEOSale120,000$878.40$105.4M887,432
Mar 27MSFTMicrosoft CorpSatya NadellaCEOSale14,500$424.10$6.2M1,234,000
Mar 27METAMeta PlatformsMark ZuckerbergCEOSale85,000$513.20$43.6M342,110,009
Mar 26JPMJPMorgan ChaseDaniel PintoPresidentBuy22,000$196.30$4.3M214,500
Mar 26AMZNAmazon.comAndy JassyCEOAward62,500$183.22$11.5M1,840,000
Mar 25GOOGLAlphabet IncSundar PichaiCEOSale41,200$168.40$6.9M928,100
Mar 25XOMExxon MobilDarren WoodsCEOBuy15,000$112.80$1.7M473,200
Mar 24UNHUnitedHealth GroupAndrew WittyCEOBuy5,000$493.10$2.5M88,400
Mar 24TSLATesla IncElon MuskCEOAward304,000$174.50$53.1M411,062,512
Mar 23AAPLApple IncLuca MaestriCFOSale18,300$211.90$3.9M106,200
+
+
+ 24 transactions · last 7 days · click any row to view SEC filing + Data: SEC EDGAR Form 4 +
+
+ + +
+
+ Hedge Fund Holdings (13F-HR) +
+
30D
60D
+
90D
180D
+
+
All
$1M+
+
$10M+
$100M+
+ + 156 holdings loaded +
+
+ + + + + + + + + + + + + + + + + +
FiledFundCompanySharesValueClassOpt
Mar 15Pershing Square CapitalAlphabet Inc (GOOGL)18,500,000$3.11BA
Mar 15Bridgewater AssociatesApple Inc (AAPL)12,400,000$2.65B
Mar 14Tiger Global MgmtMicrosoft Corp (MSFT)4,200,000$1.78B
Mar 14Third Point LLCNvidia Corp (NVDA)1,850,000$1.62B
Mar 12Renaissance TechnologiesMeta Platforms (META)3,100,000$1.59BA
Mar 12Baupost GroupAmazon.com (AMZN)7,800,000$1.43BCall
Mar 11D.E. Shaw GroupTesla Inc (TSLA)9,200,000$1.31B
Mar 10Citadel AdvisorsJPMorgan Chase (JPM)6,400,000$1.26B
Mar 10Appaloosa MgmtNvidia Corp (NVDA)1,100,000$964MPut
Mar 8Lone Pine CapitalUnitedHealth Group (UNH)1,920,000$947M
+
+
+ 156 holdings · last 90 days · click any row to view SEC 13F filing + Data: SEC EDGAR 13F-HR +
+
+
+ + + + + + + diff --git a/updater.py b/updater.py index ac5adf8..ed83884 100644 --- a/updater.py +++ b/updater.py @@ -19,7 +19,7 @@ import requests # --------------------------------------------------------------------------- # Current app version — kept in sync by push_update.py before each build. # --------------------------------------------------------------------------- -APP_VERSION = "1.4.0" +APP_VERSION = "1.4.1" def _exe_dir() -> str: