Compare commits
No commits in common. "60ee3a65853c623ebb795d9bde7fb6f71c606389" and "c9f53d0ecec7b0df033e3b64efec5d8b86fac3d4" have entirely different histories.
60ee3a6585
...
c9f53d0ece
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"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)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,35 @@
|
||||||
# Cache refresh is now handled by VPS cron jobs on the Hetzner server.
|
name: Refresh Analyst Cache
|
||||||
# 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:
|
||||||
workflow_dispatch: # manual trigger only, for emergency use
|
schedule:
|
||||||
|
- cron: '0 7 * * 1-5' # 02:00 AM EST (07:00 UTC), Mon–Fri only
|
||||||
|
workflow_dispatch: # allow manual trigger from the GitHub Actions UI
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
refresh:
|
refresh:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 120
|
timeout-minutes: 90 # analyst phase ~50 min + news phase ~6 min
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Not used
|
- name: Checkout repository
|
||||||
run: echo "Cache refresh runs on VPS. See /etc/cron.d/cache_builder."
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install \
|
||||||
|
yfinance>=0.2.36 \
|
||||||
|
pandas>=2.0.0 \
|
||||||
|
requests>=2.28.0 \
|
||||||
|
vaderSentiment>=3.3.2
|
||||||
|
|
||||||
|
- name: Run cache builder
|
||||||
|
env:
|
||||||
|
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||||
|
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||||
|
run: python cache_builder.py
|
||||||
|
|
|
||||||
|
|
@ -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_000000']
|
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']
|
||||||
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')
|
||||||
|
|
|
||||||
804
cache_builder.py
804
cache_builder.py
|
|
@ -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, date, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
|
|
@ -32,55 +32,17 @@ except ImportError:
|
||||||
_VADER = None
|
_VADER = None
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PostgreSQL connection — reads secrets from /srv/api/.env on the VPS,
|
# Supabase credentials — injected as GitHub Actions secrets
|
||||||
# or from environment variables when run locally.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
import psycopg2
|
_SUPABASE_URL = os.environ["SUPABASE_URL"]
|
||||||
import psycopg2.extras
|
_SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv("/srv/api/.env")
|
|
||||||
|
|
||||||
_DB_HOST = "127.0.0.1"
|
_SUPABASE_HEADERS = {
|
||||||
_DB_NAME = "verimund"
|
"apikey": _SUPABASE_KEY,
|
||||||
_DB_USER = "verimund_user"
|
"Authorization": f"Bearer {_SUPABASE_KEY}",
|
||||||
_DB_PASS = os.environ.get("DB_PASS", "Shlevison2k17")
|
"Content-Type": "application/json",
|
||||||
|
"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
|
||||||
|
|
@ -94,98 +56,6 @@ 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)
|
||||||
|
|
@ -290,7 +160,6 @@ 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:
|
||||||
|
|
@ -408,586 +277,24 @@ def run_news_phase(tickers: list[str]) -> None:
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Upsert helpers — write to local PostgreSQL
|
# Supabase upsert
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# 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."""
|
"""Upsert a batch of rows into analyst_cache (merge on primary key)."""
|
||||||
filtered = [{c: r.get(c) for c in _ANALYST_COLS} for r in rows]
|
if not rows:
|
||||||
_pg_upsert(f"{_TABLE_PREFIX}analyst_cache", filtered)
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_fundamentals(rows: list[dict]) -> None:
|
|
||||||
"""Upsert a batch of rows into fundamentals_cache."""
|
|
||||||
filtered = [{c: r.get(c) for c in _FUND_COLS} for r in rows]
|
|
||||||
_pg_upsert(f"{_TABLE_PREFIX}fundamentals_cache", filtered)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Phase 3 helpers — EDGAR bulk frames (domestic quarterly filers)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_FRAME_CACHE: dict = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_recent_quarters(n: int = 9) -> list[tuple[int, int]]:
|
|
||||||
ref = date.today() - timedelta(days=45)
|
|
||||||
y, q = ref.year, (ref.month - 1) // 3 + 1
|
|
||||||
out = []
|
|
||||||
for _ in range(n):
|
|
||||||
out.append((y, q))
|
|
||||||
q -= 1
|
|
||||||
if q == 0:
|
|
||||||
q, y = 4, y - 1
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
|
|
||||||
key = (concept, unit, period)
|
|
||||||
if key in _FRAME_CACHE:
|
|
||||||
return _FRAME_CACHE[key]
|
|
||||||
url = f"https://data.sec.gov/api/xbrl/frames/us-gaap/{concept}/{unit}/{period}.json"
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, headers=_EDGAR_UA, timeout=20)
|
|
||||||
if resp.status_code == 404:
|
|
||||||
_FRAME_CACHE[key] = {}
|
|
||||||
return {}
|
|
||||||
resp.raise_for_status()
|
|
||||||
result: dict[int, float] = {}
|
|
||||||
for row in resp.json().get("data", []):
|
|
||||||
try:
|
|
||||||
if isinstance(row, dict):
|
|
||||||
cik, val = int(row["cik"]), float(row["val"])
|
|
||||||
else:
|
|
||||||
if len(row) < 6:
|
|
||||||
continue
|
|
||||||
cik, val = int(row[1]), float(row[5])
|
|
||||||
result[cik] = val
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
_FRAME_CACHE[key] = result
|
|
||||||
time.sleep(0.15)
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
_FRAME_CACHE[key] = {}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _sum_frames(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
|
||||||
ttm: dict[int, dict] = {}
|
|
||||||
for concept in concepts:
|
|
||||||
for period in periods:
|
|
||||||
for cik, val in _fetch_one_frame(concept, unit, period).items():
|
|
||||||
ttm.setdefault(cik, {})
|
|
||||||
if period not in ttm[cik]:
|
|
||||||
ttm[cik][period] = val
|
|
||||||
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
|
||||||
best: dict[int, float] = {}
|
|
||||||
for concept in concepts:
|
|
||||||
for period in periods:
|
|
||||||
for cik, val in _fetch_one_frame(concept, unit, period).items():
|
|
||||||
if cik not in best:
|
|
||||||
best[cik] = val
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _build_cik_maps() -> tuple[dict[str, int], dict[int, str]]:
|
|
||||||
try:
|
|
||||||
resp = requests.get(
|
|
||||||
"https://www.sec.gov/files/company_tickers.json",
|
|
||||||
headers=_EDGAR_UA, timeout=20,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
ticker_to_cik = {}
|
|
||||||
cik_to_ticker = {}
|
|
||||||
for entry in data.values():
|
|
||||||
t = entry.get("ticker", "").upper().replace(".", "-")
|
|
||||||
cik = int(entry["cik_str"])
|
|
||||||
ticker_to_cik[t] = cik
|
|
||||||
cik_to_ticker[cik] = t
|
|
||||||
return ticker_to_cik, cik_to_ticker
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [WARN] 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
|
return
|
||||||
|
try:
|
||||||
METRICS = [
|
resp = requests.post(
|
||||||
"pe_trailing", "pb_ratio", "ev_ebitda", "ev_revenue",
|
f"{_SUPABASE_URL}/rest/v1/analyst_cache",
|
||||||
"revenue_growth", "earnings_growth", "eps_growth",
|
headers=_SUPABASE_HEADERS,
|
||||||
"roe", "roa", "debt_to_equity", "current_ratio",
|
json=rows,
|
||||||
"profit_margin", "operating_margin", "fcf_yield",
|
timeout=30,
|
||||||
"analyst_upside",
|
)
|
||||||
]
|
resp.raise_for_status()
|
||||||
MIN_TICKERS = 15
|
print(f" Upserted {len(rows)} rows to Supabase")
|
||||||
|
except Exception as e:
|
||||||
# Build merged per-ticker dict: fundamentals + analyst fields
|
print(f" [WARN] Upsert failed: {e}")
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -996,7 +303,7 @@ def run_sector_stats_phase(fundamentals: dict[str, dict]) -> None:
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("=" * 55)
|
print("=" * 55)
|
||||||
print(" ANALYST + NEWS + FUNDAMENTALS CACHE BUILDER")
|
print(" ANALYST + NEWS CACHE BUILDER")
|
||||||
print("=" * 55 + "\n")
|
print("=" * 55 + "\n")
|
||||||
|
|
||||||
tickers = fetch_tickers()
|
tickers = fetch_tickers()
|
||||||
|
|
@ -1004,69 +311,10 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
@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
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
@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
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# ─── 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
|
|
||||||
|
|
@ -1,533 +0,0 @@
|
||||||
#!/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())
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
discord.py>=2.3.0
|
|
||||||
stripe>=7.0.0
|
|
||||||
aiohttp>=3.9.0
|
|
||||||
requests>=2.31.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
|
|
@ -43,9 +43,7 @@ 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,
|
||||||
_load_sector_stats,
|
get_news_headlines, fetch_insider_trades)
|
||||||
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 = {
|
||||||
|
|
@ -784,9 +782,6 @@ 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 = {}
|
||||||
|
|
@ -805,8 +800,8 @@ class ScreenerApp(tk.Tk):
|
||||||
self._filters_window = None
|
self._filters_window = None
|
||||||
|
|
||||||
self._apply_styles()
|
self._apply_styles()
|
||||||
self._build_tab_bar()
|
|
||||||
self._build_toolbar()
|
self._build_toolbar()
|
||||||
|
self._build_tab_bar()
|
||||||
self._build_progress_bar()
|
self._build_progress_bar()
|
||||||
self._build_main_pane()
|
self._build_main_pane()
|
||||||
self._build_status_bar()
|
self._build_status_bar()
|
||||||
|
|
@ -923,11 +918,20 @@ 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)))
|
||||||
|
|
@ -946,8 +950,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="100")
|
self._top_var = tk.StringVar(value="50")
|
||||||
_RoundedDropdown(bar, self._top_var, ["100", "200", "300", "400", "500", "All"],
|
_RoundedDropdown(bar, self._top_var, ["50", "100", "150"],
|
||||||
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))
|
||||||
|
|
@ -989,6 +993,11 @@ 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")
|
||||||
|
|
@ -1004,22 +1013,6 @@ 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,
|
||||||
|
|
@ -1051,12 +1044,10 @@ 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()
|
||||||
|
|
@ -1064,7 +1055,6 @@ 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)
|
||||||
|
|
@ -1072,7 +1062,6 @@ 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()
|
||||||
|
|
@ -1294,10 +1283,9 @@ 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)
|
||||||
self._progress_bar = ttk.Progressbar(self, variable=self._progress_var, maximum=100,
|
ttk.Progressbar(self, variable=self._progress_var, maximum=100,
|
||||||
style="Progress.Horizontal.TProgressbar",
|
style="Progress.Horizontal.TProgressbar",
|
||||||
mode="determinate")
|
mode="determinate").pack(fill="x", side="top")
|
||||||
self._progress_bar.pack(fill="x", side="top")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main pane — horizontal split: ticker list | tabbed detail #
|
# Main pane — horizontal split: ticker list | tabbed detail #
|
||||||
|
|
@ -1344,52 +1332,32 @@ class ScreenerApp(tk.Tk):
|
||||||
("Sector", 140, "w"),
|
("Sector", 140, "w"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def _get_top_n(self):
|
def _get_top_n(self) -> int:
|
||||||
v = self._top_var.get()
|
v = self._top_var.get()
|
||||||
return None if v == "All" else int(v)
|
return 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 in a background thread."""
|
"""Re-score the fetched data with the selected strategy's weights, then re-filter."""
|
||||||
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._rescoring = True
|
self._df = score_stocks(self._df_raw, weights=weights)
|
||||||
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(
|
self._status_var.set(f" Strategy: {strategy} — rescored {len(self._df)} stocks.")
|
||||||
f" Strategy: {strategy} — rescored {len(self._df)} stocks.")
|
# Refresh the detail panel if a stock is currently selected
|
||||||
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)
|
|
||||||
|
|
||||||
threading.Thread(target=_rescore, daemon=True).start()
|
# Always refresh the search tab — it works independently of screener data
|
||||||
else:
|
# (live-fetched results re-score using get_weights, df results use updated df)
|
||||||
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)
|
||||||
|
|
@ -1495,10 +1463,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 if top_n is None else df.head(top_n))
|
self._populate_table(df.head(top_n))
|
||||||
|
|
||||||
# Update status
|
# Update status
|
||||||
n_shown = len(df) if top_n is None else min(len(df), top_n)
|
n_shown = min(len(df), top_n)
|
||||||
total = len(df)
|
total = len(df)
|
||||||
parts = []
|
parts = []
|
||||||
sector = self._sector_var.get()
|
sector = self._sector_var.get()
|
||||||
|
|
@ -1619,13 +1587,6 @@ 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)
|
||||||
|
|
@ -2536,30 +2497,6 @@ 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")
|
||||||
|
|
@ -2983,7 +2920,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": _mcap,
|
"book_value": _price,
|
||||||
"ev_ebitda": _flt,
|
"ev_ebitda": _flt,
|
||||||
"52w_high": _price,
|
"52w_high": _price,
|
||||||
"52w_low": _price,
|
"52w_low": _price,
|
||||||
|
|
@ -3026,37 +2963,10 @@ 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"]
|
||||||
|
|
@ -3081,13 +2991,6 @@ 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:
|
||||||
|
|
@ -3211,8 +3114,7 @@ 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.")
|
||||||
|
|
@ -3322,12 +3224,6 @@ 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 #
|
||||||
|
|
@ -3448,17 +3344,12 @@ 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)})
|
||||||
|
|
@ -3487,27 +3378,11 @@ 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":
|
||||||
text = msg["text"]
|
self._status_var.set(" " + 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(
|
||||||
|
|
@ -3622,13 +3497,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -3824,11 +3692,7 @@ 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": (
|
"fcf_yield": None,
|
||||||
(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"),
|
||||||
|
|
@ -3846,11 +3710,7 @@ 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": (
|
"volatility_30d": None,
|
||||||
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,
|
||||||
|
|
@ -3942,7 +3802,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": _mcap,
|
"book_value": _price,
|
||||||
"ev_ebitda": _flt,
|
"ev_ebitda": _flt,
|
||||||
"52w_high": _price,
|
"52w_high": _price,
|
||||||
"52w_low": _price,
|
"52w_low": _price,
|
||||||
|
|
@ -4015,13 +3875,6 @@ 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:
|
||||||
|
|
@ -4561,12 +4414,11 @@ 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, get_sector_stats=None):
|
def __init__(self, parent, df=None, df_raw=None, get_weights=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 = {}
|
||||||
|
|
@ -4604,8 +4456,7 @@ 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",
|
||||||
|
|
@ -4803,8 +4654,7 @@ 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",
|
||||||
|
|
@ -4870,7 +4720,7 @@ class _SearchTabView(tk.Frame):
|
||||||
# Tracking Tab — SEC EDGAR Form 4 Insider Transactions
|
# Tracking Tab — SEC EDGAR Form 4 Insider Transactions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _InsiderTradesView(tk.Frame):
|
class _TrackingTabView(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.
|
||||||
|
|
@ -5043,7 +4893,7 @@ class _InsiderTradesView(tk.Frame):
|
||||||
def _worker():
|
def _worker():
|
||||||
try:
|
try:
|
||||||
trades = fetch_insider_trades(
|
trades = fetch_insider_trades(
|
||||||
days_back=days, progress_cb=_progress)
|
days_back=days, max_results=60, 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))
|
||||||
|
|
@ -5148,370 +4998,6 @@ class _InsiderTradesView(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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2571,7 +2571,6 @@ 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"),
|
||||||
|
|
@ -2581,8 +2580,7 @@ def fetch_hedge_fund_filings(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
except Exception as _e:
|
except Exception:
|
||||||
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", [])
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
@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
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
@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
|
|
||||||
|
|
@ -12,7 +12,6 @@ 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,
|
||||||
|
|
@ -21,13 +20,9 @@ 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,
|
||||||
|
|
@ -35,9 +30,6 @@ 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,
|
||||||
|
|
@ -47,114 +39,10 @@ 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)
|
||||||
SELECT '1.0.0', true, 'Initial release'
|
VALUES ('1.0.0', true, 'Initial release')
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM app_versions WHERE version = '1.0.0');
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 2. ROW LEVEL SECURITY — no direct table access for anon
|
-- 2. ROW LEVEL SECURITY — no direct table access for anon
|
||||||
|
|
@ -163,20 +51,9 @@ WHERE NOT EXISTS (SELECT 1 FROM app_versions WHERE version = '1.0.0');
|
||||||
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 on licenses/app_versions/auth_log = anon cannot access directly.
|
-- No RLS policies = anon/authenticated cannot SELECT/INSERT/UPDATE/DELETE directly.
|
||||||
-- All sensitive access goes through SECURITY DEFINER functions below.
|
-- All 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
|
||||||
|
|
@ -249,7 +126,6 @@ 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;
|
||||||
|
|
@ -262,7 +138,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(p_channel TEXT DEFAULT 'stable')
|
CREATE OR REPLACE FUNCTION get_latest_version()
|
||||||
RETURNS JSON
|
RETURNS JSON
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER
|
SECURITY DEFINER
|
||||||
|
|
@ -273,7 +149,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -295,4 +170,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(TEXT) TO anon;
|
GRANT EXECUTE ON FUNCTION get_latest_version() TO anon;
|
||||||
|
|
|
||||||
|
|
@ -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.9"
|
APP_VERSION = "1.3.6"
|
||||||
|
|
||||||
|
|
||||||
def _exe_dir() -> str:
|
def _exe_dir() -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue