Compare commits
8 Commits
012e207abd
...
3c0f3ce7a5
| Author | SHA1 | Date |
|---|---|---|
|
|
3c0f3ce7a5 | |
|
|
c9f53d0ece | |
|
|
12b731ecf9 | |
|
|
81c5ec72d6 | |
|
|
33c7e7d268 | |
|
|
cd35f9e02d | |
|
|
92c1750e1b | |
|
|
c36d4c2f03 |
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python --version && pip --version)",
|
||||
"Bash(python3 --version 2>/dev/null || py --version 2>/dev/null || echo \"no python\")",
|
||||
"Bash(node --version 2>/dev/null && npm --version 2>/dev/null || echo \"no node\")",
|
||||
"Bash(where python:*)",
|
||||
"Bash(where python3:*)",
|
||||
"Bash(where py:*)",
|
||||
"Bash(ls /c/Python*)",
|
||||
"Read(//c/Users/Nolan/AppData/Local/Programs/**)",
|
||||
"Bash(ls \"/c/Users/Nolan/AppData/Local/Programs/Python\" 2>/dev/null || echo \"not found\"\nls \"/c/Users/Nolan/AppData/Local/Microsoft/WindowsApps/\"python* 2>/dev/null || echo \"not found\")",
|
||||
"Bash(cd \"C:/Users/Nolan/Desktop/Stock Tool\" && python -c \"import tkinter; import numpy; import pandas; import yfinance; import requests; print\\('All imports OK'\\)\" 2>&1)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,20 @@
|
|||
name: Refresh Analyst Cache
|
||||
# Cache refresh is now handled by VPS cron jobs on the Hetzner server.
|
||||
# See /etc/cron.d/cache_builder on the VPS:
|
||||
# Stable: 2am daily → /srv/stock-tool/cache_builder.py → analyst_cache, fundamentals_cache, sector_stats
|
||||
# Beta: 3am daily → /srv/stock-tool-beta/cache_builder.py → beta_analyst_cache, beta_fundamentals_cache, beta_sector_stats
|
||||
#
|
||||
# This workflow is intentionally disabled (no triggers).
|
||||
|
||||
name: Refresh Cache (disabled — handled by VPS cron)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * 1-5' # 02:00 AM EST (07:00 UTC), Mon–Fri only
|
||||
workflow_dispatch: # allow manual trigger from the GitHub Actions UI
|
||||
workflow_dispatch: # manual trigger only, for emergency use
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90 # analyst phase ~50 min + news phase ~6 min
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install \
|
||||
yfinance>=0.2.36 \
|
||||
pandas>=2.0.0 \
|
||||
requests>=2.28.0 \
|
||||
vaderSentiment>=3.3.2
|
||||
|
||||
- name: Run cache builder
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
run: python cache_builder.py
|
||||
- name: Not used
|
||||
run: echo "Cache refresh runs on VPS. See /etc/cron.d/cache_builder."
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from PyInstaller.utils.hooks import collect_all
|
|||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment']
|
||||
hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment', 'pyarmor_runtime_000000']
|
||||
tmp_ret = collect_all('pandas')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('numpy')
|
||||
|
|
|
|||
56
api/main.py
56
api/main.py
|
|
@ -1,3 +1,4 @@
|
|||
import math
|
||||
import os
|
||||
import hmac
|
||||
import hashlib
|
||||
|
|
@ -22,6 +23,7 @@ DB_USER = "verimund_user"
|
|||
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "")
|
||||
HMAC_SECRET = os.getenv("HMAC_SECRET", "")
|
||||
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRE_HOURS = 24
|
||||
|
||||
|
|
@ -54,6 +56,19 @@ def verify_jwt(authorization: Optional[str] = Header(None)):
|
|||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
def _clean(row: dict) -> dict:
|
||||
"""Convert NaN/Inf floats → None so FastAPI can serialize the row to JSON."""
|
||||
return {
|
||||
k: (None if isinstance(v, float) and not math.isfinite(v) else v)
|
||||
for k, v in row.items()
|
||||
}
|
||||
|
||||
|
||||
def verify_admin(authorization: Optional[str] = Header(None)):
|
||||
if not authorization or authorization != f"Bearer {ADMIN_KEY}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
class AuthRequest(BaseModel):
|
||||
license_key: str
|
||||
hwid: str
|
||||
|
|
@ -61,6 +76,12 @@ class AuthRequest(BaseModel):
|
|||
signature: str
|
||||
|
||||
|
||||
class RegisterVersionRequest(BaseModel):
|
||||
version: str
|
||||
channel: str
|
||||
release_notes: str = ""
|
||||
|
||||
|
||||
@app.post("/auth")
|
||||
def authenticate(req: AuthRequest, db=Depends(get_db)):
|
||||
try:
|
||||
|
|
@ -105,7 +126,7 @@ def get_analyst_cache(payload=Depends(verify_jwt), db=Depends(get_db)):
|
|||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
table = "beta_analyst_cache" if payload.get("channel") == "beta" else "analyst_cache"
|
||||
cur.execute(f"SELECT * FROM {table}")
|
||||
return {"data": cur.fetchall()}
|
||||
return {"data": [_clean(dict(r)) for r in cur.fetchall()]}
|
||||
|
||||
|
||||
@app.get("/cache/fundamentals")
|
||||
|
|
@ -113,25 +134,52 @@ def get_fundamentals_cache(payload=Depends(verify_jwt), db=Depends(get_db)):
|
|||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
table = "beta_fundamentals_cache" if payload.get("channel") == "beta" else "fundamentals_cache"
|
||||
cur.execute(f"SELECT * FROM {table}")
|
||||
return {"data": cur.fetchall()}
|
||||
return {"data": [_clean(dict(r)) for r in cur.fetchall()]}
|
||||
|
||||
|
||||
@app.get("/cache/sector")
|
||||
def get_sector_stats(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
table = "beta_sector_stats" if payload.get("channel") == "beta" else "sector_stats"
|
||||
cur.execute(f"SELECT * FROM {table} ORDER BY sector")
|
||||
return {"data": [_clean(dict(r)) for r in cur.fetchall()]}
|
||||
|
||||
|
||||
@app.get("/update")
|
||||
def check_update(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
channel = payload.get("channel", "stable")
|
||||
cur.execute("SELECT * FROM app_versions WHERE channel = %s ORDER BY created_at DESC LIMIT 1", (channel,))
|
||||
cur.execute("SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1", (channel,))
|
||||
version = cur.fetchone()
|
||||
if not version:
|
||||
raise HTTPException(status_code=404, detail="No version found")
|
||||
return {"version": version["version"], "channel": channel, "release_notes": version.get("release_notes", "")}
|
||||
|
||||
|
||||
@app.get("/admin/versions")
|
||||
def get_versions(_=Depends(verify_admin), db=Depends(get_db)):
|
||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT * FROM app_versions ORDER BY released_at DESC LIMIT 1")
|
||||
version = cur.fetchone()
|
||||
return {"version": version["version"] if version else "1.0.0"}
|
||||
|
||||
|
||||
@app.post("/admin/register-version")
|
||||
def register_version(req: RegisterVersionRequest, _=Depends(verify_admin), db=Depends(get_db)):
|
||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"INSERT INTO app_versions (version, channel, release_notes, released_at) VALUES (%s, %s, %s, %s)",
|
||||
(req.version, req.channel, req.release_notes, datetime.now(timezone.utc))
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "ok", "version": req.version, "channel": req.channel}
|
||||
|
||||
|
||||
@app.post("/download")
|
||||
def download_exe(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
channel = payload.get("channel", "stable")
|
||||
cur.execute("SELECT * FROM app_versions WHERE channel = %s ORDER BY created_at DESC LIMIT 1", (channel,))
|
||||
cur.execute("SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1", (channel,))
|
||||
version = cur.fetchone()
|
||||
if not version:
|
||||
raise HTTPException(status_code=404, detail="No version found")
|
||||
|
|
|
|||
767
cache_builder.py
767
cache_builder.py
|
|
@ -20,7 +20,7 @@ import time
|
|||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, date, timedelta, timezone
|
||||
|
||||
import requests
|
||||
import yfinance as yf
|
||||
|
|
@ -32,17 +32,55 @@ except ImportError:
|
|||
_VADER = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase credentials — injected as GitHub Actions secrets
|
||||
# PostgreSQL connection — reads secrets from /srv/api/.env on the VPS,
|
||||
# or from environment variables when run locally.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SUPABASE_URL = os.environ["SUPABASE_URL"]
|
||||
_SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv("/srv/api/.env")
|
||||
|
||||
_SUPABASE_HEADERS = {
|
||||
"apikey": _SUPABASE_KEY,
|
||||
"Authorization": f"Bearer {_SUPABASE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "resolution=merge-duplicates",
|
||||
}
|
||||
_DB_HOST = "127.0.0.1"
|
||||
_DB_NAME = "verimund"
|
||||
_DB_USER = "verimund_user"
|
||||
_DB_PASS = os.environ.get("DB_PASS", "Shlevison2k17")
|
||||
|
||||
# TABLE_PREFIX is set by the cron job: "" for stable, "beta_" for beta channel.
|
||||
_TABLE_PREFIX = os.environ.get("TABLE_PREFIX", "")
|
||||
|
||||
|
||||
def _get_conn():
|
||||
return psycopg2.connect(host=_DB_HOST, dbname=_DB_NAME,
|
||||
user=_DB_USER, password=_DB_PASS)
|
||||
|
||||
|
||||
def _pg_upsert(table: str, rows: list[dict], conflict_col: str = "ticker") -> None:
|
||||
"""Generic PostgreSQL upsert — INSERT ... ON CONFLICT DO UPDATE."""
|
||||
if not rows:
|
||||
return
|
||||
import math
|
||||
# Sanitize: NaN/Inf → None (PostgreSQL JSON can't handle these)
|
||||
def _clean(v):
|
||||
return None if isinstance(v, float) and not math.isfinite(v) else v
|
||||
|
||||
cols = list(rows[0].keys())
|
||||
col_str = ", ".join(f'"{c}"' for c in cols)
|
||||
vals_str = ", ".join(["%s"] * len(cols))
|
||||
upd_str = ", ".join(f'"{c}" = EXCLUDED."{c}"' for c in cols if c != conflict_col)
|
||||
sql = (
|
||||
f'INSERT INTO "{table}" ({col_str}) VALUES ({vals_str}) '
|
||||
f'ON CONFLICT ("{conflict_col}") DO UPDATE SET {upd_str}'
|
||||
)
|
||||
data = [[_clean(row.get(c)) for c in cols] for row in rows]
|
||||
try:
|
||||
conn = _get_conn()
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany(sql, data)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f" Upserted {len(rows)} rows → {table}")
|
||||
except Exception as e:
|
||||
print(f" [WARN] Upsert to {table} failed: {e}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
|
@ -56,6 +94,98 @@ MAX_WORKERS = 5
|
|||
NEWS_WORKERS = 20
|
||||
UPSERT_EVERY = 500
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Foreign / annual filers — companies that don't appear in EDGAR quarterly
|
||||
# frames because they file Form 20-F (annual only).
|
||||
# Split into two groups:
|
||||
# US_GAAP_ANNUAL — file 20-F but report under US GAAP (same concept names,
|
||||
# just annual data instead of quarterly)
|
||||
# IFRS_ANNUAL — file 20-F under IFRS (different concept names, mapped
|
||||
# to equivalent US GAAP fields below)
|
||||
# NO_XBRL — no structured XBRL data; skipped gracefully
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EDGAR_UA = {"User-Agent": "StockScreener contact@investmenttool.com"}
|
||||
|
||||
US_GAAP_ANNUAL_FILERS = {
|
||||
"AEM": "0000002809", # Agnico Eagle Mines
|
||||
"ARM": "0001973239", # ARM Holdings
|
||||
"ASML": "0000937966", # ASML Holding
|
||||
"BABA": "0001577552", # Alibaba Group
|
||||
"BAM": "0001937926", # Brookfield Asset Management
|
||||
"BBVA": "0000842180", # Banco Bilbao Vizcaya
|
||||
"BHP": "0000811809", # BHP Group
|
||||
"HDB": "0001144967", # HDFC Bank
|
||||
"HTHIY": "0000047710", # Hitachi
|
||||
"ING": "0001039765", # ING Groep
|
||||
"ITUB": "0001132597", # Itau Unibanco
|
||||
"LYG": "0001160106", # Lloyds Banking Group
|
||||
"PDD": "0001737806", # PDD Holdings
|
||||
"SAN": "0000891478", # Banco Santander
|
||||
"TM": "0001094517", # Toyota Motor
|
||||
}
|
||||
|
||||
IFRS_ANNUAL_FILERS = {
|
||||
"AZN": "0000901832", # AstraZeneca
|
||||
"BCS": "0000312069", # Barclays
|
||||
"BP": "0000313807", # BP
|
||||
"GSK": "0001131399", # GSK
|
||||
"HSBC": "0001089113", # HSBC Holdings
|
||||
"NGG": "0001004315", # National Grid
|
||||
"NU": "0001691493", # Nu Holdings
|
||||
"NVS": "0001114448", # Novartis
|
||||
"RIO": "0000863064", # Rio Tinto plc
|
||||
"SHEL": "0001306965", # Shell
|
||||
"SPOT": "0001639920", # Spotify
|
||||
"UBS": "0001610520", # UBS Group
|
||||
"UL": "0000217410", # Unilever
|
||||
}
|
||||
|
||||
# IFRS concept → internal field name mapping
|
||||
IFRS_CONCEPT_MAP = {
|
||||
"Revenue": "revenue",
|
||||
"ProfitLoss": "net_income",
|
||||
"ProfitLossAttributableToOwnersOfParent": "net_income",
|
||||
"OperatingIncomeLoss": "operating_income",
|
||||
"Assets": "assets",
|
||||
"Equity": "equity",
|
||||
"EquityAttributableToOwnersOfParent": "equity",
|
||||
"CurrentAssets": "cur_assets",
|
||||
"CurrentLiabilities": "cur_liabilities",
|
||||
"CashAndCashEquivalents": "cash",
|
||||
"NoncurrentPortionOfLongtermBorrowings": "lt_debt",
|
||||
"Borrowings": "lt_debt",
|
||||
"AdjustmentsForDepreciationAndAmortisationExpense": "dna",
|
||||
"CashFlowsFromUsedInOperatingActivities": "op_cf",
|
||||
"PurchaseOfPropertyPlantAndEquipment": "capex",
|
||||
}
|
||||
|
||||
# US GAAP annual concept → internal field name mapping (same as quarterly)
|
||||
USGAAP_CONCEPT_MAP = {
|
||||
"Revenues": "revenue",
|
||||
"RevenueFromContractWithCustomerExcludingAssessedTax": "revenue",
|
||||
"SalesRevenueNet": "revenue",
|
||||
"NetIncomeLoss": "net_income",
|
||||
"OperatingIncomeLoss": "operating_income",
|
||||
"Assets": "assets",
|
||||
"StockholdersEquity": "equity",
|
||||
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest": "equity",
|
||||
"AssetsCurrent": "cur_assets",
|
||||
"LiabilitiesCurrent": "cur_liabilities",
|
||||
"CashAndCashEquivalentsAtCarryingValue": "cash",
|
||||
"CashCashEquivalentsAndShortTermInvestments": "cash",
|
||||
"LongTermDebt": "lt_debt",
|
||||
"LongTermDebtNoncurrent": "lt_debt",
|
||||
"LongTermDebtAndFinanceLeaseLiabilities": "lt_debt",
|
||||
"FinanceLeaseLiabilityNoncurrent": "lt_debt",
|
||||
"CommonStockholdersEquity": "equity",
|
||||
"DepreciationDepletionAndAmortization": "dna",
|
||||
"DepreciationAndAmortization": "dna",
|
||||
"NetCashProvidedByUsedInOperatingActivities": "op_cf",
|
||||
"PaymentsToAcquirePropertyPlantAndEquipment": "capex",
|
||||
"CommonStockSharesOutstanding": "shares",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ticker fetching (NASDAQ Trader files — same source as the screener)
|
||||
|
|
@ -160,6 +290,7 @@ def _fetch_analyst(ticker: str) -> dict | None:
|
|||
"recommendation": info.get("recommendationKey") or "N/A",
|
||||
"sector": info.get("sector") or None,
|
||||
"industry": info.get("industry") or None,
|
||||
"dividend_yield": info.get("dividendYield"),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
except Exception:
|
||||
|
|
@ -277,24 +408,586 @@ def run_news_phase(tickers: list[str]) -> None:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase upsert
|
||||
# Upsert helpers — write to local PostgreSQL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Explicit column allow-lists keep extra fields (e.g. dividend_yield from
|
||||
# yfinance) from causing "column does not exist" errors in PostgreSQL.
|
||||
_ANALYST_COLS = (
|
||||
"ticker", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
||||
"analyst_count", "analyst_target", "recommendation", "news_sentiment",
|
||||
"sector", "industry", "updated_at",
|
||||
)
|
||||
_FUND_COLS = (
|
||||
"ticker", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
|
||||
"revenue", "revenue_growth", "earnings_growth", "roe", "roa",
|
||||
"debt_to_equity", "total_debt", "total_cash", "book_value", "current_ratio",
|
||||
"profit_margin", "operating_margin", "fcf_yield", "dividend_yield",
|
||||
"market_cap", "shares_outstanding", "ev_revenue", "short_percent",
|
||||
"filer_type", "updated_at",
|
||||
)
|
||||
|
||||
|
||||
def _upsert(rows: list[dict]) -> None:
|
||||
"""Upsert a batch of rows into analyst_cache (merge on primary key)."""
|
||||
if not rows:
|
||||
return
|
||||
"""Upsert a batch of rows into analyst_cache."""
|
||||
filtered = [{c: r.get(c) for c in _ANALYST_COLS} for r in rows]
|
||||
_pg_upsert(f"{_TABLE_PREFIX}analyst_cache", filtered)
|
||||
|
||||
|
||||
def _upsert_fundamentals(rows: list[dict]) -> None:
|
||||
"""Upsert a batch of rows into fundamentals_cache."""
|
||||
filtered = [{c: r.get(c) for c in _FUND_COLS} for r in rows]
|
||||
_pg_upsert(f"{_TABLE_PREFIX}fundamentals_cache", filtered)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 helpers — EDGAR bulk frames (domestic quarterly filers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FRAME_CACHE: dict = {}
|
||||
|
||||
|
||||
def _get_recent_quarters(n: int = 9) -> list[tuple[int, int]]:
|
||||
ref = date.today() - timedelta(days=45)
|
||||
y, q = ref.year, (ref.month - 1) // 3 + 1
|
||||
out = []
|
||||
for _ in range(n):
|
||||
out.append((y, q))
|
||||
q -= 1
|
||||
if q == 0:
|
||||
q, y = 4, y - 1
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
|
||||
key = (concept, unit, period)
|
||||
if key in _FRAME_CACHE:
|
||||
return _FRAME_CACHE[key]
|
||||
url = f"https://data.sec.gov/api/xbrl/frames/us-gaap/{concept}/{unit}/{period}.json"
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{_SUPABASE_URL}/rest/v1/analyst_cache",
|
||||
headers=_SUPABASE_HEADERS,
|
||||
json=rows,
|
||||
timeout=30,
|
||||
resp = requests.get(url, headers=_EDGAR_UA, timeout=20)
|
||||
if resp.status_code == 404:
|
||||
_FRAME_CACHE[key] = {}
|
||||
return {}
|
||||
resp.raise_for_status()
|
||||
result: dict[int, float] = {}
|
||||
for row in resp.json().get("data", []):
|
||||
try:
|
||||
if isinstance(row, dict):
|
||||
cik, val = int(row["cik"]), float(row["val"])
|
||||
else:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
cik, val = int(row[1]), float(row[5])
|
||||
result[cik] = val
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
_FRAME_CACHE[key] = result
|
||||
time.sleep(0.15)
|
||||
return result
|
||||
except Exception:
|
||||
_FRAME_CACHE[key] = {}
|
||||
return {}
|
||||
|
||||
|
||||
def _sum_frames(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
||||
ttm: dict[int, dict] = {}
|
||||
for concept in concepts:
|
||||
for period in periods:
|
||||
for cik, val in _fetch_one_frame(concept, unit, period).items():
|
||||
ttm.setdefault(cik, {})
|
||||
if period not in ttm[cik]:
|
||||
ttm[cik][period] = val
|
||||
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
|
||||
|
||||
|
||||
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
||||
best: dict[int, float] = {}
|
||||
for concept in concepts:
|
||||
for period in periods:
|
||||
for cik, val in _fetch_one_frame(concept, unit, period).items():
|
||||
if cik not in best:
|
||||
best[cik] = val
|
||||
return best
|
||||
|
||||
|
||||
def _build_cik_maps() -> tuple[dict[str, int], dict[int, str]]:
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://www.sec.gov/files/company_tickers.json",
|
||||
headers=_EDGAR_UA, timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(f" Upserted {len(rows)} rows to Supabase")
|
||||
data = resp.json()
|
||||
ticker_to_cik = {}
|
||||
cik_to_ticker = {}
|
||||
for entry in data.values():
|
||||
t = entry.get("ticker", "").upper().replace(".", "-")
|
||||
cik = int(entry["cik_str"])
|
||||
ticker_to_cik[t] = cik
|
||||
cik_to_ticker[cik] = t
|
||||
return ticker_to_cik, cik_to_ticker
|
||||
except Exception as e:
|
||||
print(f" [WARN] Upsert failed: {e}")
|
||||
print(f" [WARN] Could not build CIK maps: {e}")
|
||||
return {}, {}
|
||||
|
||||
|
||||
def _compute_fundamentals(
|
||||
price: float,
|
||||
rev: float | None, rev_prev: float | None,
|
||||
ni: float | None, ni_prev: float | None,
|
||||
op_i: float | None, d_a: float | None,
|
||||
ocf: float | None, cx: float | None,
|
||||
tot_a: float | None, eq: float | None,
|
||||
ca: float | None, cl: float | None,
|
||||
csh: float | None, ltd: float | None,
|
||||
sh: float | None,
|
||||
) -> dict:
|
||||
"""Derive all fundamental metrics from raw statement values."""
|
||||
mkt_cap = (price * sh) if sh and sh > 0 else None
|
||||
eps_trail = (ni / sh) if ni is not None and sh and sh > 0 else None
|
||||
pe_trail = (price / eps_trail) if eps_trail and eps_trail > 0 else None
|
||||
pb = (price / (eq / sh)) if eq and sh and sh > 0 and eq > 0 else None
|
||||
ebitda = ((op_i + d_a) if op_i is not None and d_a is not None else op_i)
|
||||
ev = ((mkt_cap + (ltd or 0) - (csh or 0)) if mkt_cap is not None else None)
|
||||
ev_ebitda = (ev / ebitda) if ev and ebitda and ebitda > 0 else None
|
||||
ev_revenue= (ev / rev) if ev and rev and rev > 0 else None
|
||||
roe = (ni / eq) if ni is not None and eq and eq > 0 else None
|
||||
roa = (ni / tot_a) if ni is not None and tot_a and tot_a > 0 else None
|
||||
de = (ltd / eq) if ltd is not None and eq and eq > 0 else None
|
||||
curr_r = (ca / cl) if ca and cl and cl > 0 else None
|
||||
pm = (ni / rev) if ni is not None and rev and rev > 0 else None
|
||||
om = (op_i / rev) if op_i is not None and rev and rev > 0 else None
|
||||
fcf = ((ocf - cx) if ocf is not None and cx is not None else ocf)
|
||||
fcf_yield = (fcf / mkt_cap) if fcf is not None and mkt_cap and mkt_cap > 0 else None
|
||||
rev_g = ((rev - rev_prev) / abs(rev_prev)
|
||||
if rev is not None and rev_prev and rev_prev != 0 else None)
|
||||
ni_g = ((ni - ni_prev) / abs(ni_prev)
|
||||
if ni is not None and ni_prev and ni_prev != 0 else None)
|
||||
return {
|
||||
"market_cap": mkt_cap,
|
||||
"shares_outstanding": sh,
|
||||
"pe_trailing": pe_trail,
|
||||
"pb_ratio": pb,
|
||||
"ev_ebitda": ev_ebitda,
|
||||
"ev_revenue": ev_revenue,
|
||||
"eps_trailing": eps_trail,
|
||||
"revenue": rev,
|
||||
"revenue_growth": rev_g,
|
||||
"earnings_growth": ni_g,
|
||||
"roe": roe,
|
||||
"roa": roa,
|
||||
"debt_to_equity": de,
|
||||
"total_debt": ltd,
|
||||
"total_cash": csh,
|
||||
"book_value": (eq / sh) if eq and sh and sh > 0 else None,
|
||||
"current_ratio": curr_r,
|
||||
"profit_margin": pm,
|
||||
"operating_margin": om,
|
||||
"fcf_yield": fcf_yield,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — EDGAR bulk fundamentals (domestic quarterly filers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]:
|
||||
"""
|
||||
Fetch EDGAR XBRL frames for all domestic quarterly filers and return
|
||||
{ticker: fundamentals_dict}. Foreign/annual filers handled separately.
|
||||
"""
|
||||
print("Phase 3: Fetching EDGAR bulk fundamentals ...")
|
||||
ticker_to_cik, cik_to_ticker = _build_cik_maps()
|
||||
if not ticker_to_cik:
|
||||
print(" [WARN] CIK map unavailable — EDGAR bulk skipped")
|
||||
return {}
|
||||
|
||||
quarters = _get_recent_quarters(9)
|
||||
ttm_periods = [f"CY{y}Q{q}" for y, q in quarters[:4]]
|
||||
prev_periods = [f"CY{y}Q{q}" for y, q in quarters[4:8]]
|
||||
bs_periods = [f"CY{y}Q{q}I" for y, q in quarters[:3]]
|
||||
|
||||
print(f" TTM periods : {ttm_periods}")
|
||||
|
||||
assets = _best_frame(["Assets"], "USD", bs_periods)
|
||||
equity = _best_frame(["StockholdersEquity",
|
||||
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",
|
||||
"CommonStockholdersEquity"], "USD", bs_periods)
|
||||
cur_assets = _best_frame(["AssetsCurrent"], "USD", bs_periods)
|
||||
cur_liab = _best_frame(["LiabilitiesCurrent"], "USD", bs_periods)
|
||||
cash = _best_frame(["CashAndCashEquivalentsAtCarryingValue",
|
||||
"CashCashEquivalentsAndShortTermInvestments"], "USD", bs_periods)
|
||||
lt_debt = _best_frame(["LongTermDebt", "LongTermDebtNoncurrent",
|
||||
"LongTermDebtAndCapitalLeaseObligations",
|
||||
"LongTermDebtAndFinanceLeaseLiabilities",
|
||||
"FinanceLeaseLiabilityNoncurrent"], "USD", bs_periods)
|
||||
shares = _best_frame(["CommonStockSharesOutstanding"], "shares", bs_periods)
|
||||
revenue = _sum_frames(["Revenues",
|
||||
"RevenueFromContractWithCustomerExcludingAssessedTax",
|
||||
"SalesRevenueNet"], "USD", ttm_periods)
|
||||
net_income = _sum_frames(["NetIncomeLoss"], "USD", ttm_periods)
|
||||
op_income = _sum_frames(["OperatingIncomeLoss"], "USD", ttm_periods)
|
||||
dna = _sum_frames(["DepreciationDepletionAndAmortization",
|
||||
"DepreciationAndAmortization"], "USD", ttm_periods)
|
||||
op_cf = _sum_frames(["NetCashProvidedByUsedInOperatingActivities"], "USD", ttm_periods)
|
||||
capex = _sum_frames(["PaymentsToAcquirePropertyPlantAndEquipment"], "USD", ttm_periods)
|
||||
rev_prev = _sum_frames(["Revenues",
|
||||
"RevenueFromContractWithCustomerExcludingAssessedTax",
|
||||
"SalesRevenueNet"], "USD", prev_periods)
|
||||
ni_prev = _sum_frames(["NetIncomeLoss"], "USD", prev_periods)
|
||||
|
||||
print(f" Revenue coverage: {len(revenue)} companies")
|
||||
|
||||
# Known foreign filer tickers — skip them here, handled in Phase 3B
|
||||
foreign_tickers = set(US_GAAP_ANNUAL_FILERS) | set(IFRS_ANNUAL_FILERS)
|
||||
|
||||
result: dict[str, dict] = {}
|
||||
for ticker in all_tickers:
|
||||
if ticker in foreign_tickers:
|
||||
continue
|
||||
cik = ticker_to_cik.get(ticker)
|
||||
if cik is None:
|
||||
continue
|
||||
|
||||
# yfinance price needed for ratios — use a fast single-ticker download
|
||||
try:
|
||||
raw = yf.download(ticker, period="5d", auto_adjust=True, progress=False)
|
||||
price = float(raw["Close"].dropna().iloc[-1]) if not raw.empty else None
|
||||
except Exception:
|
||||
price = None
|
||||
if price is None:
|
||||
continue
|
||||
|
||||
metrics = _compute_fundamentals(
|
||||
price,
|
||||
revenue.get(cik), rev_prev.get(cik),
|
||||
net_income.get(cik), ni_prev.get(cik),
|
||||
op_income.get(cik), dna.get(cik),
|
||||
op_cf.get(cik), capex.get(cik),
|
||||
assets.get(cik), equity.get(cik),
|
||||
cur_assets.get(cik), cur_liab.get(cik),
|
||||
cash.get(cik), lt_debt.get(cik),
|
||||
shares.get(cik),
|
||||
)
|
||||
metrics["ticker"] = ticker
|
||||
metrics["filer_type"] = "us-gaap-quarterly"
|
||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result[ticker] = metrics
|
||||
|
||||
print(f" Domestic filers matched: {len(result)}\n")
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3B — Foreign / annual filer fundamentals (companyfacts per CIK)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_companyfacts(cik_padded: str) -> dict:
|
||||
"""Fetch all XBRL facts for one company from SEC companyfacts API."""
|
||||
url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik_padded}.json"
|
||||
try:
|
||||
resp = requests.get(url, headers=_EDGAR_UA, timeout=30)
|
||||
if resp.status_code == 404:
|
||||
return {}
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("facts", {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_latest_annual_value(facts: dict, taxonomy: str, concept: str) -> float | None:
|
||||
"""Extract the most recent annual (12-month) value for a concept."""
|
||||
try:
|
||||
units = facts.get(taxonomy, {}).get(concept, {}).get("units", {})
|
||||
# Try USD first, then shares, then any available unit
|
||||
for unit_key in ("USD", "shares", *units.keys()):
|
||||
entries = units.get(unit_key, [])
|
||||
# Filter to annual (form 10-K or 20-F) or 12-month duration entries
|
||||
annual = [
|
||||
e for e in entries
|
||||
if e.get("form") in ("10-K", "20-F")
|
||||
or (e.get("start") and e.get("end") and
|
||||
_months_between(e["start"], e["end"]) >= 11)
|
||||
]
|
||||
if not annual:
|
||||
continue
|
||||
# Pick most recent by end date
|
||||
annual.sort(key=lambda e: e.get("end", ""), reverse=True)
|
||||
return float(annual[0]["val"])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_prior_annual_value(facts: dict, taxonomy: str, concept: str) -> float | None:
|
||||
"""Extract the second-most-recent annual value for YoY growth calculation."""
|
||||
try:
|
||||
units = facts.get(taxonomy, {}).get(concept, {}).get("units", {})
|
||||
for unit_key in ("USD", "shares", *units.keys()):
|
||||
entries = units.get(unit_key, [])
|
||||
annual = [
|
||||
e for e in entries
|
||||
if e.get("form") in ("10-K", "20-F")
|
||||
or (e.get("start") and e.get("end") and
|
||||
_months_between(e["start"], e["end"]) >= 11)
|
||||
]
|
||||
if len(annual) < 2:
|
||||
continue
|
||||
annual.sort(key=lambda e: e.get("end", ""), reverse=True)
|
||||
return float(annual[1]["val"])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _months_between(start: str, end: str) -> int:
|
||||
try:
|
||||
s = datetime.fromisoformat(start)
|
||||
e = datetime.fromisoformat(end)
|
||||
return (e.year - s.year) * 12 + (e.month - s.month)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_facts(facts: dict, taxonomy: str, concept_map: dict) -> dict:
|
||||
"""Extract latest and prior annual values for all mapped concepts."""
|
||||
out = {}
|
||||
for concept, field in concept_map.items():
|
||||
val = _get_latest_annual_value(facts, taxonomy, concept)
|
||||
if val is not None and field not in out:
|
||||
out[field] = val
|
||||
prior = _get_prior_annual_value(facts, taxonomy, concept)
|
||||
if prior is not None and f"{field}_prev" not in out:
|
||||
out[f"{field}_prev"] = prior
|
||||
return out
|
||||
|
||||
|
||||
def run_foreign_filers_phase() -> dict[str, dict]:
|
||||
"""
|
||||
Fetch fundamentals for all known foreign/annual filers via the SEC
|
||||
companyfacts API. Returns {ticker: fundamentals_dict}.
|
||||
"""
|
||||
print("Phase 3B: Fetching foreign/annual filer fundamentals ...")
|
||||
result: dict[str, dict] = {}
|
||||
all_foreign = {
|
||||
**{t: (cik, "us-gaap", USGAAP_CONCEPT_MAP) for t, cik in US_GAAP_ANNUAL_FILERS.items()},
|
||||
**{t: (cik, "ifrs-full", IFRS_CONCEPT_MAP) for t, cik in IFRS_ANNUAL_FILERS.items()},
|
||||
}
|
||||
|
||||
for ticker, (cik_padded, taxonomy, concept_map) in all_foreign.items():
|
||||
try:
|
||||
facts = _fetch_companyfacts(cik_padded)
|
||||
if not facts:
|
||||
print(f" [WARN] No XBRL facts for {ticker} ({cik_padded})")
|
||||
continue
|
||||
|
||||
raw = _extract_facts(facts, taxonomy, concept_map)
|
||||
|
||||
# Get current price
|
||||
try:
|
||||
dl = yf.download(ticker, period="5d", auto_adjust=True, progress=False)
|
||||
price = float(dl["Close"].dropna().iloc[-1]) if not dl.empty else None
|
||||
except Exception:
|
||||
price = None
|
||||
if price is None:
|
||||
print(f" [WARN] No price for {ticker} — skipping")
|
||||
continue
|
||||
|
||||
metrics = _compute_fundamentals(
|
||||
price,
|
||||
raw.get("revenue"), raw.get("revenue_prev"),
|
||||
raw.get("net_income"), raw.get("net_income_prev"),
|
||||
raw.get("operating_income"), raw.get("dna"),
|
||||
raw.get("op_cf"), raw.get("capex"),
|
||||
raw.get("assets"), raw.get("equity"),
|
||||
raw.get("cur_assets"), raw.get("cur_liabilities"),
|
||||
raw.get("cash"), raw.get("lt_debt"),
|
||||
raw.get("shares"),
|
||||
)
|
||||
metrics["ticker"] = ticker
|
||||
metrics["filer_type"] = f"{taxonomy}-annual"
|
||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result[ticker] = metrics
|
||||
print(f" {ticker} ({taxonomy}) ✓")
|
||||
time.sleep(0.2) # respect SEC rate limit
|
||||
except Exception as e:
|
||||
print(f" [WARN] {ticker}: {e}")
|
||||
|
||||
print(f" Foreign filers done: {len(result)}\n")
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4 — FINRA short interest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_finra_phase(existing: dict[str, dict]) -> dict[str, dict]:
|
||||
"""
|
||||
Fetch today's (or most recent) FINRA RegSHO short-volume file and merge
|
||||
short_percent into the fundamentals dict keyed by ticker.
|
||||
Returns the updated dict.
|
||||
"""
|
||||
print("Phase 4: Fetching FINRA short interest ...")
|
||||
short_map: dict[str, float] = {}
|
||||
|
||||
for days_back in range(1, 6):
|
||||
d = date.today() - timedelta(days=days_back)
|
||||
if d.weekday() >= 5:
|
||||
continue
|
||||
url = (
|
||||
"https://cdn.finra.org/equity/regsho/daily/"
|
||||
f"CNMSshvol{d.strftime('%Y%m%d')}.txt"
|
||||
)
|
||||
try:
|
||||
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
|
||||
if resp.status_code == 404:
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
for line in resp.text.splitlines()[1:]:
|
||||
parts = line.split("|")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
ticker = parts[0].strip()
|
||||
try:
|
||||
short_vol = float(parts[1])
|
||||
total_vol = float(parts[3])
|
||||
if total_vol > 0:
|
||||
short_map[ticker] = short_vol / total_vol
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
print(f" FINRA short interest: {len(short_map)} tickers ({d})")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" [WARN] FINRA {d}: {e}")
|
||||
continue
|
||||
|
||||
if not short_map:
|
||||
print(" [WARN] FINRA short interest unavailable")
|
||||
|
||||
# Merge into existing fundamentals dict
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for ticker, ratio in short_map.items():
|
||||
if ticker in existing:
|
||||
existing[ticker]["short_percent"] = ratio
|
||||
else:
|
||||
existing[ticker] = {
|
||||
"ticker": ticker,
|
||||
"short_percent": ratio,
|
||||
"filer_type": "finra-only",
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
return existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5 — Sector statistics (median + MAD per metric per sector)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _upsert_sector_stats(rows: list[dict]) -> None:
|
||||
"""Upsert sector statistics rows into the sector_stats table."""
|
||||
_pg_upsert(f"{_TABLE_PREFIX}sector_stats", rows, conflict_col="sector")
|
||||
|
||||
|
||||
def _load_sector_mapping() -> dict[str, str]:
|
||||
"""
|
||||
Load ticker → sector mapping from the analyst_cache table that was just
|
||||
populated in Phase 1. Returns {ticker: {sector, analyst_upside, eps_forward}}.
|
||||
"""
|
||||
try:
|
||||
conn = _get_conn()
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"SELECT ticker, sector, analyst_upside, eps_forward "
|
||||
f"FROM \"{_TABLE_PREFIX}analyst_cache\" WHERE sector IS NOT NULL"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
return {r["ticker"]: dict(r) for r in rows}
|
||||
except Exception as e:
|
||||
print(f" [WARN] Could not load sector mapping: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def run_sector_stats_phase(fundamentals: dict[str, dict]) -> None:
|
||||
"""
|
||||
Compute sector-level median and MAD for every fundamental metric and upsert
|
||||
to the sector_stats table. Requires >= 15 tickers per sector.
|
||||
"""
|
||||
import statistics as _stats
|
||||
|
||||
print("Phase 5: Computing sector statistics ...")
|
||||
|
||||
analyst_map = _load_sector_mapping()
|
||||
if not analyst_map:
|
||||
print(" [WARN] No analyst/sector data — sector stats skipped\n")
|
||||
return
|
||||
|
||||
METRICS = [
|
||||
"pe_trailing", "pb_ratio", "ev_ebitda", "ev_revenue",
|
||||
"revenue_growth", "earnings_growth", "eps_growth",
|
||||
"roe", "roa", "debt_to_equity", "current_ratio",
|
||||
"profit_margin", "operating_margin", "fcf_yield",
|
||||
"analyst_upside",
|
||||
]
|
||||
MIN_TICKERS = 15
|
||||
|
||||
# Build merged per-ticker dict: fundamentals + analyst fields
|
||||
merged: dict[str, dict] = {}
|
||||
for ticker, fdata in fundamentals.items():
|
||||
adata = analyst_map.get(ticker, {})
|
||||
sector = adata.get("sector")
|
||||
if not sector or sector == "N/A":
|
||||
continue
|
||||
eps_t = fdata.get("eps_trailing")
|
||||
eps_f = adata.get("eps_forward")
|
||||
eps_growth = None
|
||||
if eps_t and eps_f and abs(eps_t) > 1e-9:
|
||||
raw_g = (eps_f - eps_t) / abs(eps_t)
|
||||
# Clamp to ±150% to avoid breakeven-crossing distortion
|
||||
eps_growth = max(-1.5, min(1.5, raw_g))
|
||||
merged[ticker] = {
|
||||
**{m: fdata.get(m) for m in METRICS},
|
||||
"eps_growth": eps_growth,
|
||||
"analyst_upside": adata.get("analyst_upside"),
|
||||
"_sector": sector,
|
||||
}
|
||||
|
||||
# Group by sector
|
||||
sector_buckets: dict[str, list[dict]] = {}
|
||||
for data in merged.values():
|
||||
s = data["_sector"]
|
||||
sector_buckets.setdefault(s, []).append(data)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
rows = []
|
||||
for sector, members in sector_buckets.items():
|
||||
if len(members) < MIN_TICKERS:
|
||||
continue
|
||||
row: dict = {"sector": sector, "ticker_count": len(members), "updated_at": now}
|
||||
for metric in METRICS:
|
||||
vals = [m[metric] for m in members
|
||||
if m.get(metric) is not None
|
||||
and isinstance(m[metric], (int, float))
|
||||
and m[metric] == m[metric]] # exclude NaN
|
||||
if len(vals) >= MIN_TICKERS:
|
||||
med = _stats.median(vals)
|
||||
mad = _stats.median([abs(v - med) for v in vals])
|
||||
row[f"{metric}_med"] = med
|
||||
row[f"{metric}_mad"] = max(float(mad), 1e-10)
|
||||
else:
|
||||
row[f"{metric}_med"] = None
|
||||
row[f"{metric}_mad"] = None
|
||||
rows.append(row)
|
||||
|
||||
if rows:
|
||||
_upsert_sector_stats(rows)
|
||||
print(f" Sector stats written for {len(rows)} sectors")
|
||||
else:
|
||||
print(" [WARN] No sectors met the minimum ticker threshold")
|
||||
|
||||
print(f" Phase 5 complete\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -303,7 +996,7 @@ def _upsert(rows: list[dict]) -> None:
|
|||
|
||||
def main() -> None:
|
||||
print("=" * 55)
|
||||
print(" ANALYST + NEWS CACHE BUILDER")
|
||||
print(" ANALYST + NEWS + FUNDAMENTALS CACHE BUILDER")
|
||||
print("=" * 55 + "\n")
|
||||
|
||||
tickers = fetch_tickers()
|
||||
|
|
@ -311,9 +1004,37 @@ def main() -> None:
|
|||
print("ERROR: No tickers fetched — aborting.")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Phase 1 — analyst consensus + forward estimates (yfinance)
|
||||
run_analyst_phase(tickers)
|
||||
|
||||
# Phase 2 — news sentiment (Google News RSS + VADER)
|
||||
run_news_phase(tickers)
|
||||
print("Cache build complete.")
|
||||
|
||||
# Phase 3 — EDGAR bulk fundamentals (domestic quarterly filers)
|
||||
fundamentals = run_fundamentals_phase(tickers)
|
||||
|
||||
# Phase 3B — foreign / annual filer fundamentals (SEC companyfacts)
|
||||
foreign = run_foreign_filers_phase()
|
||||
fundamentals.update(foreign)
|
||||
|
||||
# Phase 4 — FINRA short interest (merged into fundamentals)
|
||||
fundamentals = run_finra_phase(fundamentals)
|
||||
|
||||
# Write all fundamentals to Supabase
|
||||
print(f"Writing {len(fundamentals)} rows to fundamentals_cache ...")
|
||||
batch: list[dict] = []
|
||||
for row in fundamentals.values():
|
||||
batch.append(row)
|
||||
if len(batch) >= UPSERT_EVERY:
|
||||
_upsert_fundamentals(batch)
|
||||
batch.clear()
|
||||
if batch:
|
||||
_upsert_fundamentals(batch)
|
||||
|
||||
# Phase 5 — Sector statistics (median + MAD per metric per sector)
|
||||
run_sector_stats_phase(fundamentals)
|
||||
|
||||
print("\nCache build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
@echo off
|
||||
setlocal
|
||||
|
||||
echo ============================================================
|
||||
echo Deploy to BETA (beta branch + /srv/stock-tool-beta on VPS)
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
set /p COMMIT_MSG=Commit message:
|
||||
if "%COMMIT_MSG%"=="" (
|
||||
echo Error: Commit message cannot be empty.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [1/4] Staging all changes...
|
||||
git add -A
|
||||
|
||||
echo [2/4] Committing...
|
||||
git commit -m "%COMMIT_MSG%"
|
||||
if errorlevel 1 (
|
||||
echo Nothing to commit.
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo [3/4] Pushing to Gitea beta branch...
|
||||
git push gitea HEAD:beta
|
||||
if errorlevel 1 (
|
||||
echo Push failed. Check Gitea connection.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [4/4] Deploying to VPS...
|
||||
ssh root@87.99.133.95 "cd /srv/stock-tool-beta && git pull origin beta && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS beta deploy OK"
|
||||
if errorlevel 1 (
|
||||
echo VPS deploy failed. SSH in and check manually.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Beta deploy complete.
|
||||
echo ============================================================
|
||||
pause
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
@echo off
|
||||
setlocal
|
||||
|
||||
echo ============================================================
|
||||
echo Deploy to STABLE (main branch + /srv/stock-tool on VPS)
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
set /p COMMIT_MSG=Commit message:
|
||||
if "%COMMIT_MSG%"=="" (
|
||||
echo Error: Commit message cannot be empty.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [1/4] Staging all changes...
|
||||
git add -A
|
||||
|
||||
echo [2/4] Committing...
|
||||
git commit -m "%COMMIT_MSG%"
|
||||
if errorlevel 1 (
|
||||
echo Nothing to commit.
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo [3/4] Pushing to Gitea main branch...
|
||||
git push gitea main
|
||||
if errorlevel 1 (
|
||||
echo Push failed. Check Gitea connection.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [4/4] Deploying to VPS...
|
||||
ssh root@87.99.133.95 "cd /srv/stock-tool && git pull && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS deploy OK"
|
||||
if errorlevel 1 (
|
||||
echo VPS deploy failed. SSH in and check manually.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Stable deploy complete.
|
||||
echo ============================================================
|
||||
pause
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# ─── Discord ────────────────────────────────────────────────────────────────
|
||||
# Bot token from https://discord.com/developers/applications
|
||||
DISCORD_TOKEN=
|
||||
|
||||
# Your server's ID (right-click server icon → Copy Server ID)
|
||||
GUILD_ID=
|
||||
|
||||
# ─── Role IDs ───────────────────────────────────────────────────────────────
|
||||
# Right-click each role in Server Settings → Roles → Copy Role ID
|
||||
# User role is assigned to all subscribers; Lifetime role is also assigned for lifetime purchases
|
||||
ROLE_USER_ID=
|
||||
ROLE_LIFETIME_ID=
|
||||
|
||||
# ─── Ticket channel IDs ─────────────────────────────────────────────────────
|
||||
# Category where ticket channels are created (right-click category → Copy ID)
|
||||
TICKET_CATEGORY_ID=
|
||||
|
||||
# Channel where ticket open/close events are logged (right-click channel → Copy ID)
|
||||
TICKET_LOG_CHANNEL_ID=
|
||||
|
||||
# ─── Stripe ─────────────────────────────────────────────────────────────────
|
||||
# From https://dashboard.stripe.com/apikeys
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
|
||||
# From https://dashboard.stripe.com/webhooks (signing secret for this endpoint)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Where Stripe redirects after payment (can be a thank-you page or Discord DM link)
|
||||
STRIPE_SUCCESS_URL=https://discord.com/channels/@me
|
||||
STRIPE_CANCEL_URL=https://discord.com/channels/@me
|
||||
|
||||
# ─── Supabase ───────────────────────────────────────────────────────────────
|
||||
SUPABASE_URL=https://yeispcpmepjelfbhfkwr.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# ─── Webhook server ─────────────────────────────────────────────────────────
|
||||
# Port the bot listens on for incoming Stripe webhook POSTs
|
||||
# Forward this via ngrok (dev) or expose via your server's firewall (prod)
|
||||
WEBHOOK_PORT=8080
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Discord Sales & Support Bot — Ultimate Investment Tool
|
||||
=======================================================
|
||||
Features:
|
||||
/setup → (Admin only) Posts the persistent sales embed with Purchase &
|
||||
Support buttons into the current channel. Run this once in your
|
||||
read-only sales channel.
|
||||
Purchase button → Stripe Checkout (weekly $10 / monthly $25 / lifetime $150)
|
||||
License key auto-generated + DM'd on successful payment
|
||||
Role assigned automatically in the server
|
||||
Support button → Modal prompts for a subject, then opens a private ticket
|
||||
channel with a Close button and audit log
|
||||
|
||||
Setup: copy .env.example → .env and fill in all values before running.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import requests
|
||||
import stripe
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Configuration (all values from .env)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
|
||||
GUILD_ID = int(os.getenv("GUILD_ID", "0"))
|
||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
|
||||
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
||||
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
||||
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
||||
|
||||
# Discord IDs — right-click channel/role → Copy ID (needs Developer Mode enabled)
|
||||
TICKET_CATEGORY_ID = int(os.getenv("TICKET_CATEGORY_ID", "0"))
|
||||
TICKET_LOG_CHANNEL_ID = int(os.getenv("TICKET_LOG_CHANNEL_ID", "0"))
|
||||
ROLE_USER_ID = int(os.getenv("ROLE_USER_ID", "0"))
|
||||
ROLE_LIFETIME_ID = int(os.getenv("ROLE_LIFETIME_ID", "0"))
|
||||
|
||||
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "8080"))
|
||||
|
||||
# Pricing tiers
|
||||
PRICES = {
|
||||
"weekly": {"amount": 1000, "label": "$10 / week", "days": 7},
|
||||
"monthly": {"amount": 2500, "label": "$25 / month", "days": 30},
|
||||
"lifetime": {"amount": 15000, "label": "$150 lifetime", "days": None},
|
||||
}
|
||||
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
|
||||
_SUPABASE_HEADERS = {
|
||||
"apikey": SUPABASE_SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# License helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _generate_key() -> str:
|
||||
parts = [uuid.uuid4().hex[:8].upper() for _ in range(3)]
|
||||
return "UIT-" + "-".join(parts)
|
||||
|
||||
|
||||
def issue_license(tier: str, notes: str = "") -> str:
|
||||
"""Create a new license in Supabase and return the key."""
|
||||
days = PRICES[tier]["days"]
|
||||
expiry = None
|
||||
if days is not None:
|
||||
expiry = (datetime.datetime.utcnow() + datetime.timedelta(days=days)).isoformat() + "Z"
|
||||
|
||||
key = _generate_key()
|
||||
payload = {
|
||||
"license_key": key,
|
||||
"tier": tier,
|
||||
"expiry_date": expiry,
|
||||
"active": True,
|
||||
"machines_allowed": 1,
|
||||
"notes": notes,
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{SUPABASE_URL}/rest/v1/licenses",
|
||||
headers=_SUPABASE_HEADERS,
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return key
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stripe helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_checkout_session(tier: str, discord_user_id: str, discord_username: str) -> str:
|
||||
"""Create a Stripe Checkout Session and return its URL."""
|
||||
price_info = PRICES[tier]
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[{
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"unit_amount": price_info["amount"],
|
||||
"product_data": {
|
||||
"name": f"Ultimate Investment Tool — {price_info['label']}",
|
||||
},
|
||||
},
|
||||
"quantity": 1,
|
||||
}],
|
||||
mode="payment",
|
||||
success_url=os.getenv("STRIPE_SUCCESS_URL", "https://discord.com/channels/@me"),
|
||||
cancel_url=os.getenv("STRIPE_CANCEL_URL", "https://discord.com/channels/@me"),
|
||||
metadata={
|
||||
"discord_user_id": discord_user_id,
|
||||
"discord_username": discord_username,
|
||||
"tier": tier,
|
||||
},
|
||||
)
|
||||
return session.url
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Bot setup
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.members = True
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
tree = bot.tree
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Ticket creation helper (shared by button and any future commands)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_ticket(interaction: discord.Interaction, subject: str):
|
||||
guild = interaction.guild
|
||||
category = guild.get_channel(TICKET_CATEGORY_ID)
|
||||
|
||||
overwrites = {
|
||||
guild.default_role: discord.PermissionOverwrite(view_channel=False),
|
||||
interaction.user: discord.PermissionOverwrite(
|
||||
view_channel=True,
|
||||
send_messages=True,
|
||||
read_message_history=True,
|
||||
),
|
||||
}
|
||||
|
||||
for role in guild.roles:
|
||||
if role.permissions.administrator or role.permissions.manage_channels:
|
||||
overwrites[role] = discord.PermissionOverwrite(
|
||||
view_channel=True,
|
||||
send_messages=True,
|
||||
read_message_history=True,
|
||||
manage_channels=True,
|
||||
)
|
||||
|
||||
safe_name = "".join(c for c in interaction.user.name if c.isalnum() or c in "-_")[:20]
|
||||
channel_name = f"ticket-{safe_name}"
|
||||
|
||||
if category:
|
||||
existing = discord.utils.get(category.text_channels, name=channel_name)
|
||||
if existing:
|
||||
await interaction.followup.send(
|
||||
f"You already have an open ticket: {existing.mention}\n"
|
||||
"Please use that channel or close it before opening a new one.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
channel = await guild.create_text_channel(
|
||||
name=channel_name,
|
||||
category=category,
|
||||
overwrites=overwrites,
|
||||
topic=f"Ticket by {interaction.user} ({interaction.user.id}) | {subject}",
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Support Ticket — {subject}",
|
||||
description=(
|
||||
f"Welcome {interaction.user.mention}!\n\n"
|
||||
"Please describe your issue in as much detail as possible.\n"
|
||||
"A staff member will be with you shortly.\n\n"
|
||||
"Press **🔒 Close Ticket** when your issue is resolved."
|
||||
),
|
||||
color=discord.Color.blurple(),
|
||||
timestamp=discord.utils.utcnow(),
|
||||
)
|
||||
embed.set_footer(text=str(interaction.user), icon_url=interaction.user.display_avatar.url)
|
||||
await channel.send(embed=embed, view=TicketCloseView())
|
||||
|
||||
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
|
||||
if log_channel:
|
||||
log_embed = discord.Embed(
|
||||
title="Ticket Opened",
|
||||
description=(
|
||||
f"**User:** {interaction.user.mention} (`{interaction.user.id}`)\n"
|
||||
f"**Channel:** {channel.mention}\n"
|
||||
f"**Subject:** {subject}"
|
||||
),
|
||||
color=discord.Color.green(),
|
||||
timestamp=discord.utils.utcnow(),
|
||||
)
|
||||
await log_channel.send(embed=log_embed)
|
||||
|
||||
await interaction.followup.send(
|
||||
f"Your ticket has been created: {channel.mention}", ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Persistent Views (survive bot restarts)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TicketCloseView(discord.ui.View):
|
||||
"""Close button that lives permanently in ticket channels."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(timeout=None)
|
||||
|
||||
@discord.ui.button(
|
||||
label="🔒 Close Ticket",
|
||||
style=discord.ButtonStyle.danger,
|
||||
custom_id="ticket_close",
|
||||
)
|
||||
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
channel = interaction.channel
|
||||
guild = interaction.guild
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Ticket closed by {interaction.user.mention}. Channel will be deleted in 5 seconds.",
|
||||
ephemeral=False,
|
||||
)
|
||||
|
||||
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
|
||||
if log_channel:
|
||||
embed = discord.Embed(
|
||||
title="Ticket Closed",
|
||||
description=(
|
||||
f"**Channel:** {channel.name}\n"
|
||||
f"**Closed by:** {interaction.user.mention}\n"
|
||||
f"**Topic:** {channel.topic or 'N/A'}"
|
||||
),
|
||||
color=discord.Color.red(),
|
||||
timestamp=discord.utils.utcnow(),
|
||||
)
|
||||
await log_channel.send(embed=embed)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
await channel.delete(reason=f"Ticket closed by {interaction.user}")
|
||||
|
||||
|
||||
class TierSelectView(discord.ui.View):
|
||||
"""Three buttons for selecting a pricing tier — shown ephemerally after clicking Purchase."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(timeout=120)
|
||||
|
||||
async def _handle(self, interaction: discord.Interaction, tier: str):
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
try:
|
||||
url = create_checkout_session(
|
||||
tier,
|
||||
str(interaction.user.id),
|
||||
str(interaction.user),
|
||||
)
|
||||
price_label = PRICES[tier]["label"]
|
||||
embed = discord.Embed(
|
||||
title="Complete Your Purchase",
|
||||
description=(
|
||||
f"You selected the **{price_label}** plan.\n\n"
|
||||
f"[**→ Pay securely via Stripe**]({url})\n\n"
|
||||
"Your license key will be **DM'd to you instantly** after payment.\n"
|
||||
"The checkout link is valid for **24 hours**."
|
||||
),
|
||||
color=discord.Color.green(),
|
||||
)
|
||||
embed.set_footer(text="Powered by Stripe — we never store your card details.")
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
except Exception as exc:
|
||||
await interaction.followup.send(
|
||||
f"❌ Could not create a checkout session. Please try again later.\n`{exc}`",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
@discord.ui.button(label="$10 / Week", style=discord.ButtonStyle.primary, custom_id="buy_weekly")
|
||||
async def weekly(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await self._handle(interaction, "weekly")
|
||||
|
||||
@discord.ui.button(label="$25 / Month", style=discord.ButtonStyle.primary, custom_id="buy_monthly")
|
||||
async def monthly(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await self._handle(interaction, "monthly")
|
||||
|
||||
@discord.ui.button(label="$150 Lifetime", style=discord.ButtonStyle.success, custom_id="buy_lifetime")
|
||||
async def lifetime(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await self._handle(interaction, "lifetime")
|
||||
|
||||
|
||||
class TicketModal(discord.ui.Modal, title="Open a Support Ticket"):
|
||||
"""Modal that collects a subject before creating the ticket channel."""
|
||||
|
||||
subject = discord.ui.TextInput(
|
||||
label="Subject",
|
||||
placeholder="Brief description of your issue",
|
||||
max_length=100,
|
||||
required=True,
|
||||
)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
await create_ticket(interaction, self.subject.value)
|
||||
|
||||
|
||||
class SalesView(discord.ui.View):
|
||||
"""
|
||||
Persistent view with Purchase and Support buttons.
|
||||
Posted once by /setup into the read-only sales channel.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(timeout=None)
|
||||
|
||||
@discord.ui.button(
|
||||
label="Purchase",
|
||||
style=discord.ButtonStyle.success,
|
||||
custom_id="sales_purchase",
|
||||
emoji="🛒",
|
||||
)
|
||||
async def purchase(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
embed = discord.Embed(
|
||||
title="Ultimate Investment Tool — Pricing",
|
||||
description=(
|
||||
"Select a tier below. Payment is handled securely via **Stripe**.\n"
|
||||
"Your license key will be delivered to your **DMs** the moment payment clears."
|
||||
),
|
||||
color=discord.Color.gold(),
|
||||
)
|
||||
embed.add_field(name="Weekly", value="**$10** / 7 days", inline=True)
|
||||
embed.add_field(name="Monthly", value="**$25** / 30 days", inline=True)
|
||||
embed.add_field(name="Lifetime", value="**$150** one-time", inline=True)
|
||||
embed.set_footer(text="Having trouble? Click the Support button to open a ticket.")
|
||||
await interaction.response.send_message(embed=embed, view=TierSelectView(), ephemeral=True)
|
||||
|
||||
@discord.ui.button(
|
||||
label="Support",
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id="sales_support",
|
||||
emoji="🎫",
|
||||
)
|
||||
async def support(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
await interaction.response.send_modal(TicketModal())
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Admin slash command — post the sales embed
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@tree.command(name="setup", description="Post the sales & support embed (admin only)")
|
||||
@app_commands.checks.has_permissions(administrator=True)
|
||||
async def setup(interaction: discord.Interaction):
|
||||
embed = discord.Embed(
|
||||
title="Ultimate Investment Tool",
|
||||
description=(
|
||||
"Click on the **Purchase** button to purchase a license. "
|
||||
"Click on the **Support** button to create a support ticket. "
|
||||
"Support tickets are checked by our staff, and can take time to get a response. "
|
||||
"Please be patient when waiting for staff to respond."
|
||||
),
|
||||
color=discord.Color.gold(),
|
||||
)
|
||||
await interaction.channel.send(embed=embed, view=SalesView())
|
||||
await interaction.response.send_message("Sales embed posted.", ephemeral=True)
|
||||
|
||||
|
||||
@setup.error
|
||||
async def setup_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
|
||||
if isinstance(error, app_commands.MissingPermissions):
|
||||
await interaction.response.send_message(
|
||||
"You need Administrator permission to use this command.", ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# On-ready
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
# Re-register all persistent views so buttons work after restarts
|
||||
bot.add_view(SalesView())
|
||||
bot.add_view(TicketCloseView())
|
||||
|
||||
# Sync slash commands to the guild instantly (no 1-hour global wait)
|
||||
guild_obj = discord.Object(id=GUILD_ID)
|
||||
tree.copy_global_to(guild=guild_obj)
|
||||
await tree.sync(guild=guild_obj)
|
||||
|
||||
print(f"[Bot] Online as {bot.user} | Guild {GUILD_ID} | Slash commands synced")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stripe webhook (aiohttp server on WEBHOOK_PORT)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _handle_stripe_webhook(request: web.Request) -> web.Response:
|
||||
payload = await request.read()
|
||||
sig_header = request.headers.get("stripe-signature", "")
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
|
||||
except stripe.error.SignatureVerificationError:
|
||||
print("[Webhook] Invalid Stripe signature — request rejected")
|
||||
return web.Response(status=400, text="Invalid signature")
|
||||
except Exception as exc:
|
||||
print(f"[Webhook] Error parsing event: {exc}")
|
||||
return web.Response(status=400, text=str(exc))
|
||||
|
||||
if event["type"] == "checkout.session.completed":
|
||||
session = event["data"]["object"]
|
||||
metadata = session.get("metadata", {})
|
||||
|
||||
discord_user_id = metadata.get("discord_user_id")
|
||||
discord_username = metadata.get("discord_username", "Unknown")
|
||||
tier = metadata.get("tier")
|
||||
|
||||
if not discord_user_id or tier not in PRICES:
|
||||
print(f"[Webhook] Missing/invalid metadata: {metadata}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
# 1. Issue license in Supabase
|
||||
try:
|
||||
key = issue_license(
|
||||
tier,
|
||||
notes=f"Discord: {discord_username} ({discord_user_id})",
|
||||
)
|
||||
print(f"[License] Issued {key} ({tier}) for Discord user {discord_user_id}")
|
||||
except Exception as exc:
|
||||
print(f"[License] Failed to issue for {discord_user_id}: {exc}")
|
||||
return web.Response(status=500, text="License issuance failed")
|
||||
|
||||
# 2. DM the license key to the buyer
|
||||
price_info = PRICES[tier]
|
||||
expiry_str = (
|
||||
"Never (lifetime)"
|
||||
if price_info["days"] is None
|
||||
else f"{price_info['days']} days from today"
|
||||
)
|
||||
try:
|
||||
user = await bot.fetch_user(int(discord_user_id))
|
||||
embed = discord.Embed(
|
||||
title="🎉 Purchase Confirmed — Ultimate Investment Tool",
|
||||
description=(
|
||||
f"Thank you for your purchase!\n\n"
|
||||
f"**Your License Key:**\n```\n{key}\n```\n"
|
||||
f"**Plan:** {price_info['label']}\n"
|
||||
f"**Expires:** {expiry_str}\n\n"
|
||||
"Paste this key into the application when prompted.\n"
|
||||
"⚠️ This key is locked to one machine — keep it private."
|
||||
),
|
||||
color=discord.Color.green(),
|
||||
)
|
||||
embed.set_footer(text="Need help? Click the Support button in the server.")
|
||||
await user.send(embed=embed)
|
||||
print(f"[Bot] License DM'd to {discord_username} ({discord_user_id})")
|
||||
except discord.Forbidden:
|
||||
print(f"[Bot] Cannot DM {discord_user_id} — DMs may be disabled.")
|
||||
except Exception as exc:
|
||||
print(f"[Bot] DM error for {discord_user_id}: {exc}")
|
||||
|
||||
# 3. Assign roles in the guild
|
||||
try:
|
||||
guild = bot.get_guild(GUILD_ID)
|
||||
if guild:
|
||||
member = await guild.fetch_member(int(discord_user_id))
|
||||
roles_to_add = []
|
||||
|
||||
user_role = guild.get_role(ROLE_USER_ID)
|
||||
if user_role:
|
||||
roles_to_add.append(user_role)
|
||||
|
||||
if tier == "lifetime":
|
||||
lifetime_role = guild.get_role(ROLE_LIFETIME_ID)
|
||||
if lifetime_role:
|
||||
roles_to_add.append(lifetime_role)
|
||||
|
||||
if roles_to_add:
|
||||
await member.add_roles(*roles_to_add, reason=f"Purchased {tier} via Stripe")
|
||||
names = ", ".join(r.name for r in roles_to_add)
|
||||
print(f"[Bot] Assigned roles '{names}' to {discord_username}")
|
||||
else:
|
||||
print(f"[Bot] No valid roles found to assign")
|
||||
except discord.NotFound:
|
||||
print(f"[Bot] Member {discord_user_id} not found in guild (may have left)")
|
||||
except Exception as exc:
|
||||
print(f"[Bot] Role assignment error for {discord_user_id}: {exc}")
|
||||
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
async def _start_webhook_server():
|
||||
app = web.Application()
|
||||
app.router.add_post("/stripe/webhook", _handle_stripe_webhook)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT)
|
||||
await site.start()
|
||||
print(f"[Webhook] Stripe webhook server listening on :{WEBHOOK_PORT}/stripe/webhook")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Entry point
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
async with bot:
|
||||
await _start_webhook_server()
|
||||
await bot.start(DISCORD_TOKEN)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
discord.py>=2.3.0
|
||||
stripe>=7.0.0
|
||||
aiohttp>=3.9.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
|
|
@ -43,7 +43,9 @@ except Exception:
|
|||
|
||||
from stock_screener import (INDEX_CHOICES, STRATEGY_PRESETS, WEIGHTS,
|
||||
collect_tickers, fetch_all, score_stocks,
|
||||
get_news_headlines, fetch_insider_trades)
|
||||
_load_sector_stats,
|
||||
get_news_headlines, fetch_insider_trades,
|
||||
fetch_hedge_fund_filings)
|
||||
|
||||
# Short descriptions shown as tooltips when hovering strategy dropdown items
|
||||
STRATEGY_DESCRIPTIONS = {
|
||||
|
|
@ -782,6 +784,9 @@ class ScreenerApp(tk.Tk):
|
|||
self._sort_col = None
|
||||
self._sort_asc = False
|
||||
self._df_raw = None # unscored raw data — kept for re-scoring on strategy change
|
||||
self._sector_stats = {} # cached sector stats — loaded once per screen run
|
||||
self._rescoring = False # True while a strategy re-score thread is running
|
||||
self._rescore_id = 0 # incremented on each re-score to discard stale results
|
||||
self._info_popup = None
|
||||
self._selected_ticker = None
|
||||
self._score_bars: dict = {}
|
||||
|
|
@ -800,8 +805,8 @@ class ScreenerApp(tk.Tk):
|
|||
self._filters_window = None
|
||||
|
||||
self._apply_styles()
|
||||
self._build_toolbar()
|
||||
self._build_tab_bar()
|
||||
self._build_toolbar()
|
||||
self._build_progress_bar()
|
||||
self._build_main_pane()
|
||||
self._build_status_bar()
|
||||
|
|
@ -918,20 +923,11 @@ class ScreenerApp(tk.Tk):
|
|||
def _build_toolbar(self):
|
||||
outer = tk.Frame(self, bg=T["bg"])
|
||||
outer.pack(fill="x", side="top")
|
||||
self._toolbar_frame = outer
|
||||
bar = tk.Frame(outer, bg=T["bg"], pady=_s(9))
|
||||
bar.pack(fill="x", padx=_s(12))
|
||||
tk.Frame(outer, bg=T["border"], height=1).pack(fill="x")
|
||||
|
||||
# Logo / title
|
||||
tk.Label(bar, text=f" {_APP_NAME.upper()}", bg=T["bg"], fg=T["accent"],
|
||||
font=("Segoe UI", 13, "bold")).pack(side="left", padx=(0, _s(4)))
|
||||
tk.Label(bar, text=f"v{_APP_VERSION}", bg=T["bg"], fg=T["fg2"],
|
||||
font=("Segoe UI", 9)).pack(side="left", padx=(0, _s(6)))
|
||||
_channel_badge_color = T["yellow"] if _CHANNEL == "beta" else T["accent"]
|
||||
tk.Label(bar, text=_channel_label.upper(), bg=_channel_badge_color, fg="#000000",
|
||||
font=("Segoe UI", 8, "bold"), padx=_s(6), pady=_s(2)
|
||||
).pack(side="left", padx=(0, _s(14)))
|
||||
|
||||
self._run_btn = _RoundedButton(bar, "▶ Run", self._on_run,
|
||||
width=_s(100), height=_s(34), radius=_s(10))
|
||||
self._run_btn.pack(side="left", padx=(0, _s(6)))
|
||||
|
|
@ -950,8 +946,8 @@ class ScreenerApp(tk.Tk):
|
|||
|
||||
tk.Label(bar, text="Show", bg=T["bg"], fg=T["fg2"],
|
||||
font=T["font_small"]).pack(side="left")
|
||||
self._top_var = tk.StringVar(value="50")
|
||||
_RoundedDropdown(bar, self._top_var, ["50", "100", "150"],
|
||||
self._top_var = tk.StringVar(value="100")
|
||||
_RoundedDropdown(bar, self._top_var, ["100", "200", "300", "400", "500", "All"],
|
||||
width=_s(68), height=_s(28),
|
||||
on_change=self._apply_filters
|
||||
).pack(side="left", padx=(_s(6), 0))
|
||||
|
|
@ -993,11 +989,6 @@ class ScreenerApp(tk.Tk):
|
|||
width=_s(100), height=_s(30), radius=_s(8)
|
||||
).pack(side="left", padx=(0, _s(4)))
|
||||
|
||||
_RoundedButton(bar, "?", self._open_about_dialog,
|
||||
width=_s(30), height=_s(30), radius=_s(8),
|
||||
bg=T["bg3"], fg=T["fg2"], border=T["bg3"]
|
||||
).pack(side="right", padx=(_s(4), 0))
|
||||
|
||||
self._lastrun_var = tk.StringVar(value="")
|
||||
tk.Label(bar, textvariable=self._lastrun_var, bg=T["bg"], fg=T["fg2"],
|
||||
font=("Segoe UI", 9)).pack(side="right")
|
||||
|
|
@ -1013,6 +1004,22 @@ class ScreenerApp(tk.Tk):
|
|||
bar.pack(fill="x", padx=_s(12), pady=0)
|
||||
tk.Frame(outer, bg=T["border"], height=1).pack(fill="x")
|
||||
|
||||
# Help button — always visible, flush right
|
||||
_RoundedButton(bar, "?", self._open_about_dialog,
|
||||
width=_s(30), height=_s(30), radius=_s(8),
|
||||
bg=T["bg3"], fg=T["fg2"], border=T["bg3"]
|
||||
).pack(side="right", pady=_s(8), padx=(_s(4), 0))
|
||||
|
||||
# App title / version / channel — always visible on the left
|
||||
tk.Label(bar, text=f" {_APP_NAME.upper()}", bg=T["bg"], fg=T["accent"],
|
||||
font=("Segoe UI", 13, "bold")).pack(side="left", padx=(0, _s(4)))
|
||||
tk.Label(bar, text=f"v{_APP_VERSION}", bg=T["bg"], fg=T["fg2"],
|
||||
font=("Segoe UI", 9)).pack(side="left", padx=(0, _s(6)))
|
||||
_channel_badge_color = T["yellow"] if _CHANNEL == "beta" else T["accent"]
|
||||
tk.Label(bar, text=_channel_label.upper(), bg=_channel_badge_color, fg="#000000",
|
||||
font=("Segoe UI", 8, "bold"), padx=_s(6), pady=_s(2)
|
||||
).pack(side="left", padx=(0, _s(14)))
|
||||
|
||||
self._tab_btn_refs = {}
|
||||
_tab_cmds = {
|
||||
"Screener": self._show_screener_tab,
|
||||
|
|
@ -1044,10 +1051,12 @@ class ScreenerApp(tk.Tk):
|
|||
self._search_view.pack_forget()
|
||||
if hasattr(self, "_tracking_view") and self._tracking_view is not None:
|
||||
self._tracking_view.pack_forget()
|
||||
self._toolbar_frame.pack(fill="x", side="top", before=self._progress_bar)
|
||||
self._main_frame.pack(fill="both", expand=True, padx=10, pady=(6, 10))
|
||||
self._update_tab_active("Screener")
|
||||
|
||||
def _show_search_tab(self):
|
||||
self._toolbar_frame.pack_forget()
|
||||
self._main_frame.pack_forget()
|
||||
if hasattr(self, "_tracking_view") and self._tracking_view is not None:
|
||||
self._tracking_view.pack_forget()
|
||||
|
|
@ -1055,6 +1064,7 @@ class ScreenerApp(tk.Tk):
|
|||
self._search_view = _SearchTabView(
|
||||
self, df=self._df, df_raw=self._df_raw,
|
||||
get_weights=self._get_current_weights,
|
||||
get_sector_stats=lambda: self._sector_stats,
|
||||
)
|
||||
else:
|
||||
self._search_view.update_df(self._df, self._df_raw)
|
||||
|
|
@ -1062,6 +1072,7 @@ class ScreenerApp(tk.Tk):
|
|||
self._update_tab_active("Search")
|
||||
|
||||
def _show_tracking_tab(self):
|
||||
self._toolbar_frame.pack_forget()
|
||||
self._main_frame.pack_forget()
|
||||
if hasattr(self, "_search_view") and self._search_view is not None:
|
||||
self._search_view.pack_forget()
|
||||
|
|
@ -1283,9 +1294,10 @@ class ScreenerApp(tk.Tk):
|
|||
|
||||
def _build_progress_bar(self):
|
||||
self._progress_var = tk.DoubleVar(value=0)
|
||||
ttk.Progressbar(self, variable=self._progress_var, maximum=100,
|
||||
self._progress_bar = ttk.Progressbar(self, variable=self._progress_var, maximum=100,
|
||||
style="Progress.Horizontal.TProgressbar",
|
||||
mode="determinate").pack(fill="x", side="top")
|
||||
mode="determinate")
|
||||
self._progress_bar.pack(fill="x", side="top")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main pane — horizontal split: ticker list | tabbed detail #
|
||||
|
|
@ -1332,36 +1344,56 @@ class ScreenerApp(tk.Tk):
|
|||
("Sector", 140, "w"),
|
||||
]
|
||||
|
||||
def _get_top_n(self) -> int:
|
||||
def _get_top_n(self):
|
||||
v = self._top_var.get()
|
||||
return int(v)
|
||||
return None if v == "All" else int(v)
|
||||
|
||||
def _on_strategy_change(self, _event=None):
|
||||
"""Re-score the fetched data with the selected strategy's weights, then re-filter."""
|
||||
"""Re-score the fetched data with the selected strategy's weights in a background thread."""
|
||||
if self._rescoring:
|
||||
return # Ignore if a rescore is already running
|
||||
strategy = self._strategy_var.get()
|
||||
if strategy == "Custom...":
|
||||
self._open_custom_strategy_dialog()
|
||||
return
|
||||
|
||||
# Re-score the main screener data if it is loaded
|
||||
if self._df_raw is not None:
|
||||
weights = STRATEGY_PRESETS.get(strategy)
|
||||
self._df = score_stocks(self._df_raw, weights=weights)
|
||||
self._apply_filters()
|
||||
self._status_var.set(f" Strategy: {strategy} — rescored {len(self._df)} stocks.")
|
||||
# Refresh the detail panel if a stock is currently selected
|
||||
selected = getattr(self, "_selected_ticker", None)
|
||||
if selected is not None and self._df is not None:
|
||||
match = self._df[self._df["ticker"] == selected]
|
||||
if not match.empty:
|
||||
self._update_detail(match.iloc[0])
|
||||
weights = STRATEGY_PRESETS.get(strategy)
|
||||
self._rescoring = True
|
||||
self._rescore_id += 1
|
||||
current_id = self._rescore_id
|
||||
self._status_var.set(f" Rescoring with strategy: {strategy}…")
|
||||
|
||||
# Always refresh the search tab — it works independently of screener data
|
||||
# (live-fetched results re-score using get_weights, df results use updated df)
|
||||
sv = getattr(self, "_search_view", None)
|
||||
if sv is not None:
|
||||
sv.update_df(self._df, self._df_raw)
|
||||
sv.refresh_current_result()
|
||||
def _rescore():
|
||||
result = score_stocks(
|
||||
self._df_raw, weights=weights,
|
||||
sector_stats=self._sector_stats,
|
||||
)
|
||||
def _done():
|
||||
self._rescoring = False
|
||||
if self._rescore_id != current_id:
|
||||
return # Superseded by a newer rescore — discard
|
||||
self._df = result
|
||||
self._apply_filters()
|
||||
self._status_var.set(
|
||||
f" Strategy: {strategy} — rescored {len(self._df)} stocks.")
|
||||
selected = getattr(self, "_selected_ticker", None)
|
||||
if selected is not None and self._df is not None:
|
||||
match = self._df[self._df["ticker"] == selected]
|
||||
if not match.empty:
|
||||
self._update_detail(match.iloc[0])
|
||||
sv = getattr(self, "_search_view", None)
|
||||
if sv is not None:
|
||||
sv.update_df(self._df, self._df_raw)
|
||||
sv.refresh_current_result()
|
||||
self.after(0, _done)
|
||||
|
||||
threading.Thread(target=_rescore, daemon=True).start()
|
||||
else:
|
||||
sv = getattr(self, "_search_view", None)
|
||||
if sv is not None:
|
||||
sv.update_df(self._df, self._df_raw)
|
||||
sv.refresh_current_result()
|
||||
|
||||
def _get_current_weights(self):
|
||||
"""Return the weight dict for the currently selected strategy."""
|
||||
|
|
@ -1463,10 +1495,10 @@ class ScreenerApp(tk.Tk):
|
|||
# "Score ↓" keeps the composite_score order from score_stocks
|
||||
|
||||
top_n = self._get_top_n()
|
||||
self._populate_table(df.head(top_n))
|
||||
self._populate_table(df if top_n is None else df.head(top_n))
|
||||
|
||||
# Update status
|
||||
n_shown = min(len(df), top_n)
|
||||
n_shown = len(df) if top_n is None else min(len(df), top_n)
|
||||
total = len(df)
|
||||
parts = []
|
||||
sector = self._sector_var.get()
|
||||
|
|
@ -1587,6 +1619,13 @@ class ScreenerApp(tk.Tk):
|
|||
self._detail_title.pack(fill="x", side="top")
|
||||
tk.Frame(parent, bg=T["border"], height=1).pack(fill="x")
|
||||
|
||||
# No-data notice — shown when _data_source == "none"
|
||||
self._no_data_notice = tk.Label(
|
||||
parent,
|
||||
text=" ⚠ No fundamental data found for this ticker — scores based on price & momentum only.",
|
||||
bg="#1a1200", fg=T["yellow"], font=T["font_small"],
|
||||
anchor="w", pady=6, padx=16)
|
||||
|
||||
# Notebook — hidden until a ticker is selected
|
||||
self._notebook = _CustomNotebook(parent)
|
||||
self._notebook.pack(fill="both", expand=True, padx=0, pady=0)
|
||||
|
|
@ -2497,6 +2536,30 @@ class ScreenerApp(tk.Tk):
|
|||
|
||||
# ── NEWS HIGHLIGHTS ───────────────────────────────────────────────
|
||||
headlines = row.get("news_headlines") or []
|
||||
if not headlines and not row.get("_news_fetched"):
|
||||
# Headlines not yet fetched (bulk screener path skips live fetch).
|
||||
# Show a placeholder and fetch in the background; re-render on done.
|
||||
ins("━" * 56 + "\n", "heading")
|
||||
ins(" NEWS HIGHLIGHTS\n", "heading")
|
||||
ins("━" * 56 + "\n\n", "heading")
|
||||
ins(" Fetching recent news...\n\n", "dim")
|
||||
_ticker = row.get("ticker", "")
|
||||
_name = row.get("name", "")
|
||||
if _ticker:
|
||||
def _fetch_news(_r=row, _t=_ticker, _n=_name):
|
||||
try:
|
||||
import yfinance as _yf
|
||||
_, _hl = get_news_headlines(
|
||||
_yf.Ticker(_t), ticker=_t, company_name=_n)
|
||||
_r["news_headlines"] = _hl
|
||||
except Exception:
|
||||
_r["news_headlines"] = None
|
||||
_r["_news_fetched"] = True
|
||||
try:
|
||||
self.after(0, lambda: self._update_commentary(_r))
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_fetch_news, daemon=True).start()
|
||||
if headlines:
|
||||
ins("━" * 56 + "\n", "heading")
|
||||
ins(" NEWS HIGHLIGHTS\n", "heading")
|
||||
|
|
@ -2920,7 +2983,7 @@ class ScreenerApp(tk.Tk):
|
|||
"pe_forward": _flt,
|
||||
"pe_trailing": _flt,
|
||||
"pb_ratio": _flt,
|
||||
"book_value": _price,
|
||||
"book_value": _mcap,
|
||||
"ev_ebitda": _flt,
|
||||
"52w_high": _price,
|
||||
"52w_low": _price,
|
||||
|
|
@ -2963,10 +3026,37 @@ class ScreenerApp(tk.Tk):
|
|||
"operating_margin", "fcf_yield", "analyst_upside", "dividend_yield",
|
||||
}
|
||||
|
||||
_ANALYST_KEYS = {
|
||||
"pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
||||
"analyst_count", "analyst_target", "recommendation",
|
||||
}
|
||||
_FUND_KEYS = {
|
||||
"market_cap", "pe_trailing", "pb_ratio", "book_value", "ev_ebitda",
|
||||
"ev_revenue", "eps_trailing", "revenue", "revenue_growth",
|
||||
"earnings_growth", "roe", "roa", "debt_to_equity", "total_debt",
|
||||
"total_cash", "current_ratio", "profit_margin", "operating_margin",
|
||||
"fcf_yield", "shares_outstanding",
|
||||
}
|
||||
|
||||
def _metric_reason(key: str) -> str:
|
||||
if key in _ANALYST_KEYS:
|
||||
count = row.get("analyst_count")
|
||||
if _nan(count):
|
||||
return "no analyst coverage"
|
||||
elif key in _FUND_KEYS:
|
||||
filer = row.get("filer_type")
|
||||
if filer in (None, "none", "finra-only"):
|
||||
return "no EDGAR data"
|
||||
return ""
|
||||
|
||||
for key, lbl in self._detail_labels.items():
|
||||
fmt = _FORMATTERS.get(key, lambda v: _flt(v))
|
||||
val = _derived.get(key, row.get(key))
|
||||
text = fmt(val)
|
||||
if text == "—":
|
||||
reason = _metric_reason(key)
|
||||
if reason:
|
||||
text = f"— ({reason})"
|
||||
fg = T["fg"]
|
||||
if key in _GREEN_KEYS and not _nan(val):
|
||||
fg = T["green"] if val >= 0 else T["red"]
|
||||
|
|
@ -2991,6 +3081,13 @@ class ScreenerApp(tk.Tk):
|
|||
self._update_score_bars(row)
|
||||
self._update_commentary(row)
|
||||
|
||||
# Show/hide the no-data banner
|
||||
if str(row.get("_data_source", "")) == "none":
|
||||
self._no_data_notice.pack(fill="x", side="top",
|
||||
before=self._notebook)
|
||||
else:
|
||||
self._no_data_notice.pack_forget()
|
||||
|
||||
# Trigger chart update when ticker changes
|
||||
new_ticker = row["ticker"]
|
||||
if new_ticker != self._chart_ticker:
|
||||
|
|
@ -3114,7 +3211,8 @@ class ScreenerApp(tk.Tk):
|
|||
self._strategy_var.set("Custom...")
|
||||
dlg.destroy()
|
||||
if self._df_raw is not None:
|
||||
self._df = score_stocks(self._df_raw, weights=w)
|
||||
self._df = score_stocks(self._df_raw, weights=w,
|
||||
sector_stats=self._sector_stats)
|
||||
self._apply_filters()
|
||||
self._status_var.set(
|
||||
f" Strategy: Custom — rescored {len(self._df)} stocks.")
|
||||
|
|
@ -3224,6 +3322,12 @@ class ScreenerApp(tk.Tk):
|
|||
tk.Label(bar, textvariable=self._status_var,
|
||||
bg=T["bg"], fg=T["fg2"], font=("Segoe UI", 9),
|
||||
anchor="w", padx=12).pack(side="left")
|
||||
# Right-aligned persistent cache status (updated each time a screen runs)
|
||||
self._cache_lbl_var = tk.StringVar(value="")
|
||||
self._cache_lbl = tk.Label(bar, textvariable=self._cache_lbl_var,
|
||||
bg=T["bg"], fg=T["fg2"],
|
||||
font=("Segoe UI", 9), anchor="e", padx=12)
|
||||
self._cache_lbl.pack(side="right")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Event handlers #
|
||||
|
|
@ -3344,12 +3448,17 @@ class ScreenerApp(tk.Tk):
|
|||
"text": "No data returned from yfinance."})
|
||||
return
|
||||
|
||||
self._q.put({"type": "status", "run_id": run_id,
|
||||
"text": "Loading sector stats…"})
|
||||
sector_stats = _load_sector_stats()
|
||||
self._q.put({"type": "status", "run_id": run_id,
|
||||
"text": f"Computing scores for {len(df_raw)} stocks…"})
|
||||
df_scored = score_stocks(df_raw, weights=settings.get("weights"))
|
||||
df_scored = score_stocks(df_raw, weights=settings.get("weights"),
|
||||
sector_stats=sector_stats)
|
||||
self._q.put({"type": "done", "run_id": run_id,
|
||||
"df_raw": df_raw,
|
||||
"df_scored": df_scored,
|
||||
"df_raw": df_raw,
|
||||
"df_scored": df_scored,
|
||||
"sector_stats": sector_stats,
|
||||
"total_scored": len(df_scored)})
|
||||
except Exception as exc:
|
||||
self._q.put({"type": "error", "run_id": run_id, "text": str(exc)})
|
||||
|
|
@ -3378,11 +3487,27 @@ class ScreenerApp(tk.Tk):
|
|||
f"skip={msg['skip']} ETA {msg['eta']:.0f}s")
|
||||
|
||||
elif mtype == "status":
|
||||
self._status_var.set(" " + msg["text"])
|
||||
text = msg["text"]
|
||||
self._status_var.set(" " + text)
|
||||
# Latch analyst-cache load result to the persistent right label
|
||||
tl = text.lower()
|
||||
if "analyst cache" in tl:
|
||||
clean = text.strip().lstrip("[WARN]").strip(" -–")
|
||||
if "empty" in tl or "unavailable" in tl or \
|
||||
"skipped" in tl or "could not" in tl:
|
||||
self._cache_lbl_var.set(clean)
|
||||
self._cache_lbl.config(fg=T["red"])
|
||||
elif "[warn]" in text.lower():
|
||||
self._cache_lbl_var.set(clean)
|
||||
self._cache_lbl.config(fg=T["yellow"])
|
||||
else:
|
||||
self._cache_lbl_var.set(clean)
|
||||
self._cache_lbl.config(fg=T["accent"])
|
||||
|
||||
elif mtype == "done":
|
||||
self._df_raw = msg["df_raw"]
|
||||
self._df = msg["df_scored"]
|
||||
self._df_raw = msg["df_raw"]
|
||||
self._df = msg["df_scored"]
|
||||
self._sector_stats = msg.get("sector_stats", {})
|
||||
self._progress_var.set(100)
|
||||
n = msg["total_scored"]
|
||||
self._status_var.set(
|
||||
|
|
@ -3497,6 +3622,13 @@ class _StockLookupWindow(tk.Toplevel):
|
|||
self._detail_title.pack(fill="x", side="top")
|
||||
tk.Frame(panel, bg=T["border"], height=1).pack(fill="x")
|
||||
|
||||
# No-data notice — shown when _data_source == "none"
|
||||
self._no_data_notice = tk.Label(
|
||||
panel,
|
||||
text=" ⚠ No fundamental data found for this ticker — scores based on price & momentum only.",
|
||||
bg="#1a1200", fg=T["yellow"], font=T["font_small"],
|
||||
anchor="w", pady=6, padx=16)
|
||||
|
||||
self._notebook = _CustomNotebook(panel)
|
||||
self._notebook.pack(fill="both", expand=True)
|
||||
|
||||
|
|
@ -3692,7 +3824,11 @@ class _StockLookupWindow(tk.Toplevel):
|
|||
"revenue": _g("totalRevenue"),
|
||||
"profit_margin": _g("profitMargins"),
|
||||
"operating_margin": _g("operatingMargins"),
|
||||
"fcf_yield": None,
|
||||
"fcf_yield": (
|
||||
(float(_g("freeCashflow")) / float(_g("marketCap")))
|
||||
if _g("freeCashflow") and _g("marketCap")
|
||||
and float(_g("marketCap")) > 0 else None
|
||||
),
|
||||
"dividend_yield": _g("dividendYield"),
|
||||
"recommendation": _g("recommendationKey"),
|
||||
"analyst_count": _g("numberOfAnalystOpinions"),
|
||||
|
|
@ -3710,7 +3846,11 @@ class _StockLookupWindow(tk.Toplevel):
|
|||
"news_headlines": _news_headlines,
|
||||
"short_percent": _g("shortPercentOfFloat"),
|
||||
"beta": _g("beta"),
|
||||
"volatility_30d": None,
|
||||
"volatility_30d": (
|
||||
float(close.iloc[-30:].pct_change().dropna().std()
|
||||
* (252 ** 0.5))
|
||||
if len(close) >= 35 else None
|
||||
),
|
||||
# Scores not available for live lookup
|
||||
"composite_score": None,
|
||||
"score_value": None,
|
||||
|
|
@ -3802,7 +3942,7 @@ class _StockLookupWindow(tk.Toplevel):
|
|||
"pe_forward": _flt,
|
||||
"pe_trailing": _flt,
|
||||
"pb_ratio": _flt,
|
||||
"book_value": _price,
|
||||
"book_value": _mcap,
|
||||
"ev_ebitda": _flt,
|
||||
"52w_high": _price,
|
||||
"52w_low": _price,
|
||||
|
|
@ -3875,6 +4015,13 @@ class _StockLookupWindow(tk.Toplevel):
|
|||
# Analyst commentary
|
||||
self._update_commentary(row)
|
||||
|
||||
# Show/hide the no-data banner
|
||||
if str(row.get("_data_source", "")) == "none":
|
||||
self._no_data_notice.pack(fill="x", side="top",
|
||||
before=self._notebook)
|
||||
else:
|
||||
self._no_data_notice.pack_forget()
|
||||
|
||||
# Trigger chart
|
||||
new_ticker = row.get("ticker")
|
||||
if new_ticker and new_ticker != self._chart_ticker:
|
||||
|
|
@ -4414,11 +4561,12 @@ class _SearchTabView(tk.Frame):
|
|||
_QUAL_FIELDS = ScreenerApp._QUAL_FIELDS
|
||||
_ANLST_FIELDS = ScreenerApp._ANLST_FIELDS
|
||||
|
||||
def __init__(self, parent, df=None, df_raw=None, get_weights=None):
|
||||
def __init__(self, parent, df=None, df_raw=None, get_weights=None, get_sector_stats=None):
|
||||
super().__init__(parent, bg=T["bg"])
|
||||
self._df = df
|
||||
self._df_raw = df_raw
|
||||
self._get_weights = get_weights or (lambda: None)
|
||||
self._df = df
|
||||
self._df_raw = df_raw
|
||||
self._get_weights = get_weights or (lambda: None)
|
||||
self._get_sector_stats = get_sector_stats or (lambda: {})
|
||||
self._last_row_dict = None # last displayed row (raw, pre-scored fields preserved)
|
||||
self._last_row_live = False # True = live fetch, False = from df
|
||||
self._detail_labels: dict = {}
|
||||
|
|
@ -4456,7 +4604,8 @@ class _SearchTabView(tk.Frame):
|
|||
import pandas as pd
|
||||
row = dict(self._last_row_dict)
|
||||
_weights = self._get_weights()
|
||||
scored = score_stocks(pd.DataFrame([row]), weights=_weights)
|
||||
scored = score_stocks(pd.DataFrame([row]), weights=_weights,
|
||||
sector_stats=self._get_sector_stats())
|
||||
if not scored.empty:
|
||||
s = scored.iloc[0]
|
||||
for col in ("composite_score", "score_value", "score_growth",
|
||||
|
|
@ -4654,7 +4803,8 @@ class _SearchTabView(tk.Frame):
|
|||
try:
|
||||
import pandas as pd
|
||||
_weights = self._get_weights()
|
||||
scored = score_stocks(pd.DataFrame([live_row]), weights=_weights)
|
||||
scored = score_stocks(pd.DataFrame([live_row]), weights=_weights,
|
||||
sector_stats=self._get_sector_stats())
|
||||
if not scored.empty:
|
||||
s = scored.iloc[0]
|
||||
for col in ("composite_score", "score_value", "score_growth",
|
||||
|
|
@ -4720,7 +4870,7 @@ class _SearchTabView(tk.Frame):
|
|||
# Tracking Tab — SEC EDGAR Form 4 Insider Transactions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _TrackingTabView(tk.Frame):
|
||||
class _InsiderTradesView(tk.Frame):
|
||||
"""
|
||||
Finviz-style insider trading table backed by SEC EDGAR Form 4 filings.
|
||||
Fetches data in a background thread; clicking a row opens the SEC filing.
|
||||
|
|
@ -4893,7 +5043,7 @@ class _TrackingTabView(tk.Frame):
|
|||
def _worker():
|
||||
try:
|
||||
trades = fetch_insider_trades(
|
||||
days_back=days, max_results=60, progress_cb=_progress)
|
||||
days_back=days, progress_cb=_progress)
|
||||
except Exception:
|
||||
trades = []
|
||||
self.after(0, lambda t=trades: self._on_fetched(t))
|
||||
|
|
@ -4998,6 +5148,370 @@ class _TrackingTabView(tk.Frame):
|
|||
webbrowser.open(url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hedge Fund Holdings tab (13F-HR)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _HedgeFundView(tk.Frame):
|
||||
"""
|
||||
Table of individual stock holdings parsed from SEC 13F-HR filings.
|
||||
One row per holding per fund; clicking a row opens the SEC filing index.
|
||||
"""
|
||||
|
||||
_HF_COLS = [
|
||||
("Filed", 82, "center"),
|
||||
("Fund", 195, "w"),
|
||||
("Company", 185, "w"),
|
||||
("Shares", 90, "e"),
|
||||
("Value", 90, "e"),
|
||||
("Class", 60, "center"),
|
||||
("Opt", 45, "center"),
|
||||
]
|
||||
|
||||
_PERIODS = [("30D", 30), ("60D", 60), ("90D", 90), ("180D", 180)]
|
||||
_MIN_VALS = [
|
||||
("All", 0),
|
||||
("$1M+", 1_000_000),
|
||||
("$10M+", 10_000_000),
|
||||
("$100M+", 100_000_000),
|
||||
]
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent, bg=T["bg"])
|
||||
self._all_holdings: list[dict] = []
|
||||
self._iid_to_url: dict[str, str] = {}
|
||||
self._loading = False
|
||||
self._has_loaded = False
|
||||
self._days_back = 90
|
||||
self._min_val = 1_000_000
|
||||
self._sort_asc_map: dict[str, bool] = {}
|
||||
self._status_var = tk.StringVar(
|
||||
value="Click ↻ Refresh to load hedge fund holdings.")
|
||||
self._period_btns: dict = {}
|
||||
self._minval_btns: dict = {}
|
||||
|
||||
self._build_controls()
|
||||
self._build_table()
|
||||
|
||||
# ── Controls ─────────────────────────────────────────────────────────── #
|
||||
|
||||
def _build_controls(self):
|
||||
outer = tk.Frame(self, bg=T["bg"])
|
||||
outer.pack(fill="x", padx=_s(10), pady=(_s(8), 0))
|
||||
|
||||
tk.Label(outer, text="Hedge Fund Holdings (13F-HR)", bg=T["bg"],
|
||||
fg=T["accent"],
|
||||
font=("Segoe UI", 12, "bold")).pack(side="left",
|
||||
padx=(0, _s(16)))
|
||||
|
||||
def _vsep():
|
||||
tk.Frame(outer, bg=T["border"], width=1,
|
||||
height=_s(20)).pack(side="left", padx=_s(10), fill="y")
|
||||
|
||||
# Period selector
|
||||
tk.Label(outer, text="Period", bg=T["bg"], fg=T["fg2"],
|
||||
font=T["font_small"]).pack(side="left")
|
||||
for lbl, days in self._PERIODS:
|
||||
w = max(_s(38), len(lbl) * _s(7) + _s(10))
|
||||
b = _RoundedButton(outer, lbl, lambda d=days: self._set_period(d),
|
||||
width=w, height=_s(26), radius=_s(6),
|
||||
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
|
||||
b.pack(side="left", padx=(_s(4), 0))
|
||||
self._period_btns[days] = b
|
||||
self._activate_btn(self._period_btns, self._days_back)
|
||||
|
||||
_vsep()
|
||||
|
||||
# Minimum value filter (applied client-side)
|
||||
tk.Label(outer, text="Min Value", bg=T["bg"], fg=T["fg2"],
|
||||
font=T["font_small"]).pack(side="left")
|
||||
for lbl, val in self._MIN_VALS:
|
||||
w = max(_s(42), len(lbl) * _s(7) + _s(10))
|
||||
b = _RoundedButton(outer, lbl, lambda v=val: self._set_min_val(v),
|
||||
width=w, height=_s(26), radius=_s(6),
|
||||
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
|
||||
b.pack(side="left", padx=(_s(4), 0))
|
||||
self._minval_btns[val] = b
|
||||
self._activate_btn(self._minval_btns, self._min_val)
|
||||
|
||||
_vsep()
|
||||
|
||||
self._refresh_btn = _RoundedButton(
|
||||
outer, "\u21bb Refresh", self._refresh,
|
||||
width=_s(100), height=_s(26), radius=_s(6))
|
||||
self._refresh_btn.pack(side="left")
|
||||
|
||||
tk.Label(outer, textvariable=self._status_var, bg=T["bg"], fg=T["fg2"],
|
||||
font=T["font_small"]).pack(side="left", padx=(_s(14), 0))
|
||||
|
||||
tk.Frame(self, bg=T["border"], height=1).pack(
|
||||
fill="x", padx=_s(10), pady=(_s(8), 0))
|
||||
|
||||
# ── Table ─────────────────────────────────────────────────────────────── #
|
||||
|
||||
def _build_table(self):
|
||||
frame = tk.Frame(self, bg=T["bg"])
|
||||
frame.pack(fill="both", expand=True, padx=_s(10), pady=(_s(6), _s(8)))
|
||||
|
||||
cols = [c[0] for c in self._HF_COLS]
|
||||
self._tree = ttk.Treeview(frame, columns=cols, show="headings",
|
||||
selectmode="browse")
|
||||
|
||||
for col, w, anchor in self._HF_COLS:
|
||||
self._tree.heading(col, text=col,
|
||||
command=lambda c=col: self._sort_column(c))
|
||||
self._tree.column(col, width=_s(w), anchor=anchor,
|
||||
stretch=(col in ("Fund", "Company")))
|
||||
|
||||
self._tree.tag_configure("odd", background=T["row_odd"])
|
||||
self._tree.tag_configure("even", background=T["row_even"])
|
||||
self._tree.tag_configure("c_hf", foreground=T["accent"])
|
||||
self._tree.tag_configure("c_opt", foreground=T["yellow"])
|
||||
|
||||
vsb = ttk.Scrollbar(frame, orient="vertical", command=self._tree.yview)
|
||||
hsb = ttk.Scrollbar(frame, orient="horizontal", command=self._tree.xview)
|
||||
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||||
|
||||
self._tree.grid(row=0, column=0, sticky="nsew")
|
||||
vsb.grid(row=0, column=1, sticky="ns")
|
||||
hsb.grid(row=1, column=0, sticky="ew")
|
||||
frame.grid_rowconfigure(0, weight=1)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._tree.bind("<<TreeviewSelect>>", self._on_row_select)
|
||||
|
||||
# ── Button groups ─────────────────────────────────────────────────────── #
|
||||
|
||||
@staticmethod
|
||||
def _activate_btn(btn_dict: dict, active_key):
|
||||
for key, btn in btn_dict.items():
|
||||
is_active = (key == active_key)
|
||||
bg = T["accent"] if is_active else T["bg3"]
|
||||
fg = "#000000" if is_active else T["fg2"]
|
||||
btn._bg = bg; btn._fg = fg; btn._border = bg
|
||||
btn.itemconfig(btn._rect, fill=bg, outline=bg)
|
||||
btn.itemconfig(btn._lbl, fill=fg)
|
||||
|
||||
def _set_period(self, days: int):
|
||||
self._days_back = days
|
||||
self._activate_btn(self._period_btns, days)
|
||||
self._refresh()
|
||||
|
||||
def _set_min_val(self, val: int):
|
||||
self._min_val = val
|
||||
self._activate_btn(self._minval_btns, val)
|
||||
self._apply_filter()
|
||||
|
||||
# ── Data fetch ────────────────────────────────────────────────────────── #
|
||||
|
||||
def on_show(self):
|
||||
"""Called each time the sub-tab becomes visible; auto-fetches on first show."""
|
||||
if not self._has_loaded:
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
if self._loading:
|
||||
return
|
||||
self._loading = True
|
||||
self._refresh_btn.set_state("disabled")
|
||||
self._status_var.set("Fetching from SEC EDGAR...")
|
||||
days = self._days_back
|
||||
|
||||
def _progress(done, total):
|
||||
self.after(0, lambda: self._status_var.set(
|
||||
f"Loading... ({done}/{total} funds)"))
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
holdings = fetch_hedge_fund_filings(
|
||||
days_back=days, progress_cb=_progress)
|
||||
except Exception as _hf_err:
|
||||
import traceback
|
||||
print(f"[HF ERROR] fetch_hedge_fund_filings raised an exception:\n"
|
||||
f"{traceback.format_exc()}")
|
||||
holdings = []
|
||||
self.after(0, lambda h=holdings: self._on_fetched(h))
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
def _on_fetched(self, holdings: list):
|
||||
self._loading = False
|
||||
self._has_loaded = True
|
||||
self._all_holdings = holdings
|
||||
self._refresh_btn.set_state("normal")
|
||||
self._apply_filter()
|
||||
n = len(holdings)
|
||||
if n:
|
||||
self._status_var.set(
|
||||
f"{n} holding{'s' if n != 1 else ''} loaded.")
|
||||
else:
|
||||
self._status_var.set("No holdings found for this period. Check console for details.")
|
||||
|
||||
# ── Filter & populate ─────────────────────────────────────────────────── #
|
||||
|
||||
def _apply_filter(self):
|
||||
mv = self._min_val
|
||||
if mv > 0:
|
||||
rows = [h for h in self._all_holdings
|
||||
if (h.get("value") or 0) >= mv]
|
||||
else:
|
||||
rows = list(self._all_holdings)
|
||||
self._populate_table(rows)
|
||||
|
||||
def _populate_table(self, holdings: list):
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
self._iid_to_url.clear()
|
||||
|
||||
for i, h in enumerate(holdings):
|
||||
bg_tag = "odd" if i % 2 else "even"
|
||||
opt = (h.get("put_call") or "").strip()
|
||||
color_tag = "c_opt" if opt else "c_hf"
|
||||
|
||||
# Format filed date "YYYY-MM-DD" → "Mon DD"
|
||||
date_str = h.get("filed_date", "")
|
||||
try:
|
||||
import calendar as _cal
|
||||
y, m, d = date_str[:10].split("-")
|
||||
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Format reporting period → "Q1 '25"
|
||||
period_str = h.get("period", "")
|
||||
try:
|
||||
import calendar as _cal # noqa: F811
|
||||
py, pm, _ = period_str[:10].split("-")
|
||||
period_str = f"Q{((int(pm) - 1) // 3) + 1} '{py[2:]}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
iid = str(i)
|
||||
self._iid_to_url[iid] = h.get("url", "")
|
||||
|
||||
self._tree.insert("", "end", iid=iid,
|
||||
tags=(bg_tag, color_tag),
|
||||
values=(
|
||||
date_str,
|
||||
(h.get("fund_name") or "")[:28],
|
||||
(h.get("company") or "")[:26],
|
||||
_fmt_shares(h.get("shares")),
|
||||
_fmt_value(h.get("value")),
|
||||
(h.get("class_") or "")[:8],
|
||||
opt[:4] if opt else "—",
|
||||
))
|
||||
|
||||
# ── Sorting ───────────────────────────────────────────────────────────── #
|
||||
|
||||
def _sort_column(self, col: str):
|
||||
data = [(self._tree.set(iid, col), iid)
|
||||
for iid in self._tree.get_children("")]
|
||||
|
||||
def _key(pair):
|
||||
s = pair[0].replace(",", "").replace("$", "").replace("—", "").strip()
|
||||
for sfx, mult in [("B", 1e9), ("M", 1e6), ("K", 1e3)]:
|
||||
if s.endswith(sfx):
|
||||
try: return (0, float(s[:-1]) * mult)
|
||||
except: pass
|
||||
try: return (0, float(s))
|
||||
except: return (1, s.lower())
|
||||
|
||||
asc = self._sort_asc_map.get(col, False)
|
||||
data.sort(key=_key, reverse=not asc)
|
||||
self._sort_asc_map[col] = not asc
|
||||
|
||||
for idx, (_, iid) in enumerate(data):
|
||||
self._tree.move(iid, "", idx)
|
||||
tags = [t for t in self._tree.item(iid, "tags")
|
||||
if t not in ("odd", "even")]
|
||||
tags.append("odd" if idx % 2 else "even")
|
||||
self._tree.item(iid, tags=tags)
|
||||
|
||||
# ── Row click ─────────────────────────────────────────────────────────── #
|
||||
|
||||
def _on_row_select(self, _event=None):
|
||||
sel = self._tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
url = self._iid_to_url.get(sel[0], "")
|
||||
if url:
|
||||
webbrowser.open(url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracking section container — sub-tabs: Insider Trades | Hedge Funds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _TrackingTabView(tk.Frame):
|
||||
"""
|
||||
Container for the Tracking section. Houses two sub-tabs:
|
||||
• Insider Trades — SEC Form 4 filings
|
||||
• Hedge Funds — SEC 13F-HR holdings
|
||||
"""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent, bg=T["bg"])
|
||||
self._sub_btns: dict = {}
|
||||
self._active_sub = ""
|
||||
|
||||
self._build_sub_tab_bar()
|
||||
|
||||
self._insider_view = _InsiderTradesView(self)
|
||||
self._hf_view = _HedgeFundView(self)
|
||||
|
||||
self._show_insider_sub()
|
||||
|
||||
# ── Sub-tab bar ───────────────────────────────────────────────────────── #
|
||||
|
||||
def _build_sub_tab_bar(self):
|
||||
bar = tk.Frame(self, bg=T["bg"])
|
||||
bar.pack(fill="x", padx=_s(10), pady=(_s(6), 0))
|
||||
|
||||
for name, cmd in [
|
||||
("Insider Trades", self._show_insider_sub),
|
||||
("Hedge Funds", self._show_hf_sub),
|
||||
]:
|
||||
w = max(_s(110), len(name) * _s(7) + _s(16))
|
||||
b = _RoundedButton(bar, name, cmd,
|
||||
width=w, height=_s(28), radius=_s(7),
|
||||
bg=T["bg3"], fg=T["fg2"], border=T["bg3"])
|
||||
b.pack(side="left", padx=(0, _s(6)))
|
||||
self._sub_btns[name] = b
|
||||
|
||||
def _update_sub_active(self, name: str):
|
||||
for n, btn in self._sub_btns.items():
|
||||
is_active = (n == name)
|
||||
bg = T["accent"] if is_active else T["bg3"]
|
||||
fg = "#000000" if is_active else T["fg2"]
|
||||
btn._bg = bg; btn._fg = fg; btn._border = bg
|
||||
btn.itemconfig(btn._rect, fill=bg, outline=bg)
|
||||
btn.itemconfig(btn._lbl, fill=fg)
|
||||
|
||||
# ── Sub-tab switching ─────────────────────────────────────────────────── #
|
||||
|
||||
def _show_insider_sub(self):
|
||||
self._hf_view.pack_forget()
|
||||
self._insider_view.pack(fill="both", expand=True)
|
||||
self._active_sub = "insider"
|
||||
self._update_sub_active("Insider Trades")
|
||||
self._insider_view.on_show()
|
||||
|
||||
def _show_hf_sub(self):
|
||||
self._insider_view.pack_forget()
|
||||
self._hf_view.pack(fill="both", expand=True)
|
||||
self._active_sub = "hf"
|
||||
self._update_sub_active("Hedge Funds")
|
||||
self._hf_view.on_show()
|
||||
|
||||
# ── Delegate on_show ─────────────────────────────────────────────────── #
|
||||
|
||||
def on_show(self):
|
||||
"""Called by the main app each time the Tracking tab becomes visible."""
|
||||
if self._active_sub == "hf":
|
||||
self._hf_view.on_show()
|
||||
else:
|
||||
self._insider_view.on_show()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,2 @@
|
|||
[2026-03-16 14:10:05] No rows returned for channel=beta
|
||||
[2026-03-16 14:18:03] RPC response for channel=beta: version=1.1.4, exe_url=present
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
"""
|
||||
Health Monitor
|
||||
==============
|
||||
Runs every 5 minutes via cron. Checks processes, resources, SSL, cache logs,
|
||||
and runs nightly database backups. Alerts via Discord webhook on any failure.
|
||||
Auto-fixes safe/known issues (Tier 1). Alerts and waits for human decision on
|
||||
everything else (Tier 2/3).
|
||||
|
||||
Deploy to: /srv/health_monitor.py
|
||||
Cron entry (/etc/cron.d/health_monitor):
|
||||
*/5 * * * * root python3 /srv/health_monitor.py >> /var/log/health_monitor.log 2>&1
|
||||
|
||||
Secrets loaded from /srv/api/.env:
|
||||
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
|
||||
DB_PASS=...
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv("/srv/api/.env")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
DISCORD_WEBHOOK = os.getenv("DISCORD_WEBHOOK", "")
|
||||
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
|
||||
DB_USER = "verimund_user"
|
||||
DB_NAME = "verimund"
|
||||
BACKUP_DIR = Path("/srv/backups/db")
|
||||
BACKUP_KEEP_DAYS = 7
|
||||
LOG_DIR = Path("/var/log")
|
||||
STATE_FILE = Path("/srv/health_monitor_state.json")
|
||||
|
||||
DISK_WARN_PCT = 80
|
||||
RAM_WARN_PCT = 90
|
||||
CPU_WARN_PCT = 90
|
||||
SSL_WARN_DAYS = 14
|
||||
DOMAINS = [
|
||||
"verimundsolutions.com",
|
||||
"api.verimundsolutions.com",
|
||||
"git.verimundsolutions.com",
|
||||
"admin.verimundsolutions.com",
|
||||
]
|
||||
CACHE_LOGS = {
|
||||
"stable": LOG_DIR / "cache_builder_stable.log",
|
||||
"beta": LOG_DIR / "cache_builder_beta.log",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State (tracks last daily/weekly run so we don't repeat within the window)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state: dict) -> None:
|
||||
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def _should_run(state: dict, key: str, interval_seconds: int) -> bool:
|
||||
last = state.get(key, 0)
|
||||
return (time.time() - last) >= interval_seconds
|
||||
|
||||
|
||||
def _mark_run(state: dict, key: str) -> None:
|
||||
state[key] = time.time()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discord alerts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def alert(message: str, tier: int = 2) -> None:
|
||||
"""Send a Discord webhook message. Never raises — alerting must not crash the monitor."""
|
||||
prefix = {1: "✅ [AUTO-FIXED]", 2: "⚠ [TIER 2]", 3: "🚨 [TIER 3 — ACTION REQUIRED]"}.get(tier, "ℹ")
|
||||
full = f"{prefix} {message}"
|
||||
print(full, flush=True)
|
||||
if not DISCORD_WEBHOOK:
|
||||
return
|
||||
try:
|
||||
import urllib.request
|
||||
payload = json.dumps({"content": full}).encode()
|
||||
req = urllib.request.Request(
|
||||
DISCORD_WEBHOOK,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "DiscordBot (https://verimundsolutions.com, 1.0)"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f" [alert] Discord send failed: {e}", flush=True)
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC] {message}", flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pm2_list() -> list[str]:
|
||||
"""Return list of PM2 process names that are 'online'."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["pm2", "jlist"], stderr=subprocess.DEVNULL, text=True
|
||||
)
|
||||
procs = json.loads(out)
|
||||
return [p["name"] for p in procs if p.get("pm2_env", {}).get("status") == "online"]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def check_processes() -> None:
|
||||
log("Checking processes...")
|
||||
|
||||
# Nginx
|
||||
r = subprocess.run(["systemctl", "is-active", "nginx"], capture_output=True, text=True)
|
||||
if r.stdout.strip() != "active":
|
||||
subprocess.run(["systemctl", "start", "nginx"], capture_output=True)
|
||||
alert("Nginx was down — restarted.", tier=1)
|
||||
|
||||
# PostgreSQL
|
||||
r = subprocess.run(["systemctl", "is-active", "postgresql"], capture_output=True, text=True)
|
||||
if r.stdout.strip() != "active":
|
||||
subprocess.run(["systemctl", "start", "postgresql"], capture_output=True)
|
||||
alert("PostgreSQL was down — restarted.", tier=1)
|
||||
|
||||
# PM2 itself
|
||||
r = subprocess.run(["systemctl", "is-active", "pm2-root"], capture_output=True, text=True)
|
||||
if r.stdout.strip() != "active":
|
||||
subprocess.run(["systemctl", "start", "pm2-root"], capture_output=True)
|
||||
alert("PM2 service was down — restarted.", tier=1)
|
||||
|
||||
# PM2-managed processes
|
||||
online = _pm2_list()
|
||||
for name in ("api",):
|
||||
if name not in online:
|
||||
subprocess.run(["pm2", "restart", name], capture_output=True)
|
||||
alert(f"PM2 process '{name}' was down — restarted.", tier=1)
|
||||
|
||||
# API health check (HTTP)
|
||||
try:
|
||||
import urllib.request
|
||||
urllib.request.urlopen("http://127.0.0.1:8000/docs", timeout=5)
|
||||
except Exception:
|
||||
subprocess.run(["pm2", "restart", "api"], capture_output=True)
|
||||
alert("API server not responding on port 8000 — restarted via PM2.", tier=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_resources() -> None:
|
||||
log("Checking resources...")
|
||||
|
||||
# Disk
|
||||
usage = shutil.disk_usage("/")
|
||||
pct = usage.used / usage.total * 100
|
||||
if pct >= DISK_WARN_PCT:
|
||||
_clean_old_logs()
|
||||
# Re-check after cleanup
|
||||
usage = shutil.disk_usage("/")
|
||||
pct = usage.used / usage.total * 100
|
||||
if pct >= DISK_WARN_PCT:
|
||||
alert(
|
||||
f"Disk usage is {pct:.1f}% after auto-cleanup. Manual intervention may be needed.",
|
||||
tier=3 if pct >= 90 else 2,
|
||||
)
|
||||
else:
|
||||
alert(f"Disk was {pct:.1f}% — old logs cleaned, now {pct:.1f}%.", tier=1)
|
||||
|
||||
# RAM
|
||||
with open("/proc/meminfo") as f:
|
||||
lines = {line.split(":")[0]: int(line.split()[1]) for line in f if ":" in line}
|
||||
total = lines.get("MemTotal", 1)
|
||||
avail = lines.get("MemAvailable", 1)
|
||||
ram_pct = (total - avail) / total * 100
|
||||
if ram_pct >= RAM_WARN_PCT:
|
||||
alert(f"RAM usage is {ram_pct:.1f}% ({(total - avail) // 1024} MB used of {total // 1024} MB).", tier=2)
|
||||
|
||||
# CPU (1-minute load average vs core count)
|
||||
load1 = os.getloadavg()[0]
|
||||
cores = os.cpu_count() or 1
|
||||
cpu_pct = load1 / cores * 100
|
||||
if cpu_pct >= CPU_WARN_PCT:
|
||||
alert(f"CPU load is {cpu_pct:.1f}% (load avg {load1:.2f} on {cores} cores).", tier=2)
|
||||
|
||||
|
||||
def _clean_old_logs() -> None:
|
||||
"""Delete log files older than 30 days to free disk space."""
|
||||
now = time.time()
|
||||
cutoff = now - 30 * 86400
|
||||
cleaned = 0
|
||||
for path in LOG_DIR.glob("*.log*"):
|
||||
if path.stat().st_mtime < cutoff:
|
||||
try:
|
||||
path.unlink()
|
||||
cleaned += 1
|
||||
except Exception:
|
||||
pass
|
||||
if cleaned:
|
||||
log(f" Cleaned {cleaned} old log file(s).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSL certificate checks (daily)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_ssl(state: dict) -> None:
|
||||
if not _should_run(state, "ssl_check", 86400):
|
||||
return
|
||||
log("Checking SSL certificates...")
|
||||
_mark_run(state, "ssl_check")
|
||||
|
||||
for domain in DOMAINS:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["certbot", "certificates", "--domain", domain],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
for line in r.stdout.splitlines():
|
||||
if "VALID:" in line:
|
||||
days = int(line.split("VALID:")[1].split("day")[0].strip())
|
||||
if days <= SSL_WARN_DAYS:
|
||||
log(f" SSL for {domain} expires in {days} days — renewing...")
|
||||
result = subprocess.run(
|
||||
["certbot", "renew", "--cert-name", domain, "--non-interactive"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
alert(f"SSL cert for {domain} renewed ({days} days remaining).", tier=1)
|
||||
else:
|
||||
alert(
|
||||
f"SSL cert for {domain} expires in {days} days but renewal FAILED.\n"
|
||||
f"```{result.stderr[-500:]}```",
|
||||
tier=3,
|
||||
)
|
||||
except Exception as e:
|
||||
log(f" SSL check failed for {domain}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache log checks (daily)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_cache_logs(state: dict) -> None:
|
||||
if not _should_run(state, "cache_log_check", 86400):
|
||||
return
|
||||
log("Checking cache builder logs...")
|
||||
_mark_run(state, "cache_log_check")
|
||||
|
||||
for channel, log_path in CACHE_LOGS.items():
|
||||
if not log_path.exists():
|
||||
continue
|
||||
try:
|
||||
content = log_path.read_text(errors="replace")
|
||||
lines = content.splitlines()
|
||||
recent = "\n".join(lines[-50:])
|
||||
|
||||
if "Traceback" in recent or "Error" in recent:
|
||||
alert(
|
||||
f"Cache builder ({channel}) log contains errors:\n```{recent[-800:]}```",
|
||||
tier=2,
|
||||
)
|
||||
elif "0 rows" in recent or "0 tickers" in recent.lower():
|
||||
alert(
|
||||
f"Cache builder ({channel}) may have produced 0 rows:\n```{recent[-800:]}```",
|
||||
tier=2,
|
||||
)
|
||||
except Exception as e:
|
||||
log(f" Could not read cache log for {channel}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database backup (daily)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_backup(state: dict) -> None:
|
||||
if not _should_run(state, "db_backup", 86400):
|
||||
return
|
||||
log("Running database backup...")
|
||||
_mark_run(state, "db_backup")
|
||||
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
out_path = BACKUP_DIR / f"verimund_{date_str}.sql.gz"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = DB_PASS
|
||||
|
||||
dump = subprocess.run(
|
||||
["pg_dump", "-U", DB_USER, "-h", "127.0.0.1", DB_NAME],
|
||||
capture_output=True, env=env,
|
||||
)
|
||||
if dump.returncode != 0:
|
||||
alert(
|
||||
f"pg_dump failed:\n```{dump.stderr.decode()[-500:]}```",
|
||||
tier=3,
|
||||
)
|
||||
return
|
||||
|
||||
import gzip
|
||||
with gzip.open(out_path, "wb") as f:
|
||||
f.write(dump.stdout)
|
||||
|
||||
size_mb = out_path.stat().st_size / 1024 / 1024
|
||||
log(f" Backup written: {out_path} ({size_mb:.1f} MB)")
|
||||
|
||||
# Sync to R2 if rclone is configured
|
||||
r = subprocess.run(
|
||||
["rclone", "sync", str(BACKUP_DIR), "r2:verimund-backups/db/"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
alert(f"rclone sync to R2 failed:\n```{r.stderr[-400:]}```", tier=2)
|
||||
else:
|
||||
log(" R2 sync complete.")
|
||||
|
||||
# Remove backups older than BACKUP_KEEP_DAYS
|
||||
cutoff = time.time() - BACKUP_KEEP_DAYS * 86400
|
||||
for f in BACKUP_DIR.glob("verimund_*.sql.gz"):
|
||||
if f.stat().st_mtime < cutoff:
|
||||
f.unlink()
|
||||
log(f" Deleted old backup: {f.name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log compression (weekly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compress_old_logs(state: dict) -> None:
|
||||
if not _should_run(state, "log_compress", 7 * 86400):
|
||||
return
|
||||
log("Compressing old logs...")
|
||||
_mark_run(state, "log_compress")
|
||||
|
||||
import gzip
|
||||
cutoff = time.time() - 7 * 86400
|
||||
compressed = 0
|
||||
for path in LOG_DIR.glob("*.log"):
|
||||
if path.stat().st_mtime < cutoff:
|
||||
gz_path = path.with_suffix(".log.gz")
|
||||
try:
|
||||
with open(path, "rb") as f_in, gzip.open(gz_path, "wb") as f_out:
|
||||
f_out.write(f_in.read())
|
||||
path.unlink()
|
||||
compressed += 1
|
||||
except Exception as e:
|
||||
log(f" Could not compress {path.name}: {e}")
|
||||
if compressed:
|
||||
log(f" Compressed {compressed} log file(s).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
log("=== Health monitor start ===")
|
||||
state = _load_state()
|
||||
|
||||
check_processes()
|
||||
check_resources()
|
||||
check_ssl(state)
|
||||
check_cache_logs(state)
|
||||
run_backup(state)
|
||||
compress_old_logs(state)
|
||||
|
||||
_save_state(state)
|
||||
log("=== Health monitor done ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
119
license_check.py
119
license_check.py
|
|
@ -1,16 +1,17 @@
|
|||
"""
|
||||
License Check
|
||||
=============
|
||||
Validates the stored license key against Supabase on every launch.
|
||||
Validates the stored license key against the Verimund API on every launch.
|
||||
Falls back to a local cache if the server is unreachable (30-minute grace period).
|
||||
|
||||
Storage location: %APPDATA%\\StockScreener\\
|
||||
license.dat — XOR-encrypted license key (machine-bound)
|
||||
auth_cache.dat — last successful auth result + timestamp
|
||||
license.dat — XOR-encrypted license key (machine-bound)
|
||||
auth_cache.dat — last successful auth result + timestamp + JWT token
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac as _hmac_module
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
|
@ -18,15 +19,13 @@ import time
|
|||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase config
|
||||
# API config
|
||||
# ---------------------------------------------------------------------------
|
||||
_SUPABASE_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
|
||||
_SUPABASE_ANON = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
||||
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllaXNwY3BtZXBqZWxmYmhma3dyIiwi"
|
||||
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzM1MDkxOTAsImV4cCI6MjA4OTA4NTE5MH0."
|
||||
"8kq6HzpwF81_Sx36MXbx0wUqaT9Ul9POw1LzF5vxiOo"
|
||||
)
|
||||
_API_URL = "https://api.verimundsolutions.com"
|
||||
|
||||
# HMAC secret — must match HMAC_SECRET in /srv/api/.env on the server.
|
||||
# This value is obfuscated by PyArmor before distribution.
|
||||
_HMAC_SECRET = "fa1fac57c9cc9742ac66542a17b032ccdfb2211b72c81fccd1617f17ce03ac7f"
|
||||
|
||||
# 30 minutes
|
||||
_GRACE_PERIOD_SECONDS = 30 * 60
|
||||
|
|
@ -101,21 +100,19 @@ def delete_license_key() -> None:
|
|||
# Auth cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_cache(valid: bool, reason: str, channel: str = "stable") -> None:
|
||||
def _save_cache(valid: bool, reason: str, channel: str = "stable", jwt_token: str = "") -> None:
|
||||
_ensure_dir()
|
||||
payload = {"valid": valid, "reason": reason, "channel": channel, "timestamp": time.time()}
|
||||
payload = {
|
||||
"valid": valid,
|
||||
"reason": reason,
|
||||
"channel": channel,
|
||||
"jwt_token": jwt_token,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f)
|
||||
|
||||
|
||||
def get_channel() -> str:
|
||||
"""Return the update channel ('stable' or 'beta') from the last auth cache."""
|
||||
cache = _load_cache()
|
||||
if cache:
|
||||
return cache.get("channel", "stable")
|
||||
return "stable"
|
||||
|
||||
|
||||
def _load_cache() -> dict | None:
|
||||
if not os.path.exists(_CACHE_FILE):
|
||||
return None
|
||||
|
|
@ -126,6 +123,43 @@ def _load_cache() -> dict | None:
|
|||
return None
|
||||
|
||||
|
||||
def get_channel() -> str:
|
||||
"""Return the update channel ('stable' or 'beta') from the last auth cache."""
|
||||
cache = _load_cache()
|
||||
if cache:
|
||||
return cache.get("channel", "stable")
|
||||
return "stable"
|
||||
|
||||
|
||||
def get_jwt() -> str:
|
||||
"""Return the stored JWT token, or empty string if not available."""
|
||||
cache = _load_cache()
|
||||
if cache:
|
||||
return cache.get("jwt_token", "")
|
||||
return ""
|
||||
|
||||
|
||||
def get_api_headers() -> dict:
|
||||
"""Return headers for authenticated API requests."""
|
||||
return {
|
||||
"Authorization": f"Bearer {get_jwt()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HMAC request signing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sign_request(license_key: str, timestamp: str) -> str:
|
||||
"""Generate HMAC-SHA256 signature for an auth request."""
|
||||
return _hmac_module.new(
|
||||
_HMAC_SECRET.encode(),
|
||||
f"{license_key}{timestamp}".encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -134,31 +168,41 @@ def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
|
|||
"""
|
||||
Returns (valid: bool, reason: str).
|
||||
|
||||
Tries the Supabase RPC first. If the server is unreachable, falls back
|
||||
to the cached result if it is within the 30-minute grace period.
|
||||
Calls POST /auth on the Verimund API with a signed request.
|
||||
Falls back to cached result within the 30-minute grace period if offline.
|
||||
"""
|
||||
headers = {
|
||||
"apikey": _SUPABASE_ANON,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timestamp = str(int(time.time()))
|
||||
signature = _sign_request(license_key, timestamp)
|
||||
|
||||
resp = requests.post(
|
||||
f"{_SUPABASE_URL}/rest/v1/rpc/verify_license",
|
||||
headers=headers,
|
||||
json={"p_key": license_key, "p_hwid": hwid},
|
||||
f"{_API_URL}/auth",
|
||||
json={
|
||||
"license_key": license_key,
|
||||
"hwid": hwid,
|
||||
"timestamp": timestamp,
|
||||
"signature": signature,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
valid = bool(data.get("valid", False))
|
||||
reason = data.get("reason", "Unknown error")
|
||||
data = resp.json()
|
||||
token = data.get("token", "")
|
||||
channel = data.get("channel", "stable")
|
||||
_save_cache(valid, reason, channel)
|
||||
return valid, reason
|
||||
_save_cache(True, "ok", channel, token)
|
||||
return True, "ok"
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
try:
|
||||
detail = e.response.json().get("detail", str(e))
|
||||
except Exception:
|
||||
detail = str(e)
|
||||
_save_cache(False, detail, "stable", "")
|
||||
return False, detail
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
return False, f"SSL error contacting license server: {e}"
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
cache = _load_cache()
|
||||
if cache and cache.get("valid"):
|
||||
|
|
@ -166,8 +210,7 @@ def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
|
|||
if age < _GRACE_PERIOD_SECONDS:
|
||||
return True, "offline_grace"
|
||||
return False, f"Cannot connect to license server. Check your internet connection.\n\n({e})"
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return False, f"License server error: {e}"
|
||||
|
||||
except requests.RequestException as e:
|
||||
cache = _load_cache()
|
||||
if cache and cache.get("valid"):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
[BUG]: out of license
|
||||
|
||||
## Command Line
|
||||
C:\Users\noach\AppData\Local\Programs\Python\Python314\Scripts\pyarmor gen --platform windows.x86_64 --output C:\Users\noach\Desktop\Stock Tool\Stock Tool\_pyarmor_build C:\Users\noach\Desktop\Stock Tool\Stock Tool\launcher.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\license_check.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\updater.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\activation_dialog.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\hwid.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\screener_gui.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\stock_screener.py
|
||||
|
||||
## Environments
|
||||
Python 3.14.3
|
||||
Pyarmor 9.2.4 (trial), 000000, non-profits
|
||||
Platform windows.x86_64
|
||||
Native windows.amd64
|
||||
Home C:\Users\noach\.pyarmor
|
||||
|
||||
## Traceback
|
||||
Traceback (most recent call last):
|
||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 804, in main
|
||||
main_entry(sys.argv[1:])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^
|
||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 789, in main_entry
|
||||
return args.func(ctx, args)
|
||||
~~~~~~~~~^^^^^^^^^^^
|
||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 248, in cmd_gen
|
||||
builder.process(options)
|
||||
~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 190, in process
|
||||
async_obfuscate_scripts(self, n) if n else self._obfuscate_scripts()
|
||||
~~~~~~~~~~~~~~~~~~~~~~~^^
|
||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 145, in _obfuscate_scripts
|
||||
code = Pytransform3.generate_obfuscated_script(self.ctx, r)
|
||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\core\__init__.py", line 95, in generate_obfuscated_script
|
||||
return m.generate_obfuscated_script(ctx, res)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
|
||||
File "<maker>", line 728, in generate_obfuscated_script
|
||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__init__.py", line 16, in process
|
||||
return meth(self, res, *args, **kwargs)
|
||||
File "<maker>", line 553, in process
|
||||
File "<maker>", line 559, in coserialize
|
||||
File "<maker>", line 609, in _build_ast_body
|
||||
RuntimeError: out of license
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
BACKUP_DIR=/srv/backups/db
|
||||
R2_BUCKET=r2:verimund-backups
|
||||
|
||||
# Database backup
|
||||
pg_dump -h 127.0.0.1 -U verimund_user verimund | gzip > $BACKUP_DIR/verimund_$DATE.sql.gz
|
||||
|
||||
# Upload to R2
|
||||
rclone sync $BACKUP_DIR $R2_BUCKET/db
|
||||
|
||||
# Keep only last 7 local backups
|
||||
cd $BACKUP_DIR && ls -t | tail -n +8 | xargs -r rm
|
||||
|
||||
echo "Backup complete: $DATE"
|
||||
|
|
@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS licenses (
|
|||
license_key TEXT UNIQUE NOT NULL,
|
||||
hwid TEXT, -- NULL until first activation
|
||||
tier TEXT NOT NULL DEFAULT 'monthly', -- 'weekly' | 'monthly' | 'lifetime'
|
||||
channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta'
|
||||
expiry_date TIMESTAMPTZ, -- NULL = lifetime
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
machines_allowed INTEGER NOT NULL DEFAULT 1,
|
||||
|
|
@ -20,9 +21,13 @@ CREATE TABLE IF NOT EXISTS licenses (
|
|||
activated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Add channel column to existing tables (safe to run on already-created tables)
|
||||
ALTER TABLE licenses ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_versions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
version TEXT UNIQUE NOT NULL, -- e.g. '1.0.1'
|
||||
channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta'
|
||||
gui_py_url TEXT, -- Supabase Storage URL for screener_gui.py
|
||||
screener_py_url TEXT, -- Supabase Storage URL for stock_screener.py
|
||||
release_notes TEXT,
|
||||
|
|
@ -30,6 +35,9 @@ CREATE TABLE IF NOT EXISTS app_versions (
|
|||
released_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add channel column to existing app_versions tables (safe to run on already-created tables)
|
||||
ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_log (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
license_key TEXT,
|
||||
|
|
@ -39,21 +47,136 @@ CREATE TABLE IF NOT EXISTS auth_log (
|
|||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analyst_cache (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
pe_forward DOUBLE PRECISION,
|
||||
eps_forward DOUBLE PRECISION,
|
||||
analyst_norm DOUBLE PRECISION,
|
||||
analyst_upside DOUBLE PRECISION,
|
||||
analyst_count INTEGER,
|
||||
analyst_target DOUBLE PRECISION,
|
||||
recommendation TEXT,
|
||||
news_sentiment DOUBLE PRECISION,
|
||||
sector TEXT,
|
||||
industry TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fundamentals_cache (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
-- Valuation
|
||||
pe_trailing DOUBLE PRECISION,
|
||||
pb_ratio DOUBLE PRECISION,
|
||||
ev_ebitda DOUBLE PRECISION,
|
||||
eps_trailing DOUBLE PRECISION,
|
||||
-- Growth
|
||||
revenue DOUBLE PRECISION,
|
||||
revenue_growth DOUBLE PRECISION,
|
||||
earnings_growth DOUBLE PRECISION,
|
||||
-- Quality
|
||||
roe DOUBLE PRECISION,
|
||||
roa DOUBLE PRECISION,
|
||||
debt_to_equity DOUBLE PRECISION,
|
||||
total_debt DOUBLE PRECISION,
|
||||
total_cash DOUBLE PRECISION,
|
||||
book_value DOUBLE PRECISION,
|
||||
current_ratio DOUBLE PRECISION,
|
||||
-- Profitability
|
||||
profit_margin DOUBLE PRECISION,
|
||||
operating_margin DOUBLE PRECISION,
|
||||
fcf_yield DOUBLE PRECISION,
|
||||
dividend_yield DOUBLE PRECISION,
|
||||
-- Market
|
||||
market_cap DOUBLE PRECISION,
|
||||
shares_outstanding DOUBLE PRECISION,
|
||||
ev_revenue DOUBLE PRECISION,
|
||||
-- Risk
|
||||
short_percent DOUBLE PRECISION,
|
||||
-- Meta
|
||||
filer_type TEXT, -- 'us-gaap-quarterly' | 'us-gaap-annual' | 'ifrs-annual' | 'none'
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sector_stats (
|
||||
sector TEXT PRIMARY KEY,
|
||||
-- Valuation
|
||||
pe_trailing_med DOUBLE PRECISION, pe_trailing_mad DOUBLE PRECISION,
|
||||
pb_ratio_med DOUBLE PRECISION, pb_ratio_mad DOUBLE PRECISION,
|
||||
ev_ebitda_med DOUBLE PRECISION, ev_ebitda_mad DOUBLE PRECISION,
|
||||
ev_revenue_med DOUBLE PRECISION, ev_revenue_mad DOUBLE PRECISION,
|
||||
-- Growth
|
||||
revenue_growth_med DOUBLE PRECISION, revenue_growth_mad DOUBLE PRECISION,
|
||||
earnings_growth_med DOUBLE PRECISION, earnings_growth_mad DOUBLE PRECISION,
|
||||
eps_growth_med DOUBLE PRECISION, eps_growth_mad DOUBLE PRECISION,
|
||||
-- Quality
|
||||
roe_med DOUBLE PRECISION, roe_mad DOUBLE PRECISION,
|
||||
roa_med DOUBLE PRECISION, roa_mad DOUBLE PRECISION,
|
||||
debt_to_equity_med DOUBLE PRECISION, debt_to_equity_mad DOUBLE PRECISION,
|
||||
current_ratio_med DOUBLE PRECISION, current_ratio_mad DOUBLE PRECISION,
|
||||
-- Profitability
|
||||
profit_margin_med DOUBLE PRECISION, profit_margin_mad DOUBLE PRECISION,
|
||||
operating_margin_med DOUBLE PRECISION, operating_margin_mad DOUBLE PRECISION,
|
||||
fcf_yield_med DOUBLE PRECISION, fcf_yield_mad DOUBLE PRECISION,
|
||||
-- Analyst
|
||||
analyst_upside_med DOUBLE PRECISION, analyst_upside_mad DOUBLE PRECISION,
|
||||
-- Meta
|
||||
ticker_count INTEGER,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add columns that may be missing if sector_stats was created before the current schema
|
||||
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_med DOUBLE PRECISION;
|
||||
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_mad DOUBLE PRECISION;
|
||||
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS ticker_count INTEGER;
|
||||
|
||||
ALTER TABLE sector_stats ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'sector_stats' AND policyname = 'anon_select'
|
||||
) THEN
|
||||
CREATE POLICY anon_select ON sector_stats FOR SELECT TO anon USING (true);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE fundamentals_cache ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'fundamentals_cache' AND policyname = 'anon_select'
|
||||
) THEN
|
||||
CREATE POLICY anon_select ON fundamentals_cache FOR SELECT TO anon USING (true);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Seed the initial version row so the updater has something to compare against.
|
||||
INSERT INTO app_versions (version, is_latest, release_notes)
|
||||
VALUES ('1.0.0', true, 'Initial release')
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
SELECT '1.0.0', true, 'Initial release'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM app_versions WHERE version = '1.0.0');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. ROW LEVEL SECURITY — no direct table access for anon
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE licenses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE licenses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE analyst_cache ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- No RLS policies = anon/authenticated cannot SELECT/INSERT/UPDATE/DELETE directly.
|
||||
-- All access goes through SECURITY DEFINER functions below.
|
||||
-- No RLS policies on licenses/app_versions/auth_log = anon cannot access directly.
|
||||
-- All sensitive access goes through SECURITY DEFINER functions below.
|
||||
|
||||
-- analyst_cache is public market data — allow the anon key to read it.
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'analyst_cache' AND policyname = 'anon_select'
|
||||
) THEN
|
||||
CREATE POLICY anon_select ON analyst_cache FOR SELECT TO anon USING (true);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. RPC: verify_license(p_key, p_hwid) → JSON
|
||||
|
|
@ -126,6 +249,7 @@ BEGIN
|
|||
'valid', true,
|
||||
'reason', 'ok',
|
||||
'tier', lic.tier,
|
||||
'channel', lic.channel,
|
||||
'expiry_date', lic.expiry_date
|
||||
);
|
||||
END;
|
||||
|
|
@ -138,7 +262,7 @@ $$;
|
|||
-- Returns: { version, gui_py_url, screener_py_url, release_notes }
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_latest_version()
|
||||
CREATE OR REPLACE FUNCTION get_latest_version(p_channel TEXT DEFAULT 'stable')
|
||||
RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
|
|
@ -149,6 +273,7 @@ BEGIN
|
|||
SELECT * INTO ver
|
||||
FROM app_versions
|
||||
WHERE is_latest = true
|
||||
AND channel = p_channel
|
||||
ORDER BY released_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
|
|
@ -170,4 +295,4 @@ $$;
|
|||
-- -----------------------------------------------------------------------------
|
||||
|
||||
GRANT EXECUTE ON FUNCTION verify_license(TEXT, TEXT) TO anon;
|
||||
GRANT EXECUTE ON FUNCTION get_latest_version() TO anon;
|
||||
GRANT EXECUTE ON FUNCTION get_latest_version(TEXT) TO anon;
|
||||
|
|
|
|||
219
updater.py
219
updater.py
|
|
@ -1,11 +1,9 @@
|
|||
"""
|
||||
Auto-Updater
|
||||
============
|
||||
On every launch (after a successful license check), checks Supabase for a
|
||||
newer version. If found:
|
||||
- Downloads new screener_gui.py and stock_screener.py silently
|
||||
- If a new exe is also available, self-replaces via a helper batch script
|
||||
and restarts. The user sees nothing — the app just comes back up updated.
|
||||
On every launch (after a successful license check), checks the Verimund API
|
||||
for a newer version. If found, downloads the new exe via the API and
|
||||
self-replaces via a helper batch script.
|
||||
|
||||
Called after a successful license check on every launch.
|
||||
"""
|
||||
|
|
@ -21,31 +19,7 @@ import requests
|
|||
# ---------------------------------------------------------------------------
|
||||
# Current app version — kept in sync by push_update.py before each build.
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_VERSION = "1.3.1"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub token — fine-grained PAT with Contents:read on the release repo.
|
||||
# Required to download assets from a private GitHub repository.
|
||||
# Generate at: GitHub → Settings → Developer settings → Fine-grained tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
_GITHUB_TOKEN = "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"
|
||||
_GITHUB_REPO = "nolankovacs/ultimate-investment-tool"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase config
|
||||
# ---------------------------------------------------------------------------
|
||||
_SUPABASE_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
|
||||
_SUPABASE_ANON = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
||||
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllaXNwY3BtZXBqZWxmYmhma3dyIiwi"
|
||||
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzM1MDkxOTAsImV4cCI6MjA4OTA4NTE5MH0."
|
||||
"8kq6HzpwF81_Sx36MXbx0wUqaT9Ul9POw1LzF5vxiOo"
|
||||
)
|
||||
|
||||
_HEADERS = {
|
||||
"apikey": _SUPABASE_ANON,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
APP_VERSION = "1.3.8"
|
||||
|
||||
|
||||
def _exe_dir() -> str:
|
||||
|
|
@ -61,49 +35,59 @@ def _parse_version(v: str) -> tuple:
|
|||
return (0, 0, 0)
|
||||
|
||||
|
||||
def _download_file(url: str, dest_path: str) -> None:
|
||||
"""Download url → dest_path atomically via a temp file."""
|
||||
dl_headers = {}
|
||||
if _GITHUB_TOKEN and "github.com" in url:
|
||||
dl_headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
|
||||
# GitHub API asset endpoint requires this header to stream the binary
|
||||
if "api.github.com" in url and "/releases/assets/" in url:
|
||||
dl_headers["Accept"] = "application/octet-stream"
|
||||
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
tmp_path = os.path.join(tmp_dir, os.path.basename(dest_path))
|
||||
def _log_update_error(msg: str) -> None:
|
||||
"""Log update errors silently — never let this crash the app."""
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=60, headers=dl_headers) as r:
|
||||
r.raise_for_status()
|
||||
with open(tmp_path, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
shutil.move(tmp_path, dest_path)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
from license_check import get_api_headers, _API_URL
|
||||
from hwid import get_hwid
|
||||
hwid = get_hwid()
|
||||
import license_check
|
||||
channel = license_check.get_channel()
|
||||
requests.post(
|
||||
f"{_API_URL}/log",
|
||||
headers=get_api_headers(),
|
||||
json={
|
||||
"hwid": hwid,
|
||||
"app_version": APP_VERSION,
|
||||
"channel": channel,
|
||||
"message": msg,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _self_replace_exe(exe_url: str) -> None:
|
||||
def _self_replace_exe(jwt_token: str, channel: str) -> None:
|
||||
"""
|
||||
Download the new exe to %TEMP%, then launch a helper batch script that
|
||||
waits for this process to exit, moves the file into place, and restarts
|
||||
the app. Nothing is left in the application directory. Never returns.
|
||||
Download the new exe from the API, then launch a helper batch script that
|
||||
waits for this process to exit, moves the file into place, and restarts.
|
||||
Never returns.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return # only meaningful in a frozen exe
|
||||
|
||||
current_exe = os.path.abspath(sys.executable)
|
||||
tmp_dir = tempfile.gettempdir()
|
||||
pending_exe = os.path.join(tmp_dir, "StockScreener_pending.exe")
|
||||
|
||||
# Download to %TEMP% — nothing lands next to the running exe
|
||||
try:
|
||||
_download_file(exe_url, pending_exe)
|
||||
except Exception as exc:
|
||||
_log_update_error(f"_download_file failed for {exe_url}: {exc}")
|
||||
return
|
||||
|
||||
# Batch script in %TEMP% — deletes itself and the pending file when done
|
||||
from license_check import _API_URL
|
||||
|
||||
current_exe = os.path.abspath(sys.executable)
|
||||
tmp_dir = tempfile.gettempdir()
|
||||
pending_exe = os.path.join(tmp_dir, "StockScreener_pending.exe")
|
||||
|
||||
try:
|
||||
with requests.post(
|
||||
f"{_API_URL}/download",
|
||||
headers={"Authorization": f"Bearer {jwt_token}", "Content-Type": "application/json"},
|
||||
stream=True,
|
||||
timeout=120,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
with open(pending_exe, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
except Exception as exc:
|
||||
_log_update_error(f"_self_replace_exe download failed: {exc}")
|
||||
return
|
||||
|
||||
helper = os.path.join(tmp_dir, "uit_update.bat")
|
||||
with open(helper, "w") as f:
|
||||
f.write(
|
||||
|
|
@ -116,7 +100,6 @@ def _self_replace_exe(exe_url: str) -> None:
|
|||
f"del \"%~f0\"\n"
|
||||
)
|
||||
|
||||
# Launch the helper detached, then exit so the running exe is unlocked
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", helper],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
|
||||
|
|
@ -127,16 +110,14 @@ def _self_replace_exe(exe_url: str) -> None:
|
|||
|
||||
def get_release_notes() -> tuple:
|
||||
"""
|
||||
Returns (version, release_notes) for the current channel from Supabase.
|
||||
Returns (version, release_notes) for the current channel from the API.
|
||||
Falls back gracefully on any error.
|
||||
"""
|
||||
try:
|
||||
import license_check
|
||||
channel = license_check.get_channel()
|
||||
resp = requests.post(
|
||||
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
|
||||
headers=_HEADERS,
|
||||
json={"p_channel": channel},
|
||||
from license_check import get_api_headers, get_jwt, _API_URL
|
||||
resp = requests.get(
|
||||
f"{_API_URL}/update",
|
||||
headers=get_api_headers(),
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
|
@ -148,108 +129,34 @@ def get_release_notes() -> tuple:
|
|||
return APP_VERSION, "Unable to fetch release notes."
|
||||
|
||||
|
||||
def _log_update_error(msg: str) -> None:
|
||||
"""Upload update events/errors to Supabase update_logs table."""
|
||||
# Remote upload only — no local file created
|
||||
try:
|
||||
hwid = "unknown"
|
||||
channel = "unknown"
|
||||
try:
|
||||
from hwid import get_hwid
|
||||
hwid = get_hwid()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import license_check
|
||||
channel = license_check.get_channel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
requests.post(
|
||||
f"{_SUPABASE_URL}/rest/v1/update_logs",
|
||||
headers={
|
||||
**_HEADERS,
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=minimal",
|
||||
},
|
||||
json={
|
||||
"hwid": hwid,
|
||||
"app_version": APP_VERSION,
|
||||
"channel": channel,
|
||||
"message": msg,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass # never let logging break the app
|
||||
|
||||
|
||||
def _get_github_exe_url(version: str, channel: str) -> str | None:
|
||||
"""
|
||||
Query the GitHub Releases API to find the StockScreener.exe asset URL
|
||||
for the given version and channel. Used as a fallback when Supabase
|
||||
doesn't return exe_url.
|
||||
"""
|
||||
try:
|
||||
tag = f"v{version}-{channel}" if channel != "stable" else f"v{version}"
|
||||
resp = requests.get(
|
||||
f"https://api.github.com/repos/{_GITHUB_REPO}/releases/tags/{tag}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {_GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
for asset in resp.json().get("assets", []):
|
||||
if asset["name"] == "StockScreener.exe":
|
||||
return asset["url"] # API asset URL — requires octet-stream header
|
||||
_log_update_error(f"GitHub release {tag} found but no StockScreener.exe asset")
|
||||
return None
|
||||
except Exception as exc:
|
||||
_log_update_error(f"_get_github_exe_url({version}, {channel}) failed: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def check_and_apply_update() -> None:
|
||||
"""
|
||||
Silently check for updates and apply them. Errors are swallowed so a
|
||||
failed update check never blocks the app from launching.
|
||||
"""
|
||||
try:
|
||||
import license_check
|
||||
channel = license_check.get_channel()
|
||||
from license_check import get_api_headers, get_jwt, get_channel, _API_URL
|
||||
|
||||
# Use the RPC — it runs with elevated DB permissions (SECURITY DEFINER)
|
||||
# so the anon key can read the table through it even with RLS enabled.
|
||||
resp = requests.post(
|
||||
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
|
||||
headers=_HEADERS,
|
||||
json={"p_channel": channel},
|
||||
resp = requests.get(
|
||||
f"{_API_URL}/update",
|
||||
headers=get_api_headers(),
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
latest = data.get("version", APP_VERSION)
|
||||
channel = data.get("channel", "stable")
|
||||
|
||||
if _parse_version(latest) <= _parse_version(APP_VERSION):
|
||||
return # already up to date
|
||||
|
||||
# Always resolve the download URL via the GitHub API — direct
|
||||
# github.com/releases/download URLs return 404 for private repos
|
||||
# when accessed with a token outside of a browser session.
|
||||
exe_url = _get_github_exe_url(latest, channel)
|
||||
if not exe_url:
|
||||
_log_update_error(
|
||||
f"Update available ({APP_VERSION} → {latest}) but could not "
|
||||
f"locate the asset on GitHub for channel={channel}"
|
||||
)
|
||||
jwt_token = get_jwt()
|
||||
if not jwt_token:
|
||||
_log_update_error("No JWT token available for update download")
|
||||
return
|
||||
|
||||
# Self-replace exe if a new one is available (exits this process)
|
||||
_self_replace_exe(exe_url)
|
||||
_self_replace_exe(jwt_token, channel)
|
||||
|
||||
except Exception as exc:
|
||||
_log_update_error(f"check_and_apply_update failed: {exc}")
|
||||
# never crash the app over a failed update check
|
||||
|
|
|
|||
Loading…
Reference in New Issue