This commit is contained in:
Nolan Kovacs 2026-03-26 11:07:37 -04:00
parent c9f53d0ece
commit 3c0f3ce7a5
15 changed files with 2202 additions and 124 deletions

View File

@ -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)"
]
}
}

View File

@ -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), MonFri 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."

View File

@ -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')

View File

@ -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__":

48
deploy_beta.bat Normal file
View File

@ -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

48
deploy_stable.bat Normal file
View File

@ -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

39
discord_bot/.env.example Normal file
View File

@ -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

533
discord_bot/bot.py Normal file
View File

@ -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())

View File

@ -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

638
dist/screener_gui.py vendored
View File

@ -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("<<TreeviewSelect>>", self._on_row_select)
# ── Button groups ─────────────────────────────────────────────────────── #
@staticmethod
def _activate_btn(btn_dict: dict, active_key):
for key, btn in btn_dict.items():
is_active = (key == active_key)
bg = T["accent"] if is_active else T["bg3"]
fg = "#000000" if is_active else T["fg2"]
btn._bg = bg; btn._fg = fg; btn._border = bg
btn.itemconfig(btn._rect, fill=bg, outline=bg)
btn.itemconfig(btn._lbl, fill=fg)
def _set_period(self, days: int):
self._days_back = days
self._activate_btn(self._period_btns, days)
self._refresh()
def _set_min_val(self, val: int):
self._min_val = val
self._activate_btn(self._minval_btns, val)
self._apply_filter()
# ── Data fetch ────────────────────────────────────────────────────────── #
def on_show(self):
"""Called each time the sub-tab becomes visible; auto-fetches on first show."""
if not self._has_loaded:
self._refresh()
def _refresh(self):
if self._loading:
return
self._loading = True
self._refresh_btn.set_state("disabled")
self._status_var.set("Fetching from SEC EDGAR...")
days = self._days_back
def _progress(done, total):
self.after(0, lambda: self._status_var.set(
f"Loading... ({done}/{total} funds)"))
def _worker():
try:
holdings = fetch_hedge_fund_filings(
days_back=days, progress_cb=_progress)
except Exception as _hf_err:
import traceback
print(f"[HF ERROR] fetch_hedge_fund_filings raised an exception:\n"
f"{traceback.format_exc()}")
holdings = []
self.after(0, lambda h=holdings: self._on_fetched(h))
threading.Thread(target=_worker, daemon=True).start()
def _on_fetched(self, holdings: list):
self._loading = False
self._has_loaded = True
self._all_holdings = holdings
self._refresh_btn.set_state("normal")
self._apply_filter()
n = len(holdings)
if n:
self._status_var.set(
f"{n} holding{'s' if n != 1 else ''} loaded.")
else:
self._status_var.set("No holdings found for this period. Check console for details.")
# ── Filter & populate ─────────────────────────────────────────────────── #
def _apply_filter(self):
mv = self._min_val
if mv > 0:
rows = [h for h in self._all_holdings
if (h.get("value") or 0) >= mv]
else:
rows = list(self._all_holdings)
self._populate_table(rows)
def _populate_table(self, holdings: list):
self._tree.delete(*self._tree.get_children())
self._iid_to_url.clear()
for i, h in enumerate(holdings):
bg_tag = "odd" if i % 2 else "even"
opt = (h.get("put_call") or "").strip()
color_tag = "c_opt" if opt else "c_hf"
# Format filed date "YYYY-MM-DD" → "Mon DD"
date_str = h.get("filed_date", "")
try:
import calendar as _cal
y, m, d = date_str[:10].split("-")
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
except Exception:
pass
# Format reporting period → "Q1 '25"
period_str = h.get("period", "")
try:
import calendar as _cal # noqa: F811
py, pm, _ = period_str[:10].split("-")
period_str = f"Q{((int(pm) - 1) // 3) + 1} '{py[2:]}"
except Exception:
pass
iid = str(i)
self._iid_to_url[iid] = h.get("url", "")
self._tree.insert("", "end", iid=iid,
tags=(bg_tag, color_tag),
values=(
date_str,
(h.get("fund_name") or "")[:28],
(h.get("company") or "")[:26],
_fmt_shares(h.get("shares")),
_fmt_value(h.get("value")),
(h.get("class_") or "")[:8],
opt[:4] if opt else "",
))
# ── Sorting ───────────────────────────────────────────────────────────── #
def _sort_column(self, col: str):
data = [(self._tree.set(iid, col), iid)
for iid in self._tree.get_children("")]
def _key(pair):
s = pair[0].replace(",", "").replace("$", "").replace("", "").strip()
for sfx, mult in [("B", 1e9), ("M", 1e6), ("K", 1e3)]:
if s.endswith(sfx):
try: return (0, float(s[:-1]) * mult)
except: pass
try: return (0, float(s))
except: return (1, s.lower())
asc = self._sort_asc_map.get(col, False)
data.sort(key=_key, reverse=not asc)
self._sort_asc_map[col] = not asc
for idx, (_, iid) in enumerate(data):
self._tree.move(iid, "", idx)
tags = [t for t in self._tree.item(iid, "tags")
if t not in ("odd", "even")]
tags.append("odd" if idx % 2 else "even")
self._tree.item(iid, tags=tags)
# ── Row click ─────────────────────────────────────────────────────────── #
def _on_row_select(self, _event=None):
sel = self._tree.selection()
if not sel:
return
url = self._iid_to_url.get(sel[0], "")
if url:
webbrowser.open(url)
# ---------------------------------------------------------------------------
# Tracking section container — sub-tabs: Insider Trades | Hedge Funds
# ---------------------------------------------------------------------------
class _TrackingTabView(tk.Frame):
"""
Container for the Tracking section. Houses two sub-tabs:
Insider Trades SEC Form 4 filings
Hedge Funds SEC 13F-HR holdings
"""
def __init__(self, parent):
super().__init__(parent, bg=T["bg"])
self._sub_btns: dict = {}
self._active_sub = ""
self._build_sub_tab_bar()
self._insider_view = _InsiderTradesView(self)
self._hf_view = _HedgeFundView(self)
self._show_insider_sub()
# ── Sub-tab bar ───────────────────────────────────────────────────────── #
def _build_sub_tab_bar(self):
bar = tk.Frame(self, bg=T["bg"])
bar.pack(fill="x", padx=_s(10), pady=(_s(6), 0))
for name, cmd in [
("Insider Trades", self._show_insider_sub),
("Hedge Funds", self._show_hf_sub),
]:
w = max(_s(110), len(name) * _s(7) + _s(16))
b = _RoundedButton(bar, name, cmd,
width=w, height=_s(28), radius=_s(7),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(0, _s(6)))
self._sub_btns[name] = b
def _update_sub_active(self, name: str):
for n, btn in self._sub_btns.items():
is_active = (n == name)
bg = T["accent"] if is_active else T["bg3"]
fg = "#000000" if is_active else T["fg2"]
btn._bg = bg; btn._fg = fg; btn._border = bg
btn.itemconfig(btn._rect, fill=bg, outline=bg)
btn.itemconfig(btn._lbl, fill=fg)
# ── Sub-tab switching ─────────────────────────────────────────────────── #
def _show_insider_sub(self):
self._hf_view.pack_forget()
self._insider_view.pack(fill="both", expand=True)
self._active_sub = "insider"
self._update_sub_active("Insider Trades")
self._insider_view.on_show()
def _show_hf_sub(self):
self._insider_view.pack_forget()
self._hf_view.pack(fill="both", expand=True)
self._active_sub = "hf"
self._update_sub_active("Hedge Funds")
self._hf_view.on_show()
# ── Delegate on_show ─────────────────────────────────────────────────── #
def on_show(self):
"""Called by the main app each time the Tracking tab becomes visible."""
if self._active_sub == "hf":
self._hf_view.on_show()
else:
self._insider_view.on_show()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

View File

@ -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", [])

2
dist/update_error.log vendored Normal file
View File

@ -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

40
pyarmor.bug.log Normal file
View File

@ -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 "<maker>", 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 "<maker>", line 553, in process
File "<maker>", line 559, in coserialize
File "<maker>", line 609, in _build_ast_body
RuntimeError: out of license

View File

@ -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;

View File

@ -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: