Compare commits

...

2 Commits

Author SHA1 Message Date
Nolan Kovacs 60ee3a6585 j 2026-03-26 14:12:08 -04:00
Nolan Kovacs 3c0f3ce7a5 test 2026-03-26 11:07:37 -04:00
17 changed files with 2354 additions and 125 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: on:
schedule: workflow_dispatch: # manual trigger only, for emergency use
- cron: '0 7 * * 1-5' # 02:00 AM EST (07:00 UTC), Mon–Fri only
workflow_dispatch: # allow manual trigger from the GitHub Actions UI
jobs: jobs:
refresh: refresh:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 90 # analyst phase ~50 min + news phase ~6 min timeout-minutes: 120
steps: steps:
- name: Checkout repository - name: Not used
uses: actions/checkout@v4 run: echo "Cache refresh runs on VPS. See /etc/cron.d/cache_builder."
- 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

View File

@ -3,7 +3,7 @@ from PyInstaller.utils.hooks import collect_all
datas = [] datas = []
binaries = [] 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') tmp_ret = collect_all('pandas')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('numpy') tmp_ret = collect_all('numpy')

View File

@ -20,7 +20,7 @@ import time
import urllib.parse import urllib.parse
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, date, timedelta, timezone
import requests import requests
import yfinance as yf import yfinance as yf
@ -32,17 +32,55 @@ except ImportError:
_VADER = None _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"] import psycopg2
_SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"] import psycopg2.extras
from dotenv import load_dotenv
load_dotenv("/srv/api/.env")
_SUPABASE_HEADERS = { _DB_HOST = "127.0.0.1"
"apikey": _SUPABASE_KEY, _DB_NAME = "verimund"
"Authorization": f"Bearer {_SUPABASE_KEY}", _DB_USER = "verimund_user"
"Content-Type": "application/json", _DB_PASS = os.environ.get("DB_PASS", "Shlevison2k17")
"Prefer": "resolution=merge-duplicates",
} # 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 # Constants
@ -56,6 +94,98 @@ MAX_WORKERS = 5
NEWS_WORKERS = 20 NEWS_WORKERS = 20
UPSERT_EVERY = 500 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) # 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", "recommendation": info.get("recommendationKey") or "N/A",
"sector": info.get("sector") or None, "sector": info.get("sector") or None,
"industry": info.get("industry") or None, "industry": info.get("industry") or None,
"dividend_yield": info.get("dividendYield"),
"updated_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
} }
except Exception: 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: def _upsert(rows: list[dict]) -> None:
"""Upsert a batch of rows into analyst_cache (merge on primary key).""" """Upsert a batch of rows into analyst_cache."""
if not rows: filtered = [{c: r.get(c) for c in _ANALYST_COLS} for r in rows]
return _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: try:
resp = requests.post( resp = requests.get(url, headers=_EDGAR_UA, timeout=20)
f"{_SUPABASE_URL}/rest/v1/analyst_cache", if resp.status_code == 404:
headers=_SUPABASE_HEADERS, _FRAME_CACHE[key] = {}
json=rows, return {}
timeout=30, 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() 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: 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: def main() -> None:
print("=" * 55) print("=" * 55)
print(" ANALYST + NEWS CACHE BUILDER") print(" ANALYST + NEWS + FUNDAMENTALS CACHE BUILDER")
print("=" * 55 + "\n") print("=" * 55 + "\n")
tickers = fetch_tickers() tickers = fetch_tickers()
@ -311,10 +1004,69 @@ def main() -> None:
print("ERROR: No tickers fetched — aborting.") print("ERROR: No tickers fetched — aborting.")
raise SystemExit(1) raise SystemExit(1)
# Phase 1 — analyst consensus + forward estimates (yfinance)
run_analyst_phase(tickers) run_analyst_phase(tickers)
# Phase 2 — news sentiment (Google News RSS + VADER)
run_news_phase(tickers) 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.")
def phase5_only() -> None:
"""Load fundamentals from DB and recompute sector stats — no cache rebuild."""
print("=" * 55)
print(" PHASE 5 ONLY — Sector statistics recompute")
print(f" TABLE_PREFIX={_TABLE_PREFIX!r}")
print("=" * 55 + "\n")
print(f"Loading fundamentals from {_TABLE_PREFIX}fundamentals_cache ...")
cols = [c for c in _FUND_COLS if c != "updated_at"]
col_str = ", ".join(f'"{c}"' for c in cols)
try:
conn = _get_conn()
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(f'SELECT {col_str} FROM "{_TABLE_PREFIX}fundamentals_cache"')
rows = cur.fetchall()
conn.close()
except Exception as e:
print(f"ERROR: Could not load fundamentals: {e}")
raise SystemExit(1)
fundamentals = {r["ticker"]: dict(r) for r in rows}
print(f" Loaded {len(fundamentals)} rows\n")
run_sector_stats_phase(fundamentals)
print("\nPhase 5 complete.")
if __name__ == "__main__": if __name__ == "__main__":
import sys
if "--phase5-only" in sys.argv:
phase5_only()
else:
main() 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

602
dist/screener_gui.py vendored
View File

@ -43,7 +43,9 @@ except Exception:
from stock_screener import (INDEX_CHOICES, STRATEGY_PRESETS, WEIGHTS, from stock_screener import (INDEX_CHOICES, STRATEGY_PRESETS, WEIGHTS,
collect_tickers, fetch_all, score_stocks, 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 # Short descriptions shown as tooltips when hovering strategy dropdown items
STRATEGY_DESCRIPTIONS = { STRATEGY_DESCRIPTIONS = {
@ -782,6 +784,9 @@ class ScreenerApp(tk.Tk):
self._sort_col = None self._sort_col = None
self._sort_asc = False self._sort_asc = False
self._df_raw = None # unscored raw data — kept for re-scoring on strategy change 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._info_popup = None
self._selected_ticker = None self._selected_ticker = None
self._score_bars: dict = {} self._score_bars: dict = {}
@ -800,8 +805,8 @@ class ScreenerApp(tk.Tk):
self._filters_window = None self._filters_window = None
self._apply_styles() self._apply_styles()
self._build_toolbar()
self._build_tab_bar() self._build_tab_bar()
self._build_toolbar()
self._build_progress_bar() self._build_progress_bar()
self._build_main_pane() self._build_main_pane()
self._build_status_bar() self._build_status_bar()
@ -918,20 +923,11 @@ class ScreenerApp(tk.Tk):
def _build_toolbar(self): def _build_toolbar(self):
outer = tk.Frame(self, bg=T["bg"]) outer = tk.Frame(self, bg=T["bg"])
outer.pack(fill="x", side="top") outer.pack(fill="x", side="top")
self._toolbar_frame = outer
bar = tk.Frame(outer, bg=T["bg"], pady=_s(9)) bar = tk.Frame(outer, bg=T["bg"], pady=_s(9))
bar.pack(fill="x", padx=_s(12)) bar.pack(fill="x", padx=_s(12))
tk.Frame(outer, bg=T["border"], height=1).pack(fill="x") 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, self._run_btn = _RoundedButton(bar, "▶ Run", self._on_run,
width=_s(100), height=_s(34), radius=_s(10)) width=_s(100), height=_s(34), radius=_s(10))
self._run_btn.pack(side="left", padx=(0, _s(6))) 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"], tk.Label(bar, text="Show", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left") font=T["font_small"]).pack(side="left")
self._top_var = tk.StringVar(value="50") self._top_var = tk.StringVar(value="100")
_RoundedDropdown(bar, self._top_var, ["50", "100", "150"], _RoundedDropdown(bar, self._top_var, ["100", "200", "300", "400", "500", "All"],
width=_s(68), height=_s(28), width=_s(68), height=_s(28),
on_change=self._apply_filters on_change=self._apply_filters
).pack(side="left", padx=(_s(6), 0)) ).pack(side="left", padx=(_s(6), 0))
@ -993,11 +989,6 @@ class ScreenerApp(tk.Tk):
width=_s(100), height=_s(30), radius=_s(8) width=_s(100), height=_s(30), radius=_s(8)
).pack(side="left", padx=(0, _s(4))) ).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="") self._lastrun_var = tk.StringVar(value="")
tk.Label(bar, textvariable=self._lastrun_var, bg=T["bg"], fg=T["fg2"], tk.Label(bar, textvariable=self._lastrun_var, bg=T["bg"], fg=T["fg2"],
font=("Segoe UI", 9)).pack(side="right") font=("Segoe UI", 9)).pack(side="right")
@ -1013,6 +1004,22 @@ class ScreenerApp(tk.Tk):
bar.pack(fill="x", padx=_s(12), pady=0) bar.pack(fill="x", padx=_s(12), pady=0)
tk.Frame(outer, bg=T["border"], height=1).pack(fill="x") 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 = {} self._tab_btn_refs = {}
_tab_cmds = { _tab_cmds = {
"Screener": self._show_screener_tab, "Screener": self._show_screener_tab,
@ -1044,10 +1051,12 @@ class ScreenerApp(tk.Tk):
self._search_view.pack_forget() self._search_view.pack_forget()
if hasattr(self, "_tracking_view") and self._tracking_view is not None: if hasattr(self, "_tracking_view") and self._tracking_view is not None:
self._tracking_view.pack_forget() 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._main_frame.pack(fill="both", expand=True, padx=10, pady=(6, 10))
self._update_tab_active("Screener") self._update_tab_active("Screener")
def _show_search_tab(self): def _show_search_tab(self):
self._toolbar_frame.pack_forget()
self._main_frame.pack_forget() self._main_frame.pack_forget()
if hasattr(self, "_tracking_view") and self._tracking_view is not None: if hasattr(self, "_tracking_view") and self._tracking_view is not None:
self._tracking_view.pack_forget() self._tracking_view.pack_forget()
@ -1055,6 +1064,7 @@ class ScreenerApp(tk.Tk):
self._search_view = _SearchTabView( self._search_view = _SearchTabView(
self, df=self._df, df_raw=self._df_raw, self, df=self._df, df_raw=self._df_raw,
get_weights=self._get_current_weights, get_weights=self._get_current_weights,
get_sector_stats=lambda: self._sector_stats,
) )
else: else:
self._search_view.update_df(self._df, self._df_raw) self._search_view.update_df(self._df, self._df_raw)
@ -1062,6 +1072,7 @@ class ScreenerApp(tk.Tk):
self._update_tab_active("Search") self._update_tab_active("Search")
def _show_tracking_tab(self): def _show_tracking_tab(self):
self._toolbar_frame.pack_forget()
self._main_frame.pack_forget() self._main_frame.pack_forget()
if hasattr(self, "_search_view") and self._search_view is not None: if hasattr(self, "_search_view") and self._search_view is not None:
self._search_view.pack_forget() self._search_view.pack_forget()
@ -1283,9 +1294,10 @@ class ScreenerApp(tk.Tk):
def _build_progress_bar(self): def _build_progress_bar(self):
self._progress_var = tk.DoubleVar(value=0) 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", 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 # # Main pane — horizontal split: ticker list | tabbed detail #
@ -1332,32 +1344,52 @@ class ScreenerApp(tk.Tk):
("Sector", 140, "w"), ("Sector", 140, "w"),
] ]
def _get_top_n(self) -> int: def _get_top_n(self):
v = self._top_var.get() v = self._top_var.get()
return int(v) return None if v == "All" else int(v)
def _on_strategy_change(self, _event=None): 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() strategy = self._strategy_var.get()
if strategy == "Custom...": if strategy == "Custom...":
self._open_custom_strategy_dialog() self._open_custom_strategy_dialog()
return return
# Re-score the main screener data if it is loaded
if self._df_raw is not None: if self._df_raw is not None:
weights = STRATEGY_PRESETS.get(strategy) weights = STRATEGY_PRESETS.get(strategy)
self._df = score_stocks(self._df_raw, weights=weights) self._rescoring = True
self._rescore_id += 1
current_id = self._rescore_id
self._status_var.set(f" Rescoring with strategy: {strategy}…")
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._apply_filters()
self._status_var.set(f" Strategy: {strategy} — rescored {len(self._df)} stocks.") self._status_var.set(
# Refresh the detail panel if a stock is currently selected f" Strategy: {strategy} — rescored {len(self._df)} stocks.")
selected = getattr(self, "_selected_ticker", None) selected = getattr(self, "_selected_ticker", None)
if selected is not None and self._df is not None: if selected is not None and self._df is not None:
match = self._df[self._df["ticker"] == selected] match = self._df[self._df["ticker"] == selected]
if not match.empty: if not match.empty:
self._update_detail(match.iloc[0]) 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)
# Always refresh the search tab — it works independently of screener data threading.Thread(target=_rescore, daemon=True).start()
# (live-fetched results re-score using get_weights, df results use updated df) else:
sv = getattr(self, "_search_view", None) sv = getattr(self, "_search_view", None)
if sv is not None: if sv is not None:
sv.update_df(self._df, self._df_raw) sv.update_df(self._df, self._df_raw)
@ -1463,10 +1495,10 @@ class ScreenerApp(tk.Tk):
# "Score ↓" keeps the composite_score order from score_stocks # "Score ↓" keeps the composite_score order from score_stocks
top_n = self._get_top_n() 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 # 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) total = len(df)
parts = [] parts = []
sector = self._sector_var.get() sector = self._sector_var.get()
@ -1587,6 +1619,13 @@ class ScreenerApp(tk.Tk):
self._detail_title.pack(fill="x", side="top") self._detail_title.pack(fill="x", side="top")
tk.Frame(parent, bg=T["border"], height=1).pack(fill="x") 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 # Notebook — hidden until a ticker is selected
self._notebook = _CustomNotebook(parent) self._notebook = _CustomNotebook(parent)
self._notebook.pack(fill="both", expand=True, padx=0, pady=0) self._notebook.pack(fill="both", expand=True, padx=0, pady=0)
@ -2497,6 +2536,30 @@ class ScreenerApp(tk.Tk):
# ── NEWS HIGHLIGHTS ─────────────────────────────────────────────── # ── NEWS HIGHLIGHTS ───────────────────────────────────────────────
headlines = row.get("news_headlines") or [] 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: if headlines:
ins("━" * 56 + "\n", "heading") ins("━" * 56 + "\n", "heading")
ins(" NEWS HIGHLIGHTS\n", "heading") ins(" NEWS HIGHLIGHTS\n", "heading")
@ -2920,7 +2983,7 @@ class ScreenerApp(tk.Tk):
"pe_forward": _flt, "pe_forward": _flt,
"pe_trailing": _flt, "pe_trailing": _flt,
"pb_ratio": _flt, "pb_ratio": _flt,
"book_value": _price, "book_value": _mcap,
"ev_ebitda": _flt, "ev_ebitda": _flt,
"52w_high": _price, "52w_high": _price,
"52w_low": _price, "52w_low": _price,
@ -2963,10 +3026,37 @@ class ScreenerApp(tk.Tk):
"operating_margin", "fcf_yield", "analyst_upside", "dividend_yield", "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(): for key, lbl in self._detail_labels.items():
fmt = _FORMATTERS.get(key, lambda v: _flt(v)) fmt = _FORMATTERS.get(key, lambda v: _flt(v))
val = _derived.get(key, row.get(key)) val = _derived.get(key, row.get(key))
text = fmt(val) text = fmt(val)
if text == "—":
reason = _metric_reason(key)
if reason:
text = f"— ({reason})"
fg = T["fg"] fg = T["fg"]
if key in _GREEN_KEYS and not _nan(val): if key in _GREEN_KEYS and not _nan(val):
fg = T["green"] if val >= 0 else T["red"] fg = T["green"] if val >= 0 else T["red"]
@ -2991,6 +3081,13 @@ class ScreenerApp(tk.Tk):
self._update_score_bars(row) self._update_score_bars(row)
self._update_commentary(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 # Trigger chart update when ticker changes
new_ticker = row["ticker"] new_ticker = row["ticker"]
if new_ticker != self._chart_ticker: if new_ticker != self._chart_ticker:
@ -3114,7 +3211,8 @@ class ScreenerApp(tk.Tk):
self._strategy_var.set("Custom...") self._strategy_var.set("Custom...")
dlg.destroy() dlg.destroy()
if self._df_raw is not None: 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._apply_filters()
self._status_var.set( self._status_var.set(
f" Strategy: Custom — rescored {len(self._df)} stocks.") f" Strategy: Custom — rescored {len(self._df)} stocks.")
@ -3224,6 +3322,12 @@ class ScreenerApp(tk.Tk):
tk.Label(bar, textvariable=self._status_var, tk.Label(bar, textvariable=self._status_var,
bg=T["bg"], fg=T["fg2"], font=("Segoe UI", 9), bg=T["bg"], fg=T["fg2"], font=("Segoe UI", 9),
anchor="w", padx=12).pack(side="left") 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 # # Event handlers #
@ -3344,12 +3448,17 @@ class ScreenerApp(tk.Tk):
"text": "No data returned from yfinance."}) "text": "No data returned from yfinance."})
return 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, self._q.put({"type": "status", "run_id": run_id,
"text": f"Computing scores for {len(df_raw)} stocks…"}) "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, self._q.put({"type": "done", "run_id": run_id,
"df_raw": df_raw, "df_raw": df_raw,
"df_scored": df_scored, "df_scored": df_scored,
"sector_stats": sector_stats,
"total_scored": len(df_scored)}) "total_scored": len(df_scored)})
except Exception as exc: except Exception as exc:
self._q.put({"type": "error", "run_id": run_id, "text": str(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") f"skip={msg['skip']} ETA {msg['eta']:.0f}s")
elif mtype == "status": 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": elif mtype == "done":
self._df_raw = msg["df_raw"] self._df_raw = msg["df_raw"]
self._df = msg["df_scored"] self._df = msg["df_scored"]
self._sector_stats = msg.get("sector_stats", {})
self._progress_var.set(100) self._progress_var.set(100)
n = msg["total_scored"] n = msg["total_scored"]
self._status_var.set( self._status_var.set(
@ -3497,6 +3622,13 @@ class _StockLookupWindow(tk.Toplevel):
self._detail_title.pack(fill="x", side="top") self._detail_title.pack(fill="x", side="top")
tk.Frame(panel, bg=T["border"], height=1).pack(fill="x") 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 = _CustomNotebook(panel)
self._notebook.pack(fill="both", expand=True) self._notebook.pack(fill="both", expand=True)
@ -3692,7 +3824,11 @@ class _StockLookupWindow(tk.Toplevel):
"revenue": _g("totalRevenue"), "revenue": _g("totalRevenue"),
"profit_margin": _g("profitMargins"), "profit_margin": _g("profitMargins"),
"operating_margin": _g("operatingMargins"), "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"), "dividend_yield": _g("dividendYield"),
"recommendation": _g("recommendationKey"), "recommendation": _g("recommendationKey"),
"analyst_count": _g("numberOfAnalystOpinions"), "analyst_count": _g("numberOfAnalystOpinions"),
@ -3710,7 +3846,11 @@ class _StockLookupWindow(tk.Toplevel):
"news_headlines": _news_headlines, "news_headlines": _news_headlines,
"short_percent": _g("shortPercentOfFloat"), "short_percent": _g("shortPercentOfFloat"),
"beta": _g("beta"), "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 # Scores not available for live lookup
"composite_score": None, "composite_score": None,
"score_value": None, "score_value": None,
@ -3802,7 +3942,7 @@ class _StockLookupWindow(tk.Toplevel):
"pe_forward": _flt, "pe_forward": _flt,
"pe_trailing": _flt, "pe_trailing": _flt,
"pb_ratio": _flt, "pb_ratio": _flt,
"book_value": _price, "book_value": _mcap,
"ev_ebitda": _flt, "ev_ebitda": _flt,
"52w_high": _price, "52w_high": _price,
"52w_low": _price, "52w_low": _price,
@ -3875,6 +4015,13 @@ class _StockLookupWindow(tk.Toplevel):
# Analyst commentary # Analyst commentary
self._update_commentary(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 # Trigger chart
new_ticker = row.get("ticker") new_ticker = row.get("ticker")
if new_ticker and new_ticker != self._chart_ticker: if new_ticker and new_ticker != self._chart_ticker:
@ -4414,11 +4561,12 @@ class _SearchTabView(tk.Frame):
_QUAL_FIELDS = ScreenerApp._QUAL_FIELDS _QUAL_FIELDS = ScreenerApp._QUAL_FIELDS
_ANLST_FIELDS = ScreenerApp._ANLST_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"]) super().__init__(parent, bg=T["bg"])
self._df = df self._df = df
self._df_raw = df_raw self._df_raw = df_raw
self._get_weights = get_weights or (lambda: None) 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_dict = None # last displayed row (raw, pre-scored fields preserved)
self._last_row_live = False # True = live fetch, False = from df self._last_row_live = False # True = live fetch, False = from df
self._detail_labels: dict = {} self._detail_labels: dict = {}
@ -4456,7 +4604,8 @@ class _SearchTabView(tk.Frame):
import pandas as pd import pandas as pd
row = dict(self._last_row_dict) row = dict(self._last_row_dict)
_weights = self._get_weights() _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: if not scored.empty:
s = scored.iloc[0] s = scored.iloc[0]
for col in ("composite_score", "score_value", "score_growth", for col in ("composite_score", "score_value", "score_growth",
@ -4654,7 +4803,8 @@ class _SearchTabView(tk.Frame):
try: try:
import pandas as pd import pandas as pd
_weights = self._get_weights() _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: if not scored.empty:
s = scored.iloc[0] s = scored.iloc[0]
for col in ("composite_score", "score_value", "score_growth", 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 # 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. 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. Fetches data in a background thread; clicking a row opens the SEC filing.
@ -4893,7 +5043,7 @@ class _TrackingTabView(tk.Frame):
def _worker(): def _worker():
try: try:
trades = fetch_insider_trades( trades = fetch_insider_trades(
days_back=days, max_results=60, progress_cb=_progress) days_back=days, progress_cb=_progress)
except Exception: except Exception:
trades = [] trades = []
self.after(0, lambda t=trades: self._on_fetched(t)) self.after(0, lambda t=trades: self._on_fetched(t))
@ -4998,6 +5148,370 @@ class _TrackingTabView(tk.Frame):
webbrowser.open(url) webbrowser.open(url)
# ---------------------------------------------------------------------------
# Hedge Fund Holdings tab (13F-HR)
# ---------------------------------------------------------------------------
class _HedgeFundView(tk.Frame):
"""
Table of individual stock holdings parsed from SEC 13F-HR filings.
One row per holding per fund; clicking a row opens the SEC filing index.
"""
_HF_COLS = [
("Filed", 82, "center"),
("Fund", 195, "w"),
("Company", 185, "w"),
("Shares", 90, "e"),
("Value", 90, "e"),
("Class", 60, "center"),
("Opt", 45, "center"),
]
_PERIODS = [("30D", 30), ("60D", 60), ("90D", 90), ("180D", 180)]
_MIN_VALS = [
("All", 0),
("$1M+", 1_000_000),
("$10M+", 10_000_000),
("$100M+", 100_000_000),
]
def __init__(self, parent):
super().__init__(parent, bg=T["bg"])
self._all_holdings: list[dict] = []
self._iid_to_url: dict[str, str] = {}
self._loading = False
self._has_loaded = False
self._days_back = 90
self._min_val = 1_000_000
self._sort_asc_map: dict[str, bool] = {}
self._status_var = tk.StringVar(
value="Click ↻ Refresh to load hedge fund holdings.")
self._period_btns: dict = {}
self._minval_btns: dict = {}
self._build_controls()
self._build_table()
# ── Controls ─────────────────────────────────────────────────────────── #
def _build_controls(self):
outer = tk.Frame(self, bg=T["bg"])
outer.pack(fill="x", padx=_s(10), pady=(_s(8), 0))
tk.Label(outer, text="Hedge Fund Holdings (13F-HR)", bg=T["bg"],
fg=T["accent"],
font=("Segoe UI", 12, "bold")).pack(side="left",
padx=(0, _s(16)))
def _vsep():
tk.Frame(outer, bg=T["border"], width=1,
height=_s(20)).pack(side="left", padx=_s(10), fill="y")
# Period selector
tk.Label(outer, text="Period", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
for lbl, days in self._PERIODS:
w = max(_s(38), len(lbl) * _s(7) + _s(10))
b = _RoundedButton(outer, lbl, lambda d=days: self._set_period(d),
width=w, height=_s(26), radius=_s(6),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(_s(4), 0))
self._period_btns[days] = b
self._activate_btn(self._period_btns, self._days_back)
_vsep()
# Minimum value filter (applied client-side)
tk.Label(outer, text="Min Value", bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left")
for lbl, val in self._MIN_VALS:
w = max(_s(42), len(lbl) * _s(7) + _s(10))
b = _RoundedButton(outer, lbl, lambda v=val: self._set_min_val(v),
width=w, height=_s(26), radius=_s(6),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(_s(4), 0))
self._minval_btns[val] = b
self._activate_btn(self._minval_btns, self._min_val)
_vsep()
self._refresh_btn = _RoundedButton(
outer, "\u21bb Refresh", self._refresh,
width=_s(100), height=_s(26), radius=_s(6))
self._refresh_btn.pack(side="left")
tk.Label(outer, textvariable=self._status_var, bg=T["bg"], fg=T["fg2"],
font=T["font_small"]).pack(side="left", padx=(_s(14), 0))
tk.Frame(self, bg=T["border"], height=1).pack(
fill="x", padx=_s(10), pady=(_s(8), 0))
# ── Table ─────────────────────────────────────────────────────────────── #
def _build_table(self):
frame = tk.Frame(self, bg=T["bg"])
frame.pack(fill="both", expand=True, padx=_s(10), pady=(_s(6), _s(8)))
cols = [c[0] for c in self._HF_COLS]
self._tree = ttk.Treeview(frame, columns=cols, show="headings",
selectmode="browse")
for col, w, anchor in self._HF_COLS:
self._tree.heading(col, text=col,
command=lambda c=col: self._sort_column(c))
self._tree.column(col, width=_s(w), anchor=anchor,
stretch=(col in ("Fund", "Company")))
self._tree.tag_configure("odd", background=T["row_odd"])
self._tree.tag_configure("even", background=T["row_even"])
self._tree.tag_configure("c_hf", foreground=T["accent"])
self._tree.tag_configure("c_opt", foreground=T["yellow"])
vsb = ttk.Scrollbar(frame, orient="vertical", command=self._tree.yview)
hsb = ttk.Scrollbar(frame, orient="horizontal", command=self._tree.xview)
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
self._tree.bind("<<TreeviewSelect>>", self._on_row_select)
# ── Button groups ─────────────────────────────────────────────────────── #
@staticmethod
def _activate_btn(btn_dict: dict, active_key):
for key, btn in btn_dict.items():
is_active = (key == active_key)
bg = T["accent"] if is_active else T["bg3"]
fg = "#000000" if is_active else T["fg2"]
btn._bg = bg; btn._fg = fg; btn._border = bg
btn.itemconfig(btn._rect, fill=bg, outline=bg)
btn.itemconfig(btn._lbl, fill=fg)
def _set_period(self, days: int):
self._days_back = days
self._activate_btn(self._period_btns, days)
self._refresh()
def _set_min_val(self, val: int):
self._min_val = val
self._activate_btn(self._minval_btns, val)
self._apply_filter()
# ── Data fetch ────────────────────────────────────────────────────────── #
def on_show(self):
"""Called each time the sub-tab becomes visible; auto-fetches on first show."""
if not self._has_loaded:
self._refresh()
def _refresh(self):
if self._loading:
return
self._loading = True
self._refresh_btn.set_state("disabled")
self._status_var.set("Fetching from SEC EDGAR...")
days = self._days_back
def _progress(done, total):
self.after(0, lambda: self._status_var.set(
f"Loading... ({done}/{total} funds)"))
def _worker():
try:
holdings = fetch_hedge_fund_filings(
days_back=days, progress_cb=_progress)
except Exception as _hf_err:
import traceback
print(f"[HF ERROR] fetch_hedge_fund_filings raised an exception:\n"
f"{traceback.format_exc()}")
holdings = []
self.after(0, lambda h=holdings: self._on_fetched(h))
threading.Thread(target=_worker, daemon=True).start()
def _on_fetched(self, holdings: list):
self._loading = False
self._has_loaded = True
self._all_holdings = holdings
self._refresh_btn.set_state("normal")
self._apply_filter()
n = len(holdings)
if n:
self._status_var.set(
f"{n} holding{'s' if n != 1 else ''} loaded.")
else:
self._status_var.set("No holdings found for this period. Check console for details.")
# ── Filter & populate ─────────────────────────────────────────────────── #
def _apply_filter(self):
mv = self._min_val
if mv > 0:
rows = [h for h in self._all_holdings
if (h.get("value") or 0) >= mv]
else:
rows = list(self._all_holdings)
self._populate_table(rows)
def _populate_table(self, holdings: list):
self._tree.delete(*self._tree.get_children())
self._iid_to_url.clear()
for i, h in enumerate(holdings):
bg_tag = "odd" if i % 2 else "even"
opt = (h.get("put_call") or "").strip()
color_tag = "c_opt" if opt else "c_hf"
# Format filed date "YYYY-MM-DD" → "Mon DD"
date_str = h.get("filed_date", "")
try:
import calendar as _cal
y, m, d = date_str[:10].split("-")
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
except Exception:
pass
# Format reporting period → "Q1 '25"
period_str = h.get("period", "")
try:
import calendar as _cal # noqa: F811
py, pm, _ = period_str[:10].split("-")
period_str = f"Q{((int(pm) - 1) // 3) + 1} '{py[2:]}"
except Exception:
pass
iid = str(i)
self._iid_to_url[iid] = h.get("url", "")
self._tree.insert("", "end", iid=iid,
tags=(bg_tag, color_tag),
values=(
date_str,
(h.get("fund_name") or "")[:28],
(h.get("company") or "")[:26],
_fmt_shares(h.get("shares")),
_fmt_value(h.get("value")),
(h.get("class_") or "")[:8],
opt[:4] if opt else "—",
))
# ── Sorting ───────────────────────────────────────────────────────────── #
def _sort_column(self, col: str):
data = [(self._tree.set(iid, col), iid)
for iid in self._tree.get_children("")]
def _key(pair):
s = pair[0].replace(",", "").replace("$", "").replace("—", "").strip()
for sfx, mult in [("B", 1e9), ("M", 1e6), ("K", 1e3)]:
if s.endswith(sfx):
try: return (0, float(s[:-1]) * mult)
except: pass
try: return (0, float(s))
except: return (1, s.lower())
asc = self._sort_asc_map.get(col, False)
data.sort(key=_key, reverse=not asc)
self._sort_asc_map[col] = not asc
for idx, (_, iid) in enumerate(data):
self._tree.move(iid, "", idx)
tags = [t for t in self._tree.item(iid, "tags")
if t not in ("odd", "even")]
tags.append("odd" if idx % 2 else "even")
self._tree.item(iid, tags=tags)
# ── Row click ─────────────────────────────────────────────────────────── #
def _on_row_select(self, _event=None):
sel = self._tree.selection()
if not sel:
return
url = self._iid_to_url.get(sel[0], "")
if url:
webbrowser.open(url)
# ---------------------------------------------------------------------------
# Tracking section container — sub-tabs: Insider Trades | Hedge Funds
# ---------------------------------------------------------------------------
class _TrackingTabView(tk.Frame):
"""
Container for the Tracking section. Houses two sub-tabs:
• Insider Trades — SEC Form 4 filings
• Hedge Funds — SEC 13F-HR holdings
"""
def __init__(self, parent):
super().__init__(parent, bg=T["bg"])
self._sub_btns: dict = {}
self._active_sub = ""
self._build_sub_tab_bar()
self._insider_view = _InsiderTradesView(self)
self._hf_view = _HedgeFundView(self)
self._show_insider_sub()
# ── Sub-tab bar ───────────────────────────────────────────────────────── #
def _build_sub_tab_bar(self):
bar = tk.Frame(self, bg=T["bg"])
bar.pack(fill="x", padx=_s(10), pady=(_s(6), 0))
for name, cmd in [
("Insider Trades", self._show_insider_sub),
("Hedge Funds", self._show_hf_sub),
]:
w = max(_s(110), len(name) * _s(7) + _s(16))
b = _RoundedButton(bar, name, cmd,
width=w, height=_s(28), radius=_s(7),
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
b.pack(side="left", padx=(0, _s(6)))
self._sub_btns[name] = b
def _update_sub_active(self, name: str):
for n, btn in self._sub_btns.items():
is_active = (n == name)
bg = T["accent"] if is_active else T["bg3"]
fg = "#000000" if is_active else T["fg2"]
btn._bg = bg; btn._fg = fg; btn._border = bg
btn.itemconfig(btn._rect, fill=bg, outline=bg)
btn.itemconfig(btn._lbl, fill=fg)
# ── Sub-tab switching ─────────────────────────────────────────────────── #
def _show_insider_sub(self):
self._hf_view.pack_forget()
self._insider_view.pack(fill="both", expand=True)
self._active_sub = "insider"
self._update_sub_active("Insider Trades")
self._insider_view.on_show()
def _show_hf_sub(self):
self._insider_view.pack_forget()
self._hf_view.pack(fill="both", expand=True)
self._active_sub = "hf"
self._update_sub_active("Hedge Funds")
self._hf_view.on_show()
# ── Delegate on_show ─────────────────────────────────────────────────── #
def on_show(self):
"""Called by the main app each time the Tracking tab becomes visible."""
if self._active_sub == "hf":
self._hf_view.on_show()
else:
self._insider_view.on_show()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Entry point # Entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -2571,6 +2571,7 @@ def fetch_hedge_fund_filings(
resp = _get( resp = _get(
"https://efts.sec.gov/LATEST/search-index", "https://efts.sec.gov/LATEST/search-index",
params={ params={
"q": "",
"forms": "13F-HR", "forms": "13F-HR",
"dateRange": "custom", "dateRange": "custom",
"startdt": start_dt.strftime("%Y-%m-%d"), "startdt": start_dt.strftime("%Y-%m-%d"),
@ -2580,7 +2581,8 @@ def fetch_hedge_fund_filings(
}, },
) )
resp.raise_for_status() resp.raise_for_status()
except Exception: except Exception as _e:
print(f" [HF] EFTS search failed: {_e}")
break break
page_hits = resp.json().get("hits", {}).get("hits", []) 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

60
release_beta.bat Normal file
View File

@ -0,0 +1,60 @@
@echo off
setlocal
echo ============================================================
echo Release BETA (code deploy + exe build + beta user update)
echo ============================================================
echo.
set /p COMMIT_MSG=Commit message:
if "%COMMIT_MSG%"=="" (
echo Error: Commit message cannot be empty.
pause
exit /b 1
)
set /p RELEASE_NOTES=Release notes (shown to users, press Enter to skip):
echo.
echo [1/5] Staging all changes...
git add -A
echo [2/5] Committing...
git commit -m "%COMMIT_MSG%"
if errorlevel 1 (
echo Nothing to commit — continuing to build.
)
echo [3/5] 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/5] Deploying server-side files 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. Check SSH connection.
pause
exit /b 1
)
echo [5/5] Building exe and pushing update to beta users...
if "%RELEASE_NOTES%"=="" (
python push_update.py --channel beta
) else (
python push_update.py --channel beta --notes "%RELEASE_NOTES%"
)
if errorlevel 1 (
echo Exe build or upload failed.
pause
exit /b 1
)
echo.
echo ============================================================
echo Beta release complete. Beta users will update on next launch.
echo ============================================================
pause

60
release_stable.bat Normal file
View File

@ -0,0 +1,60 @@
@echo off
setlocal
echo ============================================================
echo Release STABLE (code deploy + exe build + user update)
echo ============================================================
echo.
set /p COMMIT_MSG=Commit message:
if "%COMMIT_MSG%"=="" (
echo Error: Commit message cannot be empty.
pause
exit /b 1
)
set /p RELEASE_NOTES=Release notes (shown to users, press Enter to skip):
echo.
echo [1/5] Staging all changes...
git add -A
echo [2/5] Committing...
git commit -m "%COMMIT_MSG%"
if errorlevel 1 (
echo Nothing to commit — continuing to build.
)
echo [3/5] Pushing to Gitea main branch...
git push gitea main
if errorlevel 1 (
echo Push failed. Check Gitea connection.
pause
exit /b 1
)
echo [4/5] Deploying server-side files 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. Check SSH connection.
pause
exit /b 1
)
echo [5/5] Building exe and pushing update to users...
if "%RELEASE_NOTES%"=="" (
python push_update.py --channel stable
) else (
python push_update.py --channel stable --notes "%RELEASE_NOTES%"
)
if errorlevel 1 (
echo Exe build or upload failed.
pause
exit /b 1
)
echo.
echo ============================================================
echo Stable release complete. Users will update on next launch.
echo ============================================================
pause

View File

@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS licenses (
license_key TEXT UNIQUE NOT NULL, license_key TEXT UNIQUE NOT NULL,
hwid TEXT, -- NULL until first activation hwid TEXT, -- NULL until first activation
tier TEXT NOT NULL DEFAULT 'monthly', -- 'weekly' | 'monthly' | 'lifetime' tier TEXT NOT NULL DEFAULT 'monthly', -- 'weekly' | 'monthly' | 'lifetime'
channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta'
expiry_date TIMESTAMPTZ, -- NULL = lifetime expiry_date TIMESTAMPTZ, -- NULL = lifetime
active BOOLEAN NOT NULL DEFAULT true, active BOOLEAN NOT NULL DEFAULT true,
machines_allowed INTEGER NOT NULL DEFAULT 1, machines_allowed INTEGER NOT NULL DEFAULT 1,
@ -20,9 +21,13 @@ CREATE TABLE IF NOT EXISTS licenses (
activated_at TIMESTAMPTZ 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 ( CREATE TABLE IF NOT EXISTS app_versions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY, id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
version TEXT UNIQUE NOT NULL, -- e.g. '1.0.1' 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 gui_py_url TEXT, -- Supabase Storage URL for screener_gui.py
screener_py_url TEXT, -- Supabase Storage URL for stock_screener.py screener_py_url TEXT, -- Supabase Storage URL for stock_screener.py
release_notes TEXT, release_notes TEXT,
@ -30,6 +35,9 @@ CREATE TABLE IF NOT EXISTS app_versions (
released_at TIMESTAMPTZ DEFAULT NOW() 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 ( CREATE TABLE IF NOT EXISTS auth_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY, id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
license_key TEXT, license_key TEXT,
@ -39,10 +47,114 @@ CREATE TABLE IF NOT EXISTS auth_log (
created_at TIMESTAMPTZ DEFAULT NOW() 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. -- Seed the initial version row so the updater has something to compare against.
INSERT INTO app_versions (version, is_latest, release_notes) INSERT INTO app_versions (version, is_latest, release_notes)
VALUES ('1.0.0', true, 'Initial release') SELECT '1.0.0', true, 'Initial release'
ON CONFLICT (version) DO NOTHING; WHERE NOT EXISTS (SELECT 1 FROM app_versions WHERE version = '1.0.0');
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 2. ROW LEVEL SECURITY — no direct table access for anon -- 2. ROW LEVEL SECURITY — no direct table access for anon
@ -51,9 +163,20 @@ ON CONFLICT (version) DO NOTHING;
ALTER TABLE licenses ENABLE ROW LEVEL SECURITY; ALTER TABLE licenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY; ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth_log 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. -- No RLS policies on licenses/app_versions/auth_log = anon cannot access directly.
-- All access goes through SECURITY DEFINER functions below. -- 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 -- 3. RPC: verify_license(p_key, p_hwid) → JSON
@ -126,6 +249,7 @@ BEGIN
'valid', true, 'valid', true,
'reason', 'ok', 'reason', 'ok',
'tier', lic.tier, 'tier', lic.tier,
'channel', lic.channel,
'expiry_date', lic.expiry_date 'expiry_date', lic.expiry_date
); );
END; END;
@ -138,7 +262,7 @@ $$;
-- Returns: { version, gui_py_url, screener_py_url, release_notes } -- 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 RETURNS JSON
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER SECURITY DEFINER
@ -149,6 +273,7 @@ BEGIN
SELECT * INTO ver SELECT * INTO ver
FROM app_versions FROM app_versions
WHERE is_latest = true WHERE is_latest = true
AND channel = p_channel
ORDER BY released_at DESC ORDER BY released_at DESC
LIMIT 1; LIMIT 1;
@ -170,4 +295,4 @@ $$;
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
GRANT EXECUTE ON FUNCTION verify_license(TEXT, TEXT) TO anon; 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. # Current app version — kept in sync by push_update.py before each build.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
APP_VERSION = "1.3.6" APP_VERSION = "1.3.9"
def _exe_dir() -> str: def _exe_dir() -> str: