diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6fb306e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(python --version && pip --version)", + "Bash(python3 --version 2>/dev/null || py --version 2>/dev/null || echo \"no python\")", + "Bash(node --version 2>/dev/null && npm --version 2>/dev/null || echo \"no node\")", + "Bash(where python:*)", + "Bash(where python3:*)", + "Bash(where py:*)", + "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)" + ] + } +} diff --git a/.github/workflows/cache_refresh.yml b/.github/workflows/cache_refresh.yml index a969454..690efea 100644 --- a/.github/workflows/cache_refresh.yml +++ b/.github/workflows/cache_refresh.yml @@ -1,35 +1,20 @@ -name: Refresh Analyst Cache +# Cache refresh is now handled by VPS cron jobs on the Hetzner server. +# See /etc/cron.d/cache_builder on the VPS: +# Stable: 2am daily → /srv/stock-tool/cache_builder.py → analyst_cache, fundamentals_cache, sector_stats +# Beta: 3am daily → /srv/stock-tool-beta/cache_builder.py → beta_analyst_cache, beta_fundamentals_cache, beta_sector_stats +# +# This workflow is intentionally disabled (no triggers). + +name: Refresh Cache (disabled — handled by VPS cron) on: - schedule: - - cron: '0 7 * * 1-5' # 02:00 AM EST (07:00 UTC), Mon–Fri only - workflow_dispatch: # allow manual trigger from the GitHub Actions UI + workflow_dispatch: # manual trigger only, for emergency use jobs: refresh: runs-on: ubuntu-latest - timeout-minutes: 90 # analyst phase ~50 min + news phase ~6 min + timeout-minutes: 120 steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python 3.11 - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - pip install --upgrade pip - pip install \ - yfinance>=0.2.36 \ - pandas>=2.0.0 \ - requests>=2.28.0 \ - vaderSentiment>=3.3.2 - - - name: Run cache builder - env: - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} - run: python cache_builder.py + - name: Not used + run: echo "Cache refresh runs on VPS. See /etc/cron.d/cache_builder." diff --git a/StockScreener.spec b/StockScreener.spec index a1e7ea1..19bb04b 100644 --- a/StockScreener.spec +++ b/StockScreener.spec @@ -3,7 +3,7 @@ from PyInstaller.utils.hooks import collect_all datas = [] binaries = [] -hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment', 'pyarmor_runtime_0'] +hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment', 'pyarmor_runtime_000000'] tmp_ret = collect_all('pandas') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('numpy') diff --git a/cache_builder.py b/cache_builder.py index 9f51ddb..f4b14f6 100644 --- a/cache_builder.py +++ b/cache_builder.py @@ -20,7 +20,7 @@ import time import urllib.parse import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime, timezone +from datetime import datetime, date, timedelta, timezone import requests import yfinance as yf @@ -32,17 +32,55 @@ except ImportError: _VADER = None # --------------------------------------------------------------------------- -# Supabase credentials — injected as GitHub Actions secrets +# PostgreSQL connection — reads secrets from /srv/api/.env on the VPS, +# or from environment variables when run locally. # --------------------------------------------------------------------------- -_SUPABASE_URL = os.environ["SUPABASE_URL"] -_SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"] +import psycopg2 +import psycopg2.extras +from dotenv import load_dotenv +load_dotenv("/srv/api/.env") -_SUPABASE_HEADERS = { - "apikey": _SUPABASE_KEY, - "Authorization": f"Bearer {_SUPABASE_KEY}", - "Content-Type": "application/json", - "Prefer": "resolution=merge-duplicates", -} +_DB_HOST = "127.0.0.1" +_DB_NAME = "verimund" +_DB_USER = "verimund_user" +_DB_PASS = os.environ.get("DB_PASS", "Shlevison2k17") + +# TABLE_PREFIX is set by the cron job: "" for stable, "beta_" for beta channel. +_TABLE_PREFIX = os.environ.get("TABLE_PREFIX", "") + + +def _get_conn(): + return psycopg2.connect(host=_DB_HOST, dbname=_DB_NAME, + user=_DB_USER, password=_DB_PASS) + + +def _pg_upsert(table: str, rows: list[dict], conflict_col: str = "ticker") -> None: + """Generic PostgreSQL upsert — INSERT ... ON CONFLICT DO UPDATE.""" + if not rows: + return + import math + # Sanitize: NaN/Inf → None (PostgreSQL JSON can't handle these) + def _clean(v): + return None if isinstance(v, float) and not math.isfinite(v) else v + + cols = list(rows[0].keys()) + col_str = ", ".join(f'"{c}"' for c in cols) + vals_str = ", ".join(["%s"] * len(cols)) + upd_str = ", ".join(f'"{c}" = EXCLUDED."{c}"' for c in cols if c != conflict_col) + sql = ( + f'INSERT INTO "{table}" ({col_str}) VALUES ({vals_str}) ' + f'ON CONFLICT ("{conflict_col}") DO UPDATE SET {upd_str}' + ) + data = [[_clean(row.get(c)) for c in cols] for row in rows] + try: + conn = _get_conn() + with conn.cursor() as cur: + cur.executemany(sql, data) + conn.commit() + conn.close() + print(f" Upserted {len(rows)} rows → {table}") + except Exception as e: + print(f" [WARN] Upsert to {table} failed: {e}") # --------------------------------------------------------------------------- # Constants @@ -56,6 +94,98 @@ MAX_WORKERS = 5 NEWS_WORKERS = 20 UPSERT_EVERY = 500 +# --------------------------------------------------------------------------- +# Foreign / annual filers — companies that don't appear in EDGAR quarterly +# frames because they file Form 20-F (annual only). +# Split into two groups: +# US_GAAP_ANNUAL — file 20-F but report under US GAAP (same concept names, +# just annual data instead of quarterly) +# IFRS_ANNUAL — file 20-F under IFRS (different concept names, mapped +# to equivalent US GAAP fields below) +# NO_XBRL — no structured XBRL data; skipped gracefully +# --------------------------------------------------------------------------- + +_EDGAR_UA = {"User-Agent": "StockScreener contact@investmenttool.com"} + +US_GAAP_ANNUAL_FILERS = { + "AEM": "0000002809", # Agnico Eagle Mines + "ARM": "0001973239", # ARM Holdings + "ASML": "0000937966", # ASML Holding + "BABA": "0001577552", # Alibaba Group + "BAM": "0001937926", # Brookfield Asset Management + "BBVA": "0000842180", # Banco Bilbao Vizcaya + "BHP": "0000811809", # BHP Group + "HDB": "0001144967", # HDFC Bank + "HTHIY": "0000047710", # Hitachi + "ING": "0001039765", # ING Groep + "ITUB": "0001132597", # Itau Unibanco + "LYG": "0001160106", # Lloyds Banking Group + "PDD": "0001737806", # PDD Holdings + "SAN": "0000891478", # Banco Santander + "TM": "0001094517", # Toyota Motor +} + +IFRS_ANNUAL_FILERS = { + "AZN": "0000901832", # AstraZeneca + "BCS": "0000312069", # Barclays + "BP": "0000313807", # BP + "GSK": "0001131399", # GSK + "HSBC": "0001089113", # HSBC Holdings + "NGG": "0001004315", # National Grid + "NU": "0001691493", # Nu Holdings + "NVS": "0001114448", # Novartis + "RIO": "0000863064", # Rio Tinto plc + "SHEL": "0001306965", # Shell + "SPOT": "0001639920", # Spotify + "UBS": "0001610520", # UBS Group + "UL": "0000217410", # Unilever +} + +# IFRS concept → internal field name mapping +IFRS_CONCEPT_MAP = { + "Revenue": "revenue", + "ProfitLoss": "net_income", + "ProfitLossAttributableToOwnersOfParent": "net_income", + "OperatingIncomeLoss": "operating_income", + "Assets": "assets", + "Equity": "equity", + "EquityAttributableToOwnersOfParent": "equity", + "CurrentAssets": "cur_assets", + "CurrentLiabilities": "cur_liabilities", + "CashAndCashEquivalents": "cash", + "NoncurrentPortionOfLongtermBorrowings": "lt_debt", + "Borrowings": "lt_debt", + "AdjustmentsForDepreciationAndAmortisationExpense": "dna", + "CashFlowsFromUsedInOperatingActivities": "op_cf", + "PurchaseOfPropertyPlantAndEquipment": "capex", +} + +# US GAAP annual concept → internal field name mapping (same as quarterly) +USGAAP_CONCEPT_MAP = { + "Revenues": "revenue", + "RevenueFromContractWithCustomerExcludingAssessedTax": "revenue", + "SalesRevenueNet": "revenue", + "NetIncomeLoss": "net_income", + "OperatingIncomeLoss": "operating_income", + "Assets": "assets", + "StockholdersEquity": "equity", + "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest": "equity", + "AssetsCurrent": "cur_assets", + "LiabilitiesCurrent": "cur_liabilities", + "CashAndCashEquivalentsAtCarryingValue": "cash", + "CashCashEquivalentsAndShortTermInvestments": "cash", + "LongTermDebt": "lt_debt", + "LongTermDebtNoncurrent": "lt_debt", + "LongTermDebtAndFinanceLeaseLiabilities": "lt_debt", + "FinanceLeaseLiabilityNoncurrent": "lt_debt", + "CommonStockholdersEquity": "equity", + "DepreciationDepletionAndAmortization": "dna", + "DepreciationAndAmortization": "dna", + "NetCashProvidedByUsedInOperatingActivities": "op_cf", + "PaymentsToAcquirePropertyPlantAndEquipment": "capex", + "CommonStockSharesOutstanding": "shares", +} + # --------------------------------------------------------------------------- # Ticker fetching (NASDAQ Trader files — same source as the screener) @@ -160,6 +290,7 @@ def _fetch_analyst(ticker: str) -> dict | None: "recommendation": info.get("recommendationKey") or "N/A", "sector": info.get("sector") or None, "industry": info.get("industry") or None, + "dividend_yield": info.get("dividendYield"), "updated_at": datetime.now(timezone.utc).isoformat(), } except Exception: @@ -277,24 +408,586 @@ def run_news_phase(tickers: list[str]) -> None: # --------------------------------------------------------------------------- -# Supabase upsert +# Upsert helpers — write to local PostgreSQL # --------------------------------------------------------------------------- +# Explicit column allow-lists keep extra fields (e.g. dividend_yield from +# yfinance) from causing "column does not exist" errors in PostgreSQL. +_ANALYST_COLS = ( + "ticker", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside", + "analyst_count", "analyst_target", "recommendation", "news_sentiment", + "sector", "industry", "updated_at", +) +_FUND_COLS = ( + "ticker", "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", + "market_cap", "shares_outstanding", "ev_revenue", "short_percent", + "filer_type", "updated_at", +) + + def _upsert(rows: list[dict]) -> None: - """Upsert a batch of rows into analyst_cache (merge on primary key).""" - if not rows: - return + """Upsert a batch of rows into analyst_cache.""" + filtered = [{c: r.get(c) for c in _ANALYST_COLS} for r in rows] + _pg_upsert(f"{_TABLE_PREFIX}analyst_cache", filtered) + + +def _upsert_fundamentals(rows: list[dict]) -> None: + """Upsert a batch of rows into fundamentals_cache.""" + filtered = [{c: r.get(c) for c in _FUND_COLS} for r in rows] + _pg_upsert(f"{_TABLE_PREFIX}fundamentals_cache", filtered) + + +# --------------------------------------------------------------------------- +# Phase 3 helpers — EDGAR bulk frames (domestic quarterly filers) +# --------------------------------------------------------------------------- + +_FRAME_CACHE: dict = {} + + +def _get_recent_quarters(n: int = 9) -> list[tuple[int, int]]: + ref = date.today() - timedelta(days=45) + y, q = ref.year, (ref.month - 1) // 3 + 1 + out = [] + for _ in range(n): + out.append((y, q)) + q -= 1 + if q == 0: + q, y = 4, y - 1 + return out + + +def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]: + key = (concept, unit, period) + if key in _FRAME_CACHE: + return _FRAME_CACHE[key] + url = f"https://data.sec.gov/api/xbrl/frames/us-gaap/{concept}/{unit}/{period}.json" try: - resp = requests.post( - f"{_SUPABASE_URL}/rest/v1/analyst_cache", - headers=_SUPABASE_HEADERS, - json=rows, - timeout=30, + resp = requests.get(url, headers=_EDGAR_UA, timeout=20) + if resp.status_code == 404: + _FRAME_CACHE[key] = {} + return {} + resp.raise_for_status() + result: dict[int, float] = {} + for row in resp.json().get("data", []): + try: + if isinstance(row, dict): + cik, val = int(row["cik"]), float(row["val"]) + else: + if len(row) < 6: + continue + cik, val = int(row[1]), float(row[5]) + result[cik] = val + except (KeyError, TypeError, ValueError): + continue + _FRAME_CACHE[key] = result + time.sleep(0.15) + return result + except Exception: + _FRAME_CACHE[key] = {} + return {} + + +def _sum_frames(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]: + ttm: dict[int, dict] = {} + for concept in concepts: + for period in periods: + for cik, val in _fetch_one_frame(concept, unit, period).items(): + ttm.setdefault(cik, {}) + if period not in ttm[cik]: + ttm[cik][period] = val + return {cik: sum(pv.values()) for cik, pv in ttm.items()} + + +def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]: + best: dict[int, float] = {} + for concept in concepts: + for period in periods: + for cik, val in _fetch_one_frame(concept, unit, period).items(): + if cik not in best: + best[cik] = val + return best + + +def _build_cik_maps() -> tuple[dict[str, int], dict[int, str]]: + try: + resp = requests.get( + "https://www.sec.gov/files/company_tickers.json", + headers=_EDGAR_UA, timeout=20, ) resp.raise_for_status() - print(f" Upserted {len(rows)} rows to Supabase") + data = resp.json() + ticker_to_cik = {} + cik_to_ticker = {} + for entry in data.values(): + t = entry.get("ticker", "").upper().replace(".", "-") + cik = int(entry["cik_str"]) + ticker_to_cik[t] = cik + cik_to_ticker[cik] = t + return ticker_to_cik, cik_to_ticker except Exception as e: - print(f" [WARN] Upsert failed: {e}") + print(f" [WARN] Could not build CIK maps: {e}") + return {}, {} + + +def _compute_fundamentals( + price: float, + rev: float | None, rev_prev: float | None, + ni: float | None, ni_prev: float | None, + op_i: float | None, d_a: float | None, + ocf: float | None, cx: float | None, + tot_a: float | None, eq: float | None, + ca: float | None, cl: float | None, + csh: float | None, ltd: float | None, + sh: float | None, +) -> dict: + """Derive all fundamental metrics from raw statement values.""" + mkt_cap = (price * sh) if sh and sh > 0 else None + eps_trail = (ni / sh) if ni is not None and sh and sh > 0 else None + pe_trail = (price / eps_trail) if eps_trail and eps_trail > 0 else None + pb = (price / (eq / sh)) if eq and sh and sh > 0 and eq > 0 else None + ebitda = ((op_i + d_a) if op_i is not None and d_a is not None else op_i) + ev = ((mkt_cap + (ltd or 0) - (csh or 0)) if mkt_cap is not None else None) + ev_ebitda = (ev / ebitda) if ev and ebitda and ebitda > 0 else None + ev_revenue= (ev / rev) if ev and rev and rev > 0 else None + roe = (ni / eq) if ni is not None and eq and eq > 0 else None + roa = (ni / tot_a) if ni is not None and tot_a and tot_a > 0 else None + de = (ltd / eq) if ltd is not None and eq and eq > 0 else None + curr_r = (ca / cl) if ca and cl and cl > 0 else None + pm = (ni / rev) if ni is not None and rev and rev > 0 else None + om = (op_i / rev) if op_i is not None and rev and rev > 0 else None + fcf = ((ocf - cx) if ocf is not None and cx is not None else ocf) + fcf_yield = (fcf / mkt_cap) if fcf is not None and mkt_cap and mkt_cap > 0 else None + rev_g = ((rev - rev_prev) / abs(rev_prev) + if rev is not None and rev_prev and rev_prev != 0 else None) + ni_g = ((ni - ni_prev) / abs(ni_prev) + if ni is not None and ni_prev and ni_prev != 0 else None) + return { + "market_cap": mkt_cap, + "shares_outstanding": sh, + "pe_trailing": pe_trail, + "pb_ratio": pb, + "ev_ebitda": ev_ebitda, + "ev_revenue": ev_revenue, + "eps_trailing": eps_trail, + "revenue": rev, + "revenue_growth": rev_g, + "earnings_growth": ni_g, + "roe": roe, + "roa": roa, + "debt_to_equity": de, + "total_debt": ltd, + "total_cash": csh, + "book_value": (eq / sh) if eq and sh and sh > 0 else None, + "current_ratio": curr_r, + "profit_margin": pm, + "operating_margin": om, + "fcf_yield": fcf_yield, + } + + +# --------------------------------------------------------------------------- +# Phase 3 — EDGAR bulk fundamentals (domestic quarterly filers) +# --------------------------------------------------------------------------- + +def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]: + """ + Fetch EDGAR XBRL frames for all domestic quarterly filers and return + {ticker: fundamentals_dict}. Foreign/annual filers handled separately. + """ + print("Phase 3: Fetching EDGAR bulk fundamentals ...") + ticker_to_cik, cik_to_ticker = _build_cik_maps() + if not ticker_to_cik: + print(" [WARN] CIK map unavailable — EDGAR bulk skipped") + return {} + + quarters = _get_recent_quarters(9) + ttm_periods = [f"CY{y}Q{q}" for y, q in quarters[:4]] + prev_periods = [f"CY{y}Q{q}" for y, q in quarters[4:8]] + bs_periods = [f"CY{y}Q{q}I" for y, q in quarters[:3]] + + print(f" TTM periods : {ttm_periods}") + + assets = _best_frame(["Assets"], "USD", bs_periods) + equity = _best_frame(["StockholdersEquity", + "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest", + "CommonStockholdersEquity"], "USD", bs_periods) + cur_assets = _best_frame(["AssetsCurrent"], "USD", bs_periods) + cur_liab = _best_frame(["LiabilitiesCurrent"], "USD", bs_periods) + cash = _best_frame(["CashAndCashEquivalentsAtCarryingValue", + "CashCashEquivalentsAndShortTermInvestments"], "USD", bs_periods) + lt_debt = _best_frame(["LongTermDebt", "LongTermDebtNoncurrent", + "LongTermDebtAndCapitalLeaseObligations", + "LongTermDebtAndFinanceLeaseLiabilities", + "FinanceLeaseLiabilityNoncurrent"], "USD", bs_periods) + shares = _best_frame(["CommonStockSharesOutstanding"], "shares", bs_periods) + revenue = _sum_frames(["Revenues", + "RevenueFromContractWithCustomerExcludingAssessedTax", + "SalesRevenueNet"], "USD", ttm_periods) + net_income = _sum_frames(["NetIncomeLoss"], "USD", ttm_periods) + op_income = _sum_frames(["OperatingIncomeLoss"], "USD", ttm_periods) + dna = _sum_frames(["DepreciationDepletionAndAmortization", + "DepreciationAndAmortization"], "USD", ttm_periods) + op_cf = _sum_frames(["NetCashProvidedByUsedInOperatingActivities"], "USD", ttm_periods) + capex = _sum_frames(["PaymentsToAcquirePropertyPlantAndEquipment"], "USD", ttm_periods) + rev_prev = _sum_frames(["Revenues", + "RevenueFromContractWithCustomerExcludingAssessedTax", + "SalesRevenueNet"], "USD", prev_periods) + ni_prev = _sum_frames(["NetIncomeLoss"], "USD", prev_periods) + + print(f" Revenue coverage: {len(revenue)} companies") + + # Known foreign filer tickers — skip them here, handled in Phase 3B + foreign_tickers = set(US_GAAP_ANNUAL_FILERS) | set(IFRS_ANNUAL_FILERS) + + result: dict[str, dict] = {} + for ticker in all_tickers: + if ticker in foreign_tickers: + continue + cik = ticker_to_cik.get(ticker) + if cik is None: + continue + + # yfinance price needed for ratios — use a fast single-ticker download + try: + raw = yf.download(ticker, period="5d", auto_adjust=True, progress=False) + price = float(raw["Close"].dropna().iloc[-1]) if not raw.empty else None + except Exception: + price = None + if price is None: + continue + + metrics = _compute_fundamentals( + price, + revenue.get(cik), rev_prev.get(cik), + net_income.get(cik), ni_prev.get(cik), + op_income.get(cik), dna.get(cik), + op_cf.get(cik), capex.get(cik), + assets.get(cik), equity.get(cik), + cur_assets.get(cik), cur_liab.get(cik), + cash.get(cik), lt_debt.get(cik), + shares.get(cik), + ) + metrics["ticker"] = ticker + metrics["filer_type"] = "us-gaap-quarterly" + metrics["updated_at"] = datetime.now(timezone.utc).isoformat() + result[ticker] = metrics + + print(f" Domestic filers matched: {len(result)}\n") + return result + + +# --------------------------------------------------------------------------- +# Phase 3B — Foreign / annual filer fundamentals (companyfacts per CIK) +# --------------------------------------------------------------------------- + +def _fetch_companyfacts(cik_padded: str) -> dict: + """Fetch all XBRL facts for one company from SEC companyfacts API.""" + url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik_padded}.json" + try: + resp = requests.get(url, headers=_EDGAR_UA, timeout=30) + if resp.status_code == 404: + return {} + resp.raise_for_status() + return resp.json().get("facts", {}) + except Exception: + return {} + + +def _get_latest_annual_value(facts: dict, taxonomy: str, concept: str) -> float | None: + """Extract the most recent annual (12-month) value for a concept.""" + try: + units = facts.get(taxonomy, {}).get(concept, {}).get("units", {}) + # Try USD first, then shares, then any available unit + for unit_key in ("USD", "shares", *units.keys()): + entries = units.get(unit_key, []) + # Filter to annual (form 10-K or 20-F) or 12-month duration entries + annual = [ + e for e in entries + if e.get("form") in ("10-K", "20-F") + or (e.get("start") and e.get("end") and + _months_between(e["start"], e["end"]) >= 11) + ] + if not annual: + continue + # Pick most recent by end date + annual.sort(key=lambda e: e.get("end", ""), reverse=True) + return float(annual[0]["val"]) + except Exception: + pass + return None + + +def _get_prior_annual_value(facts: dict, taxonomy: str, concept: str) -> float | None: + """Extract the second-most-recent annual value for YoY growth calculation.""" + try: + units = facts.get(taxonomy, {}).get(concept, {}).get("units", {}) + for unit_key in ("USD", "shares", *units.keys()): + entries = units.get(unit_key, []) + annual = [ + e for e in entries + if e.get("form") in ("10-K", "20-F") + or (e.get("start") and e.get("end") and + _months_between(e["start"], e["end"]) >= 11) + ] + if len(annual) < 2: + continue + annual.sort(key=lambda e: e.get("end", ""), reverse=True) + return float(annual[1]["val"]) + except Exception: + pass + return None + + +def _months_between(start: str, end: str) -> int: + try: + s = datetime.fromisoformat(start) + e = datetime.fromisoformat(end) + return (e.year - s.year) * 12 + (e.month - s.month) + except Exception: + return 0 + + +def _extract_facts(facts: dict, taxonomy: str, concept_map: dict) -> dict: + """Extract latest and prior annual values for all mapped concepts.""" + out = {} + for concept, field in concept_map.items(): + val = _get_latest_annual_value(facts, taxonomy, concept) + if val is not None and field not in out: + out[field] = val + prior = _get_prior_annual_value(facts, taxonomy, concept) + if prior is not None and f"{field}_prev" not in out: + out[f"{field}_prev"] = prior + return out + + +def run_foreign_filers_phase() -> dict[str, dict]: + """ + Fetch fundamentals for all known foreign/annual filers via the SEC + companyfacts API. Returns {ticker: fundamentals_dict}. + """ + print("Phase 3B: Fetching foreign/annual filer fundamentals ...") + result: dict[str, dict] = {} + all_foreign = { + **{t: (cik, "us-gaap", USGAAP_CONCEPT_MAP) for t, cik in US_GAAP_ANNUAL_FILERS.items()}, + **{t: (cik, "ifrs-full", IFRS_CONCEPT_MAP) for t, cik in IFRS_ANNUAL_FILERS.items()}, + } + + for ticker, (cik_padded, taxonomy, concept_map) in all_foreign.items(): + try: + facts = _fetch_companyfacts(cik_padded) + if not facts: + print(f" [WARN] No XBRL facts for {ticker} ({cik_padded})") + continue + + raw = _extract_facts(facts, taxonomy, concept_map) + + # Get current price + try: + dl = yf.download(ticker, period="5d", auto_adjust=True, progress=False) + price = float(dl["Close"].dropna().iloc[-1]) if not dl.empty else None + except Exception: + price = None + if price is None: + print(f" [WARN] No price for {ticker} — skipping") + continue + + metrics = _compute_fundamentals( + price, + raw.get("revenue"), raw.get("revenue_prev"), + raw.get("net_income"), raw.get("net_income_prev"), + raw.get("operating_income"), raw.get("dna"), + raw.get("op_cf"), raw.get("capex"), + raw.get("assets"), raw.get("equity"), + raw.get("cur_assets"), raw.get("cur_liabilities"), + raw.get("cash"), raw.get("lt_debt"), + raw.get("shares"), + ) + metrics["ticker"] = ticker + metrics["filer_type"] = f"{taxonomy}-annual" + metrics["updated_at"] = datetime.now(timezone.utc).isoformat() + result[ticker] = metrics + print(f" {ticker} ({taxonomy}) ✓") + time.sleep(0.2) # respect SEC rate limit + except Exception as e: + print(f" [WARN] {ticker}: {e}") + + print(f" Foreign filers done: {len(result)}\n") + return result + + +# --------------------------------------------------------------------------- +# Phase 4 — FINRA short interest +# --------------------------------------------------------------------------- + +def run_finra_phase(existing: dict[str, dict]) -> dict[str, dict]: + """ + Fetch today's (or most recent) FINRA RegSHO short-volume file and merge + short_percent into the fundamentals dict keyed by ticker. + Returns the updated dict. + """ + print("Phase 4: Fetching FINRA short interest ...") + short_map: dict[str, float] = {} + + for days_back in range(1, 6): + d = date.today() - timedelta(days=days_back) + if d.weekday() >= 5: + continue + url = ( + "https://cdn.finra.org/equity/regsho/daily/" + f"CNMSshvol{d.strftime('%Y%m%d')}.txt" + ) + try: + resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) + if resp.status_code == 404: + continue + resp.raise_for_status() + for line in resp.text.splitlines()[1:]: + parts = line.split("|") + if len(parts) < 5: + continue + ticker = parts[0].strip() + try: + short_vol = float(parts[1]) + total_vol = float(parts[3]) + if total_vol > 0: + short_map[ticker] = short_vol / total_vol + except (ValueError, IndexError): + continue + print(f" FINRA short interest: {len(short_map)} tickers ({d})") + break + except Exception as e: + print(f" [WARN] FINRA {d}: {e}") + continue + + if not short_map: + print(" [WARN] FINRA short interest unavailable") + + # Merge into existing fundamentals dict + now = datetime.now(timezone.utc).isoformat() + for ticker, ratio in short_map.items(): + if ticker in existing: + existing[ticker]["short_percent"] = ratio + else: + existing[ticker] = { + "ticker": ticker, + "short_percent": ratio, + "filer_type": "finra-only", + "updated_at": now, + } + + return existing + + +# --------------------------------------------------------------------------- +# Phase 5 — Sector statistics (median + MAD per metric per sector) +# --------------------------------------------------------------------------- + +def _upsert_sector_stats(rows: list[dict]) -> None: + """Upsert sector statistics rows into the sector_stats table.""" + _pg_upsert(f"{_TABLE_PREFIX}sector_stats", rows, conflict_col="sector") + + +def _load_sector_mapping() -> dict[str, str]: + """ + Load ticker → sector mapping from the analyst_cache table that was just + populated in Phase 1. Returns {ticker: {sector, analyst_upside, eps_forward}}. + """ + try: + conn = _get_conn() + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + f"SELECT ticker, sector, analyst_upside, eps_forward " + f"FROM \"{_TABLE_PREFIX}analyst_cache\" WHERE sector IS NOT NULL" + ) + rows = cur.fetchall() + conn.close() + return {r["ticker"]: dict(r) for r in rows} + except Exception as e: + print(f" [WARN] Could not load sector mapping: {e}") + return {} + + +def run_sector_stats_phase(fundamentals: dict[str, dict]) -> None: + """ + Compute sector-level median and MAD for every fundamental metric and upsert + to the sector_stats table. Requires >= 15 tickers per sector. + """ + import statistics as _stats + + print("Phase 5: Computing sector statistics ...") + + analyst_map = _load_sector_mapping() + if not analyst_map: + print(" [WARN] No analyst/sector data — sector stats skipped\n") + return + + METRICS = [ + "pe_trailing", "pb_ratio", "ev_ebitda", "ev_revenue", + "revenue_growth", "earnings_growth", "eps_growth", + "roe", "roa", "debt_to_equity", "current_ratio", + "profit_margin", "operating_margin", "fcf_yield", + "analyst_upside", + ] + MIN_TICKERS = 15 + + # Build merged per-ticker dict: fundamentals + analyst fields + merged: dict[str, dict] = {} + for ticker, fdata in fundamentals.items(): + adata = analyst_map.get(ticker, {}) + sector = adata.get("sector") + if not sector or sector == "N/A": + continue + eps_t = fdata.get("eps_trailing") + eps_f = adata.get("eps_forward") + eps_growth = None + if eps_t and eps_f and abs(eps_t) > 1e-9: + raw_g = (eps_f - eps_t) / abs(eps_t) + # Clamp to ±150% to avoid breakeven-crossing distortion + eps_growth = max(-1.5, min(1.5, raw_g)) + merged[ticker] = { + **{m: fdata.get(m) for m in METRICS}, + "eps_growth": eps_growth, + "analyst_upside": adata.get("analyst_upside"), + "_sector": sector, + } + + # Group by sector + sector_buckets: dict[str, list[dict]] = {} + for data in merged.values(): + s = data["_sector"] + sector_buckets.setdefault(s, []).append(data) + + now = datetime.now(timezone.utc).isoformat() + rows = [] + for sector, members in sector_buckets.items(): + if len(members) < MIN_TICKERS: + continue + row: dict = {"sector": sector, "ticker_count": len(members), "updated_at": now} + for metric in METRICS: + vals = [m[metric] for m in members + if m.get(metric) is not None + and isinstance(m[metric], (int, float)) + and m[metric] == m[metric]] # exclude NaN + if len(vals) >= MIN_TICKERS: + med = _stats.median(vals) + mad = _stats.median([abs(v - med) for v in vals]) + row[f"{metric}_med"] = med + row[f"{metric}_mad"] = max(float(mad), 1e-10) + else: + row[f"{metric}_med"] = None + row[f"{metric}_mad"] = None + rows.append(row) + + if rows: + _upsert_sector_stats(rows) + print(f" Sector stats written for {len(rows)} sectors") + else: + print(" [WARN] No sectors met the minimum ticker threshold") + + print(f" Phase 5 complete\n") # --------------------------------------------------------------------------- @@ -303,7 +996,7 @@ def _upsert(rows: list[dict]) -> None: def main() -> None: print("=" * 55) - print(" ANALYST + NEWS CACHE BUILDER") + print(" ANALYST + NEWS + FUNDAMENTALS CACHE BUILDER") print("=" * 55 + "\n") tickers = fetch_tickers() @@ -311,9 +1004,37 @@ def main() -> None: print("ERROR: No tickers fetched — aborting.") raise SystemExit(1) + # Phase 1 — analyst consensus + forward estimates (yfinance) run_analyst_phase(tickers) + + # Phase 2 — news sentiment (Google News RSS + VADER) run_news_phase(tickers) - print("Cache build complete.") + + # Phase 3 — EDGAR bulk fundamentals (domestic quarterly filers) + fundamentals = run_fundamentals_phase(tickers) + + # Phase 3B — foreign / annual filer fundamentals (SEC companyfacts) + foreign = run_foreign_filers_phase() + fundamentals.update(foreign) + + # Phase 4 — FINRA short interest (merged into fundamentals) + fundamentals = run_finra_phase(fundamentals) + + # Write all fundamentals to Supabase + print(f"Writing {len(fundamentals)} rows to fundamentals_cache ...") + batch: list[dict] = [] + for row in fundamentals.values(): + batch.append(row) + if len(batch) >= UPSERT_EVERY: + _upsert_fundamentals(batch) + batch.clear() + if batch: + _upsert_fundamentals(batch) + + # Phase 5 — Sector statistics (median + MAD per metric per sector) + run_sector_stats_phase(fundamentals) + + print("\nCache build complete.") if __name__ == "__main__": diff --git a/deploy_beta.bat b/deploy_beta.bat new file mode 100644 index 0000000..a8c989b --- /dev/null +++ b/deploy_beta.bat @@ -0,0 +1,48 @@ +@echo off +setlocal + +echo ============================================================ +echo Deploy to BETA (beta branch + /srv/stock-tool-beta on VPS) +echo ============================================================ +echo. + +set /p COMMIT_MSG=Commit message: +if "%COMMIT_MSG%"=="" ( + echo Error: Commit message cannot be empty. + pause + exit /b 1 +) + +echo. +echo [1/4] Staging all changes... +git add -A + +echo [2/4] Committing... +git commit -m "%COMMIT_MSG%" +if errorlevel 1 ( + echo Nothing to commit. + pause + exit /b 0 +) + +echo [3/4] Pushing to Gitea beta branch... +git push gitea HEAD:beta +if errorlevel 1 ( + echo Push failed. Check Gitea connection. + pause + exit /b 1 +) + +echo [4/4] Deploying to VPS... +ssh root@87.99.133.95 "cd /srv/stock-tool-beta && git pull origin beta && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS beta deploy OK" +if errorlevel 1 ( + echo VPS deploy failed. SSH in and check manually. + pause + exit /b 1 +) + +echo. +echo ============================================================ +echo Beta deploy complete. +echo ============================================================ +pause diff --git a/deploy_stable.bat b/deploy_stable.bat new file mode 100644 index 0000000..d4510a6 --- /dev/null +++ b/deploy_stable.bat @@ -0,0 +1,48 @@ +@echo off +setlocal + +echo ============================================================ +echo Deploy to STABLE (main branch + /srv/stock-tool on VPS) +echo ============================================================ +echo. + +set /p COMMIT_MSG=Commit message: +if "%COMMIT_MSG%"=="" ( + echo Error: Commit message cannot be empty. + pause + exit /b 1 +) + +echo. +echo [1/4] Staging all changes... +git add -A + +echo [2/4] Committing... +git commit -m "%COMMIT_MSG%" +if errorlevel 1 ( + echo Nothing to commit. + pause + exit /b 0 +) + +echo [3/4] Pushing to Gitea main branch... +git push gitea main +if errorlevel 1 ( + echo Push failed. Check Gitea connection. + pause + exit /b 1 +) + +echo [4/4] Deploying to VPS... +ssh root@87.99.133.95 "cd /srv/stock-tool && git pull && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS deploy OK" +if errorlevel 1 ( + echo VPS deploy failed. SSH in and check manually. + pause + exit /b 1 +) + +echo. +echo ============================================================ +echo Stable deploy complete. +echo ============================================================ +pause diff --git a/discord_bot/.env.example b/discord_bot/.env.example new file mode 100644 index 0000000..a4d49a3 --- /dev/null +++ b/discord_bot/.env.example @@ -0,0 +1,39 @@ +# ─── Discord ──────────────────────────────────────────────────────────────── +# Bot token from https://discord.com/developers/applications +DISCORD_TOKEN= + +# Your server's ID (right-click server icon → Copy Server ID) +GUILD_ID= + +# ─── Role IDs ─────────────────────────────────────────────────────────────── +# Right-click each role in Server Settings → Roles → Copy Role ID +# User role is assigned to all subscribers; Lifetime role is also assigned for lifetime purchases +ROLE_USER_ID= +ROLE_LIFETIME_ID= + +# ─── Ticket channel IDs ───────────────────────────────────────────────────── +# Category where ticket channels are created (right-click category → Copy ID) +TICKET_CATEGORY_ID= + +# Channel where ticket open/close events are logged (right-click channel → Copy ID) +TICKET_LOG_CHANNEL_ID= + +# ─── Stripe ───────────────────────────────────────────────────────────────── +# From https://dashboard.stripe.com/apikeys +STRIPE_SECRET_KEY=sk_live_... + +# From https://dashboard.stripe.com/webhooks (signing secret for this endpoint) +STRIPE_WEBHOOK_SECRET=whsec_... + +# Where Stripe redirects after payment (can be a thank-you page or Discord DM link) +STRIPE_SUCCESS_URL=https://discord.com/channels/@me +STRIPE_CANCEL_URL=https://discord.com/channels/@me + +# ─── Supabase ─────────────────────────────────────────────────────────────── +SUPABASE_URL=https://yeispcpmepjelfbhfkwr.supabase.co +SUPABASE_SERVICE_ROLE_KEY= + +# ─── Webhook server ───────────────────────────────────────────────────────── +# Port the bot listens on for incoming Stripe webhook POSTs +# Forward this via ngrok (dev) or expose via your server's firewall (prod) +WEBHOOK_PORT=8080 diff --git a/discord_bot/bot.py b/discord_bot/bot.py new file mode 100644 index 0000000..f5c09dd --- /dev/null +++ b/discord_bot/bot.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +""" +Discord Sales & Support Bot — Ultimate Investment Tool +======================================================= +Features: + /setup → (Admin only) Posts the persistent sales embed with Purchase & + Support buttons into the current channel. Run this once in your + read-only sales channel. + Purchase button → Stripe Checkout (weekly $10 / monthly $25 / lifetime $150) + License key auto-generated + DM'd on successful payment + Role assigned automatically in the server + Support button → Modal prompts for a subject, then opens a private ticket + channel with a Close button and audit log + +Setup: copy .env.example → .env and fill in all values before running. +""" + +import asyncio +import datetime +import os +import uuid + +import aiohttp +from aiohttp import web +import discord +from discord import app_commands +from discord.ext import commands +import requests +import stripe +from dotenv import load_dotenv + +load_dotenv() + +# ───────────────────────────────────────────────────────────────────────────── +# Configuration (all values from .env) +# ───────────────────────────────────────────────────────────────────────────── +DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +GUILD_ID = int(os.getenv("GUILD_ID", "0")) +STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") +STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") +SUPABASE_URL = os.getenv("SUPABASE_URL") +SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY") + +# Discord IDs — right-click channel/role → Copy ID (needs Developer Mode enabled) +TICKET_CATEGORY_ID = int(os.getenv("TICKET_CATEGORY_ID", "0")) +TICKET_LOG_CHANNEL_ID = int(os.getenv("TICKET_LOG_CHANNEL_ID", "0")) +ROLE_USER_ID = int(os.getenv("ROLE_USER_ID", "0")) +ROLE_LIFETIME_ID = int(os.getenv("ROLE_LIFETIME_ID", "0")) + +WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "8080")) + +# Pricing tiers +PRICES = { + "weekly": {"amount": 1000, "label": "$10 / week", "days": 7}, + "monthly": {"amount": 2500, "label": "$25 / month", "days": 30}, + "lifetime": {"amount": 15000, "label": "$150 lifetime", "days": None}, +} + +stripe.api_key = STRIPE_SECRET_KEY + +_SUPABASE_HEADERS = { + "apikey": SUPABASE_SERVICE_KEY, + "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", + "Content-Type": "application/json", + "Prefer": "return=representation", +} + +# ───────────────────────────────────────────────────────────────────────────── +# License helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _generate_key() -> str: + parts = [uuid.uuid4().hex[:8].upper() for _ in range(3)] + return "UIT-" + "-".join(parts) + + +def issue_license(tier: str, notes: str = "") -> str: + """Create a new license in Supabase and return the key.""" + days = PRICES[tier]["days"] + expiry = None + if days is not None: + expiry = (datetime.datetime.utcnow() + datetime.timedelta(days=days)).isoformat() + "Z" + + key = _generate_key() + payload = { + "license_key": key, + "tier": tier, + "expiry_date": expiry, + "active": True, + "machines_allowed": 1, + "notes": notes, + } + resp = requests.post( + f"{SUPABASE_URL}/rest/v1/licenses", + headers=_SUPABASE_HEADERS, + json=payload, + timeout=10, + ) + resp.raise_for_status() + return key + + +# ───────────────────────────────────────────────────────────────────────────── +# Stripe helpers +# ───────────────────────────────────────────────────────────────────────────── + +def create_checkout_session(tier: str, discord_user_id: str, discord_username: str) -> str: + """Create a Stripe Checkout Session and return its URL.""" + price_info = PRICES[tier] + session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[{ + "price_data": { + "currency": "usd", + "unit_amount": price_info["amount"], + "product_data": { + "name": f"Ultimate Investment Tool — {price_info['label']}", + }, + }, + "quantity": 1, + }], + mode="payment", + success_url=os.getenv("STRIPE_SUCCESS_URL", "https://discord.com/channels/@me"), + cancel_url=os.getenv("STRIPE_CANCEL_URL", "https://discord.com/channels/@me"), + metadata={ + "discord_user_id": discord_user_id, + "discord_username": discord_username, + "tier": tier, + }, + ) + return session.url + + +# ───────────────────────────────────────────────────────────────────────────── +# Bot setup +# ───────────────────────────────────────────────────────────────────────────── + +intents = discord.Intents.default() +intents.members = True +bot = commands.Bot(command_prefix="!", intents=intents) +tree = bot.tree + + +# ───────────────────────────────────────────────────────────────────────────── +# Ticket creation helper (shared by button and any future commands) +# ───────────────────────────────────────────────────────────────────────────── + +async def create_ticket(interaction: discord.Interaction, subject: str): + guild = interaction.guild + category = guild.get_channel(TICKET_CATEGORY_ID) + + overwrites = { + guild.default_role: discord.PermissionOverwrite(view_channel=False), + interaction.user: discord.PermissionOverwrite( + view_channel=True, + send_messages=True, + read_message_history=True, + ), + } + + for role in guild.roles: + if role.permissions.administrator or role.permissions.manage_channels: + overwrites[role] = discord.PermissionOverwrite( + view_channel=True, + send_messages=True, + read_message_history=True, + manage_channels=True, + ) + + safe_name = "".join(c for c in interaction.user.name if c.isalnum() or c in "-_")[:20] + channel_name = f"ticket-{safe_name}" + + if category: + existing = discord.utils.get(category.text_channels, name=channel_name) + if existing: + await interaction.followup.send( + f"You already have an open ticket: {existing.mention}\n" + "Please use that channel or close it before opening a new one.", + ephemeral=True, + ) + return + + channel = await guild.create_text_channel( + name=channel_name, + category=category, + overwrites=overwrites, + topic=f"Ticket by {interaction.user} ({interaction.user.id}) | {subject}", + ) + + embed = discord.Embed( + title=f"Support Ticket — {subject}", + description=( + f"Welcome {interaction.user.mention}!\n\n" + "Please describe your issue in as much detail as possible.\n" + "A staff member will be with you shortly.\n\n" + "Press **🔒 Close Ticket** when your issue is resolved." + ), + color=discord.Color.blurple(), + timestamp=discord.utils.utcnow(), + ) + embed.set_footer(text=str(interaction.user), icon_url=interaction.user.display_avatar.url) + await channel.send(embed=embed, view=TicketCloseView()) + + log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID) + if log_channel: + log_embed = discord.Embed( + title="Ticket Opened", + description=( + f"**User:** {interaction.user.mention} (`{interaction.user.id}`)\n" + f"**Channel:** {channel.mention}\n" + f"**Subject:** {subject}" + ), + color=discord.Color.green(), + timestamp=discord.utils.utcnow(), + ) + await log_channel.send(embed=log_embed) + + await interaction.followup.send( + f"Your ticket has been created: {channel.mention}", ephemeral=True + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Persistent Views (survive bot restarts) +# ───────────────────────────────────────────────────────────────────────────── + +class TicketCloseView(discord.ui.View): + """Close button that lives permanently in ticket channels.""" + + def __init__(self): + super().__init__(timeout=None) + + @discord.ui.button( + label="🔒 Close Ticket", + style=discord.ButtonStyle.danger, + custom_id="ticket_close", + ) + async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button): + channel = interaction.channel + guild = interaction.guild + + await interaction.response.send_message( + f"Ticket closed by {interaction.user.mention}. Channel will be deleted in 5 seconds.", + ephemeral=False, + ) + + log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID) + if log_channel: + embed = discord.Embed( + title="Ticket Closed", + description=( + f"**Channel:** {channel.name}\n" + f"**Closed by:** {interaction.user.mention}\n" + f"**Topic:** {channel.topic or 'N/A'}" + ), + color=discord.Color.red(), + timestamp=discord.utils.utcnow(), + ) + await log_channel.send(embed=embed) + + await asyncio.sleep(5) + await channel.delete(reason=f"Ticket closed by {interaction.user}") + + +class TierSelectView(discord.ui.View): + """Three buttons for selecting a pricing tier — shown ephemerally after clicking Purchase.""" + + def __init__(self): + super().__init__(timeout=120) + + async def _handle(self, interaction: discord.Interaction, tier: str): + await interaction.response.defer(ephemeral=True, thinking=True) + try: + url = create_checkout_session( + tier, + str(interaction.user.id), + str(interaction.user), + ) + price_label = PRICES[tier]["label"] + embed = discord.Embed( + title="Complete Your Purchase", + description=( + f"You selected the **{price_label}** plan.\n\n" + f"[**→ Pay securely via Stripe**]({url})\n\n" + "Your license key will be **DM'd to you instantly** after payment.\n" + "The checkout link is valid for **24 hours**." + ), + color=discord.Color.green(), + ) + embed.set_footer(text="Powered by Stripe — we never store your card details.") + await interaction.followup.send(embed=embed, ephemeral=True) + except Exception as exc: + await interaction.followup.send( + f"❌ Could not create a checkout session. Please try again later.\n`{exc}`", + ephemeral=True, + ) + + @discord.ui.button(label="$10 / Week", style=discord.ButtonStyle.primary, custom_id="buy_weekly") + async def weekly(self, interaction: discord.Interaction, button: discord.ui.Button): + await self._handle(interaction, "weekly") + + @discord.ui.button(label="$25 / Month", style=discord.ButtonStyle.primary, custom_id="buy_monthly") + async def monthly(self, interaction: discord.Interaction, button: discord.ui.Button): + await self._handle(interaction, "monthly") + + @discord.ui.button(label="$150 Lifetime", style=discord.ButtonStyle.success, custom_id="buy_lifetime") + async def lifetime(self, interaction: discord.Interaction, button: discord.ui.Button): + await self._handle(interaction, "lifetime") + + +class TicketModal(discord.ui.Modal, title="Open a Support Ticket"): + """Modal that collects a subject before creating the ticket channel.""" + + subject = discord.ui.TextInput( + label="Subject", + placeholder="Brief description of your issue", + max_length=100, + required=True, + ) + + async def on_submit(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True, thinking=True) + await create_ticket(interaction, self.subject.value) + + +class SalesView(discord.ui.View): + """ + Persistent view with Purchase and Support buttons. + Posted once by /setup into the read-only sales channel. + """ + + def __init__(self): + super().__init__(timeout=None) + + @discord.ui.button( + label="Purchase", + style=discord.ButtonStyle.success, + custom_id="sales_purchase", + emoji="🛒", + ) + async def purchase(self, interaction: discord.Interaction, button: discord.ui.Button): + embed = discord.Embed( + title="Ultimate Investment Tool — Pricing", + description=( + "Select a tier below. Payment is handled securely via **Stripe**.\n" + "Your license key will be delivered to your **DMs** the moment payment clears." + ), + color=discord.Color.gold(), + ) + embed.add_field(name="Weekly", value="**$10** / 7 days", inline=True) + embed.add_field(name="Monthly", value="**$25** / 30 days", inline=True) + embed.add_field(name="Lifetime", value="**$150** one-time", inline=True) + embed.set_footer(text="Having trouble? Click the Support button to open a ticket.") + await interaction.response.send_message(embed=embed, view=TierSelectView(), ephemeral=True) + + @discord.ui.button( + label="Support", + style=discord.ButtonStyle.secondary, + custom_id="sales_support", + emoji="🎫", + ) + async def support(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_modal(TicketModal()) + + +# ───────────────────────────────────────────────────────────────────────────── +# Admin slash command — post the sales embed +# ───────────────────────────────────────────────────────────────────────────── + +@tree.command(name="setup", description="Post the sales & support embed (admin only)") +@app_commands.checks.has_permissions(administrator=True) +async def setup(interaction: discord.Interaction): + embed = discord.Embed( + title="Ultimate Investment Tool", + description=( + "Click on the **Purchase** button to purchase a license. " + "Click on the **Support** button to create a support ticket. " + "Support tickets are checked by our staff, and can take time to get a response. " + "Please be patient when waiting for staff to respond." + ), + color=discord.Color.gold(), + ) + await interaction.channel.send(embed=embed, view=SalesView()) + await interaction.response.send_message("Sales embed posted.", ephemeral=True) + + +@setup.error +async def setup_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.MissingPermissions): + await interaction.response.send_message( + "You need Administrator permission to use this command.", ephemeral=True + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# On-ready +# ───────────────────────────────────────────────────────────────────────────── + +@bot.event +async def on_ready(): + # Re-register all persistent views so buttons work after restarts + bot.add_view(SalesView()) + bot.add_view(TicketCloseView()) + + # Sync slash commands to the guild instantly (no 1-hour global wait) + guild_obj = discord.Object(id=GUILD_ID) + tree.copy_global_to(guild=guild_obj) + await tree.sync(guild=guild_obj) + + print(f"[Bot] Online as {bot.user} | Guild {GUILD_ID} | Slash commands synced") + + +# ───────────────────────────────────────────────────────────────────────────── +# Stripe webhook (aiohttp server on WEBHOOK_PORT) +# ───────────────────────────────────────────────────────────────────────────── + +async def _handle_stripe_webhook(request: web.Request) -> web.Response: + payload = await request.read() + sig_header = request.headers.get("stripe-signature", "") + + try: + event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET) + except stripe.error.SignatureVerificationError: + print("[Webhook] Invalid Stripe signature — request rejected") + return web.Response(status=400, text="Invalid signature") + except Exception as exc: + print(f"[Webhook] Error parsing event: {exc}") + return web.Response(status=400, text=str(exc)) + + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + metadata = session.get("metadata", {}) + + discord_user_id = metadata.get("discord_user_id") + discord_username = metadata.get("discord_username", "Unknown") + tier = metadata.get("tier") + + if not discord_user_id or tier not in PRICES: + print(f"[Webhook] Missing/invalid metadata: {metadata}") + return web.Response(status=200, text="OK") + + # 1. Issue license in Supabase + try: + key = issue_license( + tier, + notes=f"Discord: {discord_username} ({discord_user_id})", + ) + print(f"[License] Issued {key} ({tier}) for Discord user {discord_user_id}") + except Exception as exc: + print(f"[License] Failed to issue for {discord_user_id}: {exc}") + return web.Response(status=500, text="License issuance failed") + + # 2. DM the license key to the buyer + price_info = PRICES[tier] + expiry_str = ( + "Never (lifetime)" + if price_info["days"] is None + else f"{price_info['days']} days from today" + ) + try: + user = await bot.fetch_user(int(discord_user_id)) + embed = discord.Embed( + title="🎉 Purchase Confirmed — Ultimate Investment Tool", + description=( + f"Thank you for your purchase!\n\n" + f"**Your License Key:**\n```\n{key}\n```\n" + f"**Plan:** {price_info['label']}\n" + f"**Expires:** {expiry_str}\n\n" + "Paste this key into the application when prompted.\n" + "⚠️ This key is locked to one machine — keep it private." + ), + color=discord.Color.green(), + ) + embed.set_footer(text="Need help? Click the Support button in the server.") + await user.send(embed=embed) + print(f"[Bot] License DM'd to {discord_username} ({discord_user_id})") + except discord.Forbidden: + print(f"[Bot] Cannot DM {discord_user_id} — DMs may be disabled.") + except Exception as exc: + print(f"[Bot] DM error for {discord_user_id}: {exc}") + + # 3. Assign roles in the guild + try: + guild = bot.get_guild(GUILD_ID) + if guild: + member = await guild.fetch_member(int(discord_user_id)) + roles_to_add = [] + + user_role = guild.get_role(ROLE_USER_ID) + if user_role: + roles_to_add.append(user_role) + + if tier == "lifetime": + lifetime_role = guild.get_role(ROLE_LIFETIME_ID) + if lifetime_role: + roles_to_add.append(lifetime_role) + + if roles_to_add: + await member.add_roles(*roles_to_add, reason=f"Purchased {tier} via Stripe") + names = ", ".join(r.name for r in roles_to_add) + print(f"[Bot] Assigned roles '{names}' to {discord_username}") + else: + print(f"[Bot] No valid roles found to assign") + except discord.NotFound: + print(f"[Bot] Member {discord_user_id} not found in guild (may have left)") + except Exception as exc: + print(f"[Bot] Role assignment error for {discord_user_id}: {exc}") + + return web.Response(status=200, text="OK") + + +async def _start_webhook_server(): + app = web.Application() + app.router.add_post("/stripe/webhook", _handle_stripe_webhook) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT) + await site.start() + print(f"[Webhook] Stripe webhook server listening on :{WEBHOOK_PORT}/stripe/webhook") + + +# ───────────────────────────────────────────────────────────────────────────── +# Entry point +# ───────────────────────────────────────────────────────────────────────────── + +async def main(): + async with bot: + await _start_webhook_server() + await bot.start(DISCORD_TOKEN) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/discord_bot/requirements.txt b/discord_bot/requirements.txt new file mode 100644 index 0000000..22991a4 --- /dev/null +++ b/discord_bot/requirements.txt @@ -0,0 +1,5 @@ +discord.py>=2.3.0 +stripe>=7.0.0 +aiohttp>=3.9.0 +requests>=2.31.0 +python-dotenv>=1.0.0 diff --git a/dist/screener_gui.py b/dist/screener_gui.py index 02c1b48..254300c 100644 --- a/dist/screener_gui.py +++ b/dist/screener_gui.py @@ -43,7 +43,9 @@ except Exception: from stock_screener import (INDEX_CHOICES, STRATEGY_PRESETS, WEIGHTS, collect_tickers, fetch_all, score_stocks, - get_news_headlines, fetch_insider_trades) + _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 = { @@ -782,6 +784,9 @@ class ScreenerApp(tk.Tk): 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 = {} @@ -800,8 +805,8 @@ class ScreenerApp(tk.Tk): self._filters_window = None self._apply_styles() - self._build_toolbar() self._build_tab_bar() + self._build_toolbar() self._build_progress_bar() self._build_main_pane() self._build_status_bar() @@ -918,20 +923,11 @@ class ScreenerApp(tk.Tk): 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") - # Logo / title - 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._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))) @@ -950,8 +946,8 @@ class ScreenerApp(tk.Tk): tk.Label(bar, text="Show", bg=T["bg"], fg=T["fg2"], font=T["font_small"]).pack(side="left") - self._top_var = tk.StringVar(value="50") - _RoundedDropdown(bar, self._top_var, ["50", "100", "150"], + 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)) @@ -993,11 +989,6 @@ class ScreenerApp(tk.Tk): width=_s(100), height=_s(30), radius=_s(8) ).pack(side="left", padx=(0, _s(4))) - _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", padx=(_s(4), 0)) - 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") @@ -1013,6 +1004,22 @@ class ScreenerApp(tk.Tk): 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, @@ -1044,10 +1051,12 @@ class ScreenerApp(tk.Tk): 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() @@ -1055,6 +1064,7 @@ class ScreenerApp(tk.Tk): 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) @@ -1062,6 +1072,7 @@ class ScreenerApp(tk.Tk): 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() @@ -1283,9 +1294,10 @@ class ScreenerApp(tk.Tk): def _build_progress_bar(self): self._progress_var = tk.DoubleVar(value=0) - ttk.Progressbar(self, variable=self._progress_var, maximum=100, + self._progress_bar = ttk.Progressbar(self, variable=self._progress_var, maximum=100, style="Progress.Horizontal.TProgressbar", - mode="determinate").pack(fill="x", side="top") + mode="determinate") + self._progress_bar.pack(fill="x", side="top") # ------------------------------------------------------------------ # # Main pane — horizontal split: ticker list | tabbed detail # @@ -1332,36 +1344,56 @@ class ScreenerApp(tk.Tk): ("Sector", 140, "w"), ] - def _get_top_n(self) -> int: + def _get_top_n(self): v = self._top_var.get() - return int(v) + 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, then re-filter.""" + """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 - # Re-score the main screener data if it is loaded if self._df_raw is not None: - weights = STRATEGY_PRESETS.get(strategy) - self._df = score_stocks(self._df_raw, weights=weights) - self._apply_filters() - self._status_var.set(f" Strategy: {strategy} — rescored {len(self._df)} stocks.") - # Refresh the detail panel if a stock is currently selected - 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]) + 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}…") - # Always refresh the search tab — it works independently of screener data - # (live-fetched results re-score using get_weights, df results use updated df) - sv = getattr(self, "_search_view", None) - if sv is not None: - sv.update_df(self._df, self._df_raw) - sv.refresh_current_result() + def _rescore(): + result = score_stocks( + self._df_raw, weights=weights, + sector_stats=self._sector_stats, + ) + 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.""" @@ -1463,10 +1495,10 @@ class ScreenerApp(tk.Tk): # "Score ↓" keeps the composite_score order from score_stocks top_n = self._get_top_n() - self._populate_table(df.head(top_n)) + self._populate_table(df if top_n is None else df.head(top_n)) # Update status - n_shown = min(len(df), top_n) + n_shown = len(df) if top_n is None else min(len(df), top_n) total = len(df) parts = [] sector = self._sector_var.get() @@ -1587,6 +1619,13 @@ class ScreenerApp(tk.Tk): 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) @@ -2497,6 +2536,30 @@ class ScreenerApp(tk.Tk): # ── 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") @@ -2920,7 +2983,7 @@ class ScreenerApp(tk.Tk): "pe_forward": _flt, "pe_trailing": _flt, "pb_ratio": _flt, - "book_value": _price, + "book_value": _mcap, "ev_ebitda": _flt, "52w_high": _price, "52w_low": _price, @@ -2963,10 +3026,37 @@ class ScreenerApp(tk.Tk): "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"] @@ -2991,6 +3081,13 @@ class ScreenerApp(tk.Tk): 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: @@ -3114,7 +3211,8 @@ class ScreenerApp(tk.Tk): self._strategy_var.set("Custom...") dlg.destroy() if self._df_raw is not None: - self._df = score_stocks(self._df_raw, weights=w) + 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.") @@ -3224,6 +3322,12 @@ class ScreenerApp(tk.Tk): 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 # @@ -3344,12 +3448,17 @@ class ScreenerApp(tk.Tk): "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")) + 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, + "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)}) @@ -3378,11 +3487,27 @@ class ScreenerApp(tk.Tk): f"skip={msg['skip']} ETA {msg['eta']:.0f}s") elif mtype == "status": - self._status_var.set(" " + msg["text"]) + 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._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( @@ -3497,6 +3622,13 @@ class _StockLookupWindow(tk.Toplevel): 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) @@ -3692,7 +3824,11 @@ class _StockLookupWindow(tk.Toplevel): "revenue": _g("totalRevenue"), "profit_margin": _g("profitMargins"), "operating_margin": _g("operatingMargins"), - "fcf_yield": None, + "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"), @@ -3710,7 +3846,11 @@ class _StockLookupWindow(tk.Toplevel): "news_headlines": _news_headlines, "short_percent": _g("shortPercentOfFloat"), "beta": _g("beta"), - "volatility_30d": None, + "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, @@ -3802,7 +3942,7 @@ class _StockLookupWindow(tk.Toplevel): "pe_forward": _flt, "pe_trailing": _flt, "pb_ratio": _flt, - "book_value": _price, + "book_value": _mcap, "ev_ebitda": _flt, "52w_high": _price, "52w_low": _price, @@ -3875,6 +4015,13 @@ class _StockLookupWindow(tk.Toplevel): # 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: @@ -4414,11 +4561,12 @@ class _SearchTabView(tk.Frame): _QUAL_FIELDS = ScreenerApp._QUAL_FIELDS _ANLST_FIELDS = ScreenerApp._ANLST_FIELDS - def __init__(self, parent, df=None, df_raw=None, get_weights=None): + 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._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 = {} @@ -4456,7 +4604,8 @@ class _SearchTabView(tk.Frame): import pandas as pd row = dict(self._last_row_dict) _weights = self._get_weights() - scored = score_stocks(pd.DataFrame([row]), weights=_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", @@ -4654,7 +4803,8 @@ class _SearchTabView(tk.Frame): try: import pandas as pd _weights = self._get_weights() - scored = score_stocks(pd.DataFrame([live_row]), weights=_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", @@ -4720,7 +4870,7 @@ class _SearchTabView(tk.Frame): # Tracking Tab — SEC EDGAR Form 4 Insider Transactions # --------------------------------------------------------------------------- -class _TrackingTabView(tk.Frame): +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. @@ -4893,7 +5043,7 @@ class _TrackingTabView(tk.Frame): def _worker(): try: trades = fetch_insider_trades( - days_back=days, max_results=60, progress_cb=_progress) + days_back=days, progress_cb=_progress) except Exception: trades = [] self.after(0, lambda t=trades: self._on_fetched(t)) @@ -4998,6 +5148,370 @@ class _TrackingTabView(tk.Frame): 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("<>", 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 # --------------------------------------------------------------------------- diff --git a/dist/stock_screener.py b/dist/stock_screener.py index adadcab..c036f44 100644 --- a/dist/stock_screener.py +++ b/dist/stock_screener.py @@ -2571,6 +2571,7 @@ def fetch_hedge_fund_filings( resp = _get( "https://efts.sec.gov/LATEST/search-index", params={ + "q": "", "forms": "13F-HR", "dateRange": "custom", "startdt": start_dt.strftime("%Y-%m-%d"), @@ -2580,7 +2581,8 @@ def fetch_hedge_fund_filings( }, ) resp.raise_for_status() - except Exception: + except Exception as _e: + print(f" [HF] EFTS search failed: {_e}") break page_hits = resp.json().get("hits", {}).get("hits", []) diff --git a/dist/update_error.log b/dist/update_error.log new file mode 100644 index 0000000..b9497ec --- /dev/null +++ b/dist/update_error.log @@ -0,0 +1,2 @@ +[2026-03-16 14:10:05] No rows returned for channel=beta +[2026-03-16 14:18:03] RPC response for channel=beta: version=1.1.4, exe_url=present diff --git a/pyarmor.bug.log b/pyarmor.bug.log new file mode 100644 index 0000000..1a9cc56 --- /dev/null +++ b/pyarmor.bug.log @@ -0,0 +1,40 @@ +[BUG]: out of license + +## Command Line +C:\Users\noach\AppData\Local\Programs\Python\Python314\Scripts\pyarmor gen --platform windows.x86_64 --output C:\Users\noach\Desktop\Stock Tool\Stock Tool\_pyarmor_build C:\Users\noach\Desktop\Stock Tool\Stock Tool\launcher.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\license_check.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\updater.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\activation_dialog.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\hwid.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\screener_gui.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\stock_screener.py + +## Environments +Python 3.14.3 +Pyarmor 9.2.4 (trial), 000000, non-profits +Platform windows.x86_64 +Native windows.amd64 +Home C:\Users\noach\.pyarmor + +## Traceback +Traceback (most recent call last): + File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 804, in main + main_entry(sys.argv[1:]) + ~~~~~~~~~~^^^^^^^^^^^^^^ + File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 789, in main_entry + return args.func(ctx, args) + ~~~~~~~~~^^^^^^^^^^^ + File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 248, in cmd_gen + builder.process(options) + ~~~~~~~~~~~~~~~^^^^^^^^^ + File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 190, in process + async_obfuscate_scripts(self, n) if n else self._obfuscate_scripts() + ~~~~~~~~~~~~~~~~~~~~~~~^^ + File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 145, in _obfuscate_scripts + code = Pytransform3.generate_obfuscated_script(self.ctx, r) + File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\core\__init__.py", line 95, in generate_obfuscated_script + return m.generate_obfuscated_script(ctx, res) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^ + File "", line 728, in generate_obfuscated_script + File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__init__.py", line 16, in process + return meth(self, res, *args, **kwargs) + File "", line 553, in process + File "", line 559, in coserialize + File "", line 609, in _build_ast_body +RuntimeError: out of license + + diff --git a/supabase_schema.sql b/supabase_schema.sql index f3364af..1eda088 100644 --- a/supabase_schema.sql +++ b/supabase_schema.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS licenses ( license_key TEXT UNIQUE NOT NULL, hwid TEXT, -- NULL until first activation tier TEXT NOT NULL DEFAULT 'monthly', -- 'weekly' | 'monthly' | 'lifetime' + channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta' expiry_date TIMESTAMPTZ, -- NULL = lifetime active BOOLEAN NOT NULL DEFAULT true, machines_allowed INTEGER NOT NULL DEFAULT 1, @@ -20,9 +21,13 @@ CREATE TABLE IF NOT EXISTS licenses ( activated_at TIMESTAMPTZ ); +-- Add channel column to existing tables (safe to run on already-created tables) +ALTER TABLE licenses ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable'; + CREATE TABLE IF NOT EXISTS app_versions ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, version TEXT UNIQUE NOT NULL, -- e.g. '1.0.1' + channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta' gui_py_url TEXT, -- Supabase Storage URL for screener_gui.py screener_py_url TEXT, -- Supabase Storage URL for stock_screener.py release_notes TEXT, @@ -30,6 +35,9 @@ CREATE TABLE IF NOT EXISTS app_versions ( released_at TIMESTAMPTZ DEFAULT NOW() ); +-- Add channel column to existing app_versions tables (safe to run on already-created tables) +ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable'; + CREATE TABLE IF NOT EXISTS auth_log ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, license_key TEXT, @@ -39,21 +47,136 @@ CREATE TABLE IF NOT EXISTS auth_log ( created_at TIMESTAMPTZ DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS analyst_cache ( + ticker TEXT PRIMARY KEY, + pe_forward DOUBLE PRECISION, + eps_forward DOUBLE PRECISION, + analyst_norm DOUBLE PRECISION, + analyst_upside DOUBLE PRECISION, + analyst_count INTEGER, + analyst_target DOUBLE PRECISION, + recommendation TEXT, + news_sentiment DOUBLE PRECISION, + sector TEXT, + industry TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fundamentals_cache ( + ticker TEXT PRIMARY KEY, + -- Valuation + pe_trailing DOUBLE PRECISION, + pb_ratio DOUBLE PRECISION, + ev_ebitda DOUBLE PRECISION, + eps_trailing DOUBLE PRECISION, + -- Growth + revenue DOUBLE PRECISION, + revenue_growth DOUBLE PRECISION, + earnings_growth DOUBLE PRECISION, + -- Quality + roe DOUBLE PRECISION, + roa DOUBLE PRECISION, + debt_to_equity DOUBLE PRECISION, + total_debt DOUBLE PRECISION, + total_cash DOUBLE PRECISION, + book_value DOUBLE PRECISION, + current_ratio DOUBLE PRECISION, + -- Profitability + profit_margin DOUBLE PRECISION, + operating_margin DOUBLE PRECISION, + fcf_yield DOUBLE PRECISION, + dividend_yield DOUBLE PRECISION, + -- Market + market_cap DOUBLE PRECISION, + shares_outstanding DOUBLE PRECISION, + ev_revenue DOUBLE PRECISION, + -- Risk + short_percent DOUBLE PRECISION, + -- Meta + filer_type TEXT, -- 'us-gaap-quarterly' | 'us-gaap-annual' | 'ifrs-annual' | 'none' + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS sector_stats ( + sector TEXT PRIMARY KEY, + -- Valuation + pe_trailing_med DOUBLE PRECISION, pe_trailing_mad DOUBLE PRECISION, + pb_ratio_med DOUBLE PRECISION, pb_ratio_mad DOUBLE PRECISION, + ev_ebitda_med DOUBLE PRECISION, ev_ebitda_mad DOUBLE PRECISION, + ev_revenue_med DOUBLE PRECISION, ev_revenue_mad DOUBLE PRECISION, + -- Growth + revenue_growth_med DOUBLE PRECISION, revenue_growth_mad DOUBLE PRECISION, + earnings_growth_med DOUBLE PRECISION, earnings_growth_mad DOUBLE PRECISION, + eps_growth_med DOUBLE PRECISION, eps_growth_mad DOUBLE PRECISION, + -- Quality + roe_med DOUBLE PRECISION, roe_mad DOUBLE PRECISION, + roa_med DOUBLE PRECISION, roa_mad DOUBLE PRECISION, + debt_to_equity_med DOUBLE PRECISION, debt_to_equity_mad DOUBLE PRECISION, + current_ratio_med DOUBLE PRECISION, current_ratio_mad DOUBLE PRECISION, + -- Profitability + profit_margin_med DOUBLE PRECISION, profit_margin_mad DOUBLE PRECISION, + operating_margin_med DOUBLE PRECISION, operating_margin_mad DOUBLE PRECISION, + fcf_yield_med DOUBLE PRECISION, fcf_yield_mad DOUBLE PRECISION, + -- Analyst + analyst_upside_med DOUBLE PRECISION, analyst_upside_mad DOUBLE PRECISION, + -- Meta + ticker_count INTEGER, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Add columns that may be missing if sector_stats was created before the current schema +ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_med DOUBLE PRECISION; +ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_mad DOUBLE PRECISION; +ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS ticker_count INTEGER; + +ALTER TABLE sector_stats ENABLE ROW LEVEL SECURITY; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'sector_stats' AND policyname = 'anon_select' + ) THEN + CREATE POLICY anon_select ON sector_stats FOR SELECT TO anon USING (true); + END IF; +END $$; + +ALTER TABLE fundamentals_cache ENABLE ROW LEVEL SECURITY; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'fundamentals_cache' AND policyname = 'anon_select' + ) THEN + CREATE POLICY anon_select ON fundamentals_cache FOR SELECT TO anon USING (true); + END IF; +END $$; + -- Seed the initial version row so the updater has something to compare against. INSERT INTO app_versions (version, is_latest, release_notes) -VALUES ('1.0.0', true, 'Initial release') -ON CONFLICT (version) DO NOTHING; +SELECT '1.0.0', true, 'Initial release' +WHERE NOT EXISTS (SELECT 1 FROM app_versions WHERE version = '1.0.0'); -- ----------------------------------------------------------------------------- -- 2. ROW LEVEL SECURITY — no direct table access for anon -- ----------------------------------------------------------------------------- -ALTER TABLE licenses ENABLE ROW LEVEL SECURITY; -ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE licenses ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE analyst_cache ENABLE ROW LEVEL SECURITY; --- No RLS policies = anon/authenticated cannot SELECT/INSERT/UPDATE/DELETE directly. --- All access goes through SECURITY DEFINER functions below. +-- No RLS policies on licenses/app_versions/auth_log = anon cannot access directly. +-- All sensitive access goes through SECURITY DEFINER functions below. + +-- analyst_cache is public market data — allow the anon key to read it. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'analyst_cache' AND policyname = 'anon_select' + ) THEN + CREATE POLICY anon_select ON analyst_cache FOR SELECT TO anon USING (true); + END IF; +END $$; -- ----------------------------------------------------------------------------- -- 3. RPC: verify_license(p_key, p_hwid) → JSON @@ -126,6 +249,7 @@ BEGIN 'valid', true, 'reason', 'ok', 'tier', lic.tier, + 'channel', lic.channel, 'expiry_date', lic.expiry_date ); END; @@ -138,7 +262,7 @@ $$; -- Returns: { version, gui_py_url, screener_py_url, release_notes } -- ----------------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION get_latest_version() +CREATE OR REPLACE FUNCTION get_latest_version(p_channel TEXT DEFAULT 'stable') RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER @@ -149,6 +273,7 @@ BEGIN SELECT * INTO ver FROM app_versions WHERE is_latest = true + AND channel = p_channel ORDER BY released_at DESC LIMIT 1; @@ -170,4 +295,4 @@ $$; -- ----------------------------------------------------------------------------- GRANT EXECUTE ON FUNCTION verify_license(TEXT, TEXT) TO anon; -GRANT EXECUTE ON FUNCTION get_latest_version() TO anon; +GRANT EXECUTE ON FUNCTION get_latest_version(TEXT) TO anon; diff --git a/updater.py b/updater.py index a3dc293..65e08ce 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.3.6" +APP_VERSION = "1.3.8" def _exe_dir() -> str: