Add EDGAR bulk fetch, Supabase analyst cache, nightly GitHub Actions workflow
- Replace per-ticker yf.Ticker.info with EDGAR XBRL frames API (~50 bulk calls)
- Add nightly cache_builder.py: fetches analyst/news/sector data for all US stocks
- Add .github/workflows/cache_refresh.yml: runs cache_builder Mon-Fri at 2am EST
- Fix sector/industry: cache_builder stores sector from yfinance; screener reads it
- Fix PyInstaller: add collect_all('tzdata'), set upx=False to prevent archive corruption
- Fix EDGAR row format: use dict keys (cik/val) instead of array indices
- Fix yfinance MultiIndex: squeeze single-ticker DataFrame to Series
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4afd71ba86
commit
9c7588c396
|
|
@ -0,0 +1,35 @@
|
|||
name: Refresh Analyst Cache
|
||||
|
||||
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
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90 # analyst phase ~50 min + news phase ~6 min
|
||||
|
||||
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
|
||||
Binary file not shown.
|
|
@ -3,25 +3,24 @@ 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',
|
||||
'hwid', 'license_check', 'activation_dialog', 'updater',
|
||||
]
|
||||
|
||||
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']
|
||||
tmp_ret = collect_all('pandas')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('numpy')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('yfinance')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
tmp_ret = collect_all('matplotlib')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
tmp_ret = collect_all('pandas')
|
||||
tmp_ret = collect_all('requests')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('certifi')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('charset_normalizer')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('vaderSentiment')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('tzdata')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
|
|
@ -38,10 +37,7 @@ a = Analysis(
|
|||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
# Bytecode encryption key (requires tinyaes + PyInstaller 5.x)
|
||||
# If your PyInstaller version does not support this, remove the key= line.
|
||||
pyz = PYZ(a.pure, cipher=None) # swap cipher=None for cipher=block_cipher if using --key
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
|
|
@ -53,7 +49,7 @@ exe = EXE(
|
|||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
|
|
@ -62,4 +58,5 @@ exe = EXE(
|
|||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
uac_admin=True, # embed UAC manifest: always run as Administrator
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,24 +7,84 @@ saves it to disk so it won't be asked again.
|
|||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
import license_check
|
||||
from hwid import get_hwid
|
||||
|
||||
# ---- Colors matching the screener's dark theme ----
|
||||
_BG = "#1e1e2e"
|
||||
_PANEL = "#2a2a3e"
|
||||
_ACCENT = "#4f8ef7"
|
||||
_TEXT = "#e0e0f0"
|
||||
_SUBTEXT = "#888899"
|
||||
_ERROR = "#ff6b6b"
|
||||
_SUCCESS = "#50fa7b"
|
||||
_BORDER = "#3a3a5e"
|
||||
_ENTRY_BG = "#12121f"
|
||||
# ---- Colors — exact match to the main app theme ----
|
||||
_BG = "#000000"
|
||||
_ACCENT = "#00e676"
|
||||
_TEXT = "#ffffff"
|
||||
_SUBTEXT = "#555555"
|
||||
_ERROR = "#ff4545"
|
||||
_SUCCESS = "#00e676"
|
||||
_BORDER = "#0d0d0d"
|
||||
|
||||
|
||||
def run_activation() -> bool:
|
||||
# ---- Shared pill-shape helper ------------------------------------------------
|
||||
|
||||
def _pill_pts(w, h, r=10):
|
||||
return [r,0, w-r,0, w,0, w,r, w,h-r, w,h, w-r,h, r,h, 0,h, 0,h-r, 0,r, 0,0]
|
||||
|
||||
|
||||
# ---- Pill entry (borderless green canvas entry) ------------------------------
|
||||
|
||||
def _make_pill_entry(parent, textvariable, width_px=380, height_px=40,
|
||||
font=("Consolas", 11)):
|
||||
"""Return a (frame, entry) tuple — a pill-shaped green entry."""
|
||||
r = height_px // 2
|
||||
frame = tk.Frame(parent, bg=_BG, bd=0, highlightthickness=0)
|
||||
cv = tk.Canvas(frame, width=width_px, height=height_px,
|
||||
highlightthickness=0, bd=0, bg=_BG)
|
||||
cv.pack()
|
||||
pts = _pill_pts(width_px, height_px, r)
|
||||
cv.create_polygon(pts, smooth=True, fill=_ACCENT, outline=_ACCENT, width=0)
|
||||
inner = tk.Frame(cv, bg=_ACCENT, bd=0, highlightthickness=0)
|
||||
cv.create_window(width_px // 2, height_px // 2, window=inner,
|
||||
width=width_px - 10, height=height_px - 8)
|
||||
entry = tk.Entry(inner, textvariable=textvariable,
|
||||
font=font, bg=_ACCENT, fg="#000000",
|
||||
insertbackground="#000000", relief="flat",
|
||||
bd=0, highlightthickness=0)
|
||||
entry.pack(fill="x", expand=True, padx=8, ipady=2)
|
||||
return frame, entry
|
||||
|
||||
|
||||
# ---- Pill button (borderless canvas button) ----------------------------------
|
||||
|
||||
class _PillBtn(tk.Canvas):
|
||||
def __init__(self, parent, text, command,
|
||||
width=120, height=36, radius=10,
|
||||
bg=_ACCENT, fg="#000000"):
|
||||
super().__init__(parent, width=width, height=height,
|
||||
highlightthickness=0, bd=0, bg=parent.cget("bg"))
|
||||
self._bg = bg
|
||||
self._fg = fg
|
||||
self._command = command
|
||||
self._enabled = True
|
||||
pts = _pill_pts(width, height, radius)
|
||||
self._rect = self.create_polygon(pts, smooth=True,
|
||||
fill=bg, outline=bg, width=0)
|
||||
self._lbl = self.create_text(width // 2, height // 2,
|
||||
text=text, fill=fg,
|
||||
font=("Segoe UI", 10, "bold"))
|
||||
self.config(cursor="hand2")
|
||||
self.bind("<Button-1>", lambda _e: self._command() if self._enabled else None)
|
||||
self.bind("<Enter>", lambda _e: self.itemconfig(self._rect, fill="#00c060") if self._enabled else None)
|
||||
self.bind("<Leave>", lambda _e: self.itemconfig(self._rect, fill=self._bg))
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
self._enabled = enabled
|
||||
self.itemconfig(self._rect, fill=self._bg if enabled else _BORDER)
|
||||
self.itemconfig(self._lbl, fill=self._fg if enabled else _SUBTEXT)
|
||||
self.config(cursor="hand2" if enabled else "")
|
||||
|
||||
def set_text(self, text: str):
|
||||
self.itemconfig(self._lbl, text=text)
|
||||
|
||||
|
||||
# ---- Main dialog ------------------------------------------------------------
|
||||
|
||||
def run_activation(hwid: str) -> bool:
|
||||
"""
|
||||
Open the activation dialog and block until the user either activates
|
||||
successfully or closes the window.
|
||||
|
|
@ -34,7 +94,7 @@ def run_activation() -> bool:
|
|||
activated = [False]
|
||||
|
||||
root = tk.Tk()
|
||||
root.title("Ultimate Investment Tool — Activation")
|
||||
root.title("Ultimate Investment Tool — License Activation")
|
||||
root.configure(bg=_BG)
|
||||
root.resizable(False, False)
|
||||
|
||||
|
|
@ -45,108 +105,78 @@ def run_activation() -> bool:
|
|||
y = (root.winfo_screenheight() - h) // 2
|
||||
root.geometry(f"{w}x{h}+{x}+{y}")
|
||||
|
||||
# Intercept close button — same as clicking Exit
|
||||
root.protocol("WM_DELETE_WINDOW", lambda: _on_exit(root, activated))
|
||||
|
||||
# ---- Header ----
|
||||
header = tk.Frame(root, bg=_ACCENT, height=4)
|
||||
header.pack(fill="x")
|
||||
# ---- Header accent bar ----
|
||||
tk.Frame(root, bg=_ACCENT, height=4).pack(fill="x")
|
||||
|
||||
tk.Label(
|
||||
root, text="Ultimate Investment Tool",
|
||||
font=("Segoe UI", 16, "bold"),
|
||||
bg=_BG, fg=_TEXT
|
||||
).pack(pady=(28, 4))
|
||||
tk.Label(root, text="Ultimate Investment Tool",
|
||||
font=("Segoe UI", 16, "bold"),
|
||||
bg=_BG, fg=_TEXT).pack(pady=(28, 4))
|
||||
|
||||
tk.Label(
|
||||
root, text="Enter your license key to activate this software.",
|
||||
font=("Segoe UI", 9),
|
||||
bg=_BG, fg=_SUBTEXT
|
||||
).pack()
|
||||
|
||||
# ---- Key entry ----
|
||||
entry_frame = tk.Frame(root, bg=_PANEL, bd=0, highlightthickness=1,
|
||||
highlightbackground=_BORDER)
|
||||
entry_frame.pack(padx=40, pady=24, fill="x")
|
||||
tk.Label(root, text="Enter your license key to activate this software.",
|
||||
font=("Segoe UI", 9),
|
||||
bg=_BG, fg=_SUBTEXT).pack()
|
||||
|
||||
# ---- Pill key entry ----
|
||||
key_var = tk.StringVar()
|
||||
key_entry = tk.Entry(
|
||||
entry_frame, textvariable=key_var,
|
||||
font=("Consolas", 11), bg=_ENTRY_BG, fg=_TEXT,
|
||||
insertbackground=_TEXT, relief="flat",
|
||||
bd=10, width=36
|
||||
)
|
||||
key_entry.pack(fill="x")
|
||||
ef, key_entry = _make_pill_entry(root, key_var, width_px=400, height_px=42)
|
||||
ef.pack(pady=22)
|
||||
key_entry.focus()
|
||||
|
||||
# ---- Status label ----
|
||||
status_var = tk.StringVar(value="")
|
||||
status_label = tk.Label(
|
||||
root, textvariable=status_var,
|
||||
font=("Segoe UI", 9),
|
||||
bg=_BG, fg=_ERROR, wraplength=400
|
||||
)
|
||||
status_label = tk.Label(root, textvariable=status_var,
|
||||
font=("Segoe UI", 9),
|
||||
bg=_BG, fg=_ERROR, wraplength=420)
|
||||
status_label.pack(pady=(0, 8))
|
||||
|
||||
# ---- Buttons ----
|
||||
# ---- Pill buttons ----
|
||||
btn_frame = tk.Frame(root, bg=_BG)
|
||||
btn_frame.pack(pady=4)
|
||||
|
||||
activate_btn = tk.Button(
|
||||
btn_frame, text="Activate",
|
||||
font=("Segoe UI", 10, "bold"),
|
||||
bg=_ACCENT, fg="white",
|
||||
activebackground="#3a7ae0", activeforeground="white",
|
||||
relief="flat", cursor="hand2", padx=24, pady=8,
|
||||
command=lambda: _on_activate(root, key_var, status_var, status_label,
|
||||
activate_btn, activated)
|
||||
)
|
||||
activate_btn.grid(row=0, column=0, padx=8)
|
||||
activate_btn = _PillBtn(btn_frame, "Activate",
|
||||
command=lambda: _on_activate(
|
||||
root, key_var, status_var, status_label,
|
||||
activate_btn, activated, hwid),
|
||||
width=130, height=38, bg=_ACCENT, fg="#000000")
|
||||
activate_btn.grid(row=0, column=0, padx=10)
|
||||
|
||||
exit_btn = tk.Button(
|
||||
btn_frame, text="Exit",
|
||||
font=("Segoe UI", 10),
|
||||
bg=_PANEL, fg=_SUBTEXT,
|
||||
activebackground=_BORDER, activeforeground=_TEXT,
|
||||
relief="flat", cursor="hand2", padx=24, pady=8,
|
||||
command=lambda: _on_exit(root, activated)
|
||||
)
|
||||
exit_btn.grid(row=0, column=1, padx=8)
|
||||
exit_btn = _PillBtn(btn_frame, "Exit",
|
||||
command=lambda: _on_exit(root, activated),
|
||||
width=110, height=38, bg=_BORDER, fg=_SUBTEXT)
|
||||
exit_btn.grid(row=0, column=1, padx=10)
|
||||
|
||||
# Allow Enter key to trigger activation
|
||||
root.bind("<Return>", lambda e: _on_activate(
|
||||
root, key_var, status_var, status_label, activate_btn, activated
|
||||
))
|
||||
root, key_var, status_var, status_label, activate_btn, activated, hwid))
|
||||
|
||||
# ---- Footer ----
|
||||
tk.Label(
|
||||
root, text="Need a license? Contact us to subscribe.",
|
||||
font=("Segoe UI", 8),
|
||||
bg=_BG, fg=_SUBTEXT
|
||||
).pack(side="bottom", pady=14)
|
||||
tk.Label(root, text="Need a license? Contact us to subscribe.",
|
||||
font=("Segoe UI", 8),
|
||||
bg=_BG, fg=_SUBTEXT).pack(side="bottom", pady=14)
|
||||
|
||||
root.mainloop()
|
||||
return activated[0]
|
||||
|
||||
|
||||
def _on_activate(root, key_var, status_var, status_label, activate_btn, activated):
|
||||
def _on_activate(root, key_var, status_var, status_label,
|
||||
activate_btn, activated, hwid):
|
||||
key = key_var.get().strip()
|
||||
|
||||
if not key:
|
||||
status_var.set("Please enter a license key.")
|
||||
return
|
||||
|
||||
# Show checking state
|
||||
activate_btn.config(state="disabled", text="Checking…")
|
||||
activate_btn.set_enabled(False)
|
||||
activate_btn.set_text("Checking…")
|
||||
status_var.set("Contacting license server…")
|
||||
status_label.config(fg=_SUBTEXT)
|
||||
root.update()
|
||||
|
||||
try:
|
||||
hwid = get_hwid()
|
||||
valid, reason = license_check.verify_license(key, hwid)
|
||||
except Exception as exc:
|
||||
activate_btn.config(state="normal", text="Activate")
|
||||
activate_btn.set_enabled(True)
|
||||
activate_btn.set_text("Activate")
|
||||
status_var.set(f"Error: {exc}")
|
||||
status_label.config(fg=_ERROR)
|
||||
return
|
||||
|
|
@ -158,7 +188,8 @@ def _on_activate(root, key_var, status_var, status_label, activate_btn, activate
|
|||
activated[0] = True
|
||||
root.after(900, root.destroy)
|
||||
else:
|
||||
activate_btn.config(state="normal", text="Activate")
|
||||
activate_btn.set_enabled(True)
|
||||
activate_btn.set_text("Activate")
|
||||
status_var.set(reason)
|
||||
status_label.config(fg=_ERROR)
|
||||
|
||||
|
|
|
|||
40
build.bat
40
build.bat
|
|
@ -6,7 +6,7 @@ echo ======================================
|
|||
echo.
|
||||
|
||||
echo Installing dependencies...
|
||||
python -m pip install pyinstaller tinyaes matplotlib --quiet
|
||||
python -m pip install pyinstaller matplotlib --quiet
|
||||
if errorlevel 1 (
|
||||
echo ERROR: pip failed. Make sure Python is installed and on PATH.
|
||||
pause
|
||||
|
|
@ -21,48 +21,12 @@ copy /Y dist\stock_screener.py stock_screener.py >nul
|
|||
echo Building executable...
|
||||
echo.
|
||||
|
||||
python -m PyInstaller ^
|
||||
--onefile ^
|
||||
--windowed ^
|
||||
--key "UIT-K3y-2025-Ultr4Inv3st" ^
|
||||
--name "StockScreener" ^
|
||||
--hidden-import pandas ^
|
||||
--hidden-import numpy ^
|
||||
--hidden-import yfinance ^
|
||||
--hidden-import requests ^
|
||||
--hidden-import lxml ^
|
||||
--hidden-import lxml.etree ^
|
||||
--hidden-import html5lib ^
|
||||
--hidden-import bs4 ^
|
||||
--hidden-import appdirs ^
|
||||
--hidden-import platformdirs ^
|
||||
--hidden-import tkinter ^
|
||||
--hidden-import tkinter.ttk ^
|
||||
--hidden-import tkinter.messagebox ^
|
||||
--hidden-import screener_gui ^
|
||||
--hidden-import stock_screener ^
|
||||
--hidden-import matplotlib ^
|
||||
--hidden-import matplotlib.backends.backend_tkagg ^
|
||||
--hidden-import matplotlib.figure ^
|
||||
--hidden-import matplotlib.dates ^
|
||||
--hidden-import matplotlib.ticker ^
|
||||
--hidden-import winreg ^
|
||||
--hidden-import hwid ^
|
||||
--hidden-import license_check ^
|
||||
--hidden-import activation_dialog ^
|
||||
--hidden-import updater ^
|
||||
--collect-all yfinance ^
|
||||
--collect-all matplotlib ^
|
||||
launcher.py
|
||||
python -m PyInstaller StockScreener.spec
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo BUILD FAILED. See errors above.
|
||||
echo.
|
||||
echo NOTE: If the error mentions '--key' or 'tinyaes', your PyInstaller version
|
||||
echo may not support bytecode encryption. Remove the '--key' line above
|
||||
echo and rebuild. Encryption requires PyInstaller 5.x + tinyaes.
|
||||
echo.
|
||||
echo Cleaning up temporary files...
|
||||
if exist screener_gui.py del screener_gui.py
|
||||
if exist stock_screener.py del stock_screener.py
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
"""
|
||||
Analyst & News Cache Builder
|
||||
============================
|
||||
Runs nightly via GitHub Actions. Fetches analyst consensus data and
|
||||
news sentiment for every US-listed stock and upserts results into the
|
||||
Supabase analyst_cache table.
|
||||
|
||||
Phase 1 — Analyst data via yf.Ticker.info (~50 min for 6,500 tickers)
|
||||
Phase 2 — News sentiment via Google News RSS (~6 min, 20 workers)
|
||||
|
||||
Each phase writes to Supabase incrementally every UPSERT_EVERY records
|
||||
so a partial run is never fully lost.
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
import yfinance as yf
|
||||
|
||||
try:
|
||||
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
||||
_VADER = SentimentIntensityAnalyzer()
|
||||
except ImportError:
|
||||
_VADER = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase credentials — injected as GitHub Actions secrets
|
||||
# ---------------------------------------------------------------------------
|
||||
_SUPABASE_URL = os.environ["SUPABASE_URL"]
|
||||
_SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
|
||||
|
||||
_SUPABASE_HEADERS = {
|
||||
"apikey": _SUPABASE_KEY,
|
||||
"Authorization": f"Bearer {_SUPABASE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "resolution=merge-duplicates",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
_TICKER_RE = re.compile(r'^[A-Z]{1,5}(-[A-Z]{1,2})?$')
|
||||
_HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
||||
|
||||
INFO_BATCH = 20
|
||||
INFO_PAUSE = 4.0
|
||||
MAX_WORKERS = 5
|
||||
NEWS_WORKERS = 20
|
||||
UPSERT_EVERY = 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ticker fetching (NASDAQ Trader files — same source as the screener)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clean_ticker(symbol: str) -> str | None:
|
||||
t = symbol.strip().upper().replace(".", "-")
|
||||
return t if _TICKER_RE.match(t) else None
|
||||
|
||||
|
||||
def fetch_tickers() -> list[str]:
|
||||
"""Return all US common-stock tickers from NASDAQ Trader listing files."""
|
||||
tickers = []
|
||||
base = "https://www.nasdaqtrader.com/dynamic/SymDir/"
|
||||
|
||||
# NASDAQ-listed stocks
|
||||
try:
|
||||
resp = requests.get(base + "nasdaqlisted.txt", headers=_HTTP_HEADERS, timeout=20)
|
||||
resp.raise_for_status()
|
||||
for line in resp.text.splitlines()[1:]:
|
||||
if line.startswith("File Creation"):
|
||||
break
|
||||
parts = line.split("|")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
if parts[3].strip() == "Y" or parts[6].strip() == "Y": # test/ETF
|
||||
continue
|
||||
t = _clean_ticker(parts[0].strip())
|
||||
if t:
|
||||
tickers.append(t)
|
||||
print(f" NASDAQ listed : {sum(1 for _ in tickers)} tickers")
|
||||
except Exception as e:
|
||||
print(f" [WARN] nasdaqlisted.txt: {e}")
|
||||
|
||||
# NYSE / AMEX listed stocks
|
||||
try:
|
||||
resp = requests.get(base + "otherlisted.txt", headers=_HTTP_HEADERS, timeout=20)
|
||||
resp.raise_for_status()
|
||||
count = 0
|
||||
for line in resp.text.splitlines()[1:]:
|
||||
if line.startswith("File Creation"):
|
||||
break
|
||||
parts = line.split("|")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
if parts[2].strip() not in {"N", "A"}: # NYSE=N, AMEX=A only
|
||||
continue
|
||||
if parts[4].strip() == "Y" or parts[6].strip() == "Y": # ETF/test
|
||||
continue
|
||||
t = _clean_ticker(parts[0].strip())
|
||||
if t:
|
||||
tickers.append(t)
|
||||
count += 1
|
||||
print(f" NYSE/AMEX : {count} tickers")
|
||||
except Exception as e:
|
||||
print(f" [WARN] otherlisted.txt: {e}")
|
||||
|
||||
combined = list(dict.fromkeys(tickers))
|
||||
print(f" Total : {len(combined)} unique tickers\n")
|
||||
return combined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Analyst data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_analyst(ticker: str) -> dict | None:
|
||||
"""Fetch analyst consensus fields for one ticker via yf.Ticker.info."""
|
||||
time.sleep(random.uniform(0.0, 1.0))
|
||||
try:
|
||||
info = yf.Ticker(ticker).info
|
||||
if not info or not isinstance(info, dict) or len(info) < 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
rec_mean = float(info["recommendationMean"]) if info.get("recommendationMean") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
rec_mean = None
|
||||
analyst_norm = (5.0 - rec_mean) / 4.0 if rec_mean is not None and 1 <= rec_mean <= 5 else None
|
||||
|
||||
try:
|
||||
target = float(info["targetMeanPrice"]) if info.get("targetMeanPrice") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
target = None
|
||||
|
||||
price = info.get("currentPrice") or info.get("regularMarketPrice")
|
||||
try:
|
||||
price = float(price) if price is not None else None
|
||||
except (TypeError, ValueError):
|
||||
price = None
|
||||
|
||||
analyst_upside = (target / price - 1.0) if target and price and price > 0 else None
|
||||
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"pe_forward": info.get("forwardPE"),
|
||||
"eps_forward": info.get("forwardEps"),
|
||||
"analyst_norm": analyst_norm,
|
||||
"analyst_upside": analyst_upside,
|
||||
"analyst_count": info.get("numberOfAnalystOpinions"),
|
||||
"analyst_target": target,
|
||||
"recommendation": info.get("recommendationKey") or "N/A",
|
||||
"sector": info.get("sector") or None,
|
||||
"industry": info.get("industry") or None,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def run_analyst_phase(tickers: list[str]) -> None:
|
||||
print(f"Phase 1: Analyst data for {len(tickers)} tickers ...")
|
||||
total = len(tickers)
|
||||
done = 0
|
||||
pending: list[dict] = []
|
||||
start = time.time()
|
||||
|
||||
for batch_start in range(0, total, INFO_BATCH):
|
||||
batch = tickers[batch_start: batch_start + INFO_BATCH]
|
||||
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:
|
||||
futures = {ex.submit(_fetch_analyst, t): t for t in batch}
|
||||
for fut in as_completed(futures):
|
||||
done += 1
|
||||
result = fut.result()
|
||||
if result:
|
||||
pending.append(result)
|
||||
elapsed = time.time() - start
|
||||
eta = (elapsed / done) * (total - done) if done else 0
|
||||
print(
|
||||
f"\r {done}/{total} ({done / total * 100:.0f}%)"
|
||||
f" cached={len(pending)} ETA={eta:.0f}s ",
|
||||
end="", flush=True,
|
||||
)
|
||||
|
||||
if len(pending) >= UPSERT_EVERY:
|
||||
print()
|
||||
_upsert(pending)
|
||||
pending.clear()
|
||||
|
||||
if batch_start + INFO_BATCH < total:
|
||||
time.sleep(INFO_PAUSE)
|
||||
|
||||
if pending:
|
||||
print()
|
||||
_upsert(pending)
|
||||
|
||||
print(f"\n Phase 1 complete in {time.time() - start:.0f}s\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — News sentiment (Google News RSS + VADER)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_news_sentiment(ticker: str) -> dict | None:
|
||||
"""Fetch Google News RSS headlines and compute VADER compound score."""
|
||||
if _VADER is None:
|
||||
return None
|
||||
try:
|
||||
query = urllib.parse.quote(f"{ticker} stock")
|
||||
rss_url = (
|
||||
f"https://news.google.com/rss/search"
|
||||
f"?q={query}&hl=en-US&gl=US&ceid=US:en"
|
||||
)
|
||||
resp = requests.get(rss_url, headers=_HTTP_HEADERS, timeout=8)
|
||||
resp.raise_for_status()
|
||||
root = ET.fromstring(resp.content)
|
||||
scores = []
|
||||
for item in root.findall(".//item")[:15]:
|
||||
title_el = item.find("title")
|
||||
if title_el is not None and title_el.text:
|
||||
title = html.unescape(
|
||||
re.sub(r"<[^>]+>", " ", title_el.text)
|
||||
).strip()
|
||||
scores.append(_VADER.polarity_scores(title)["compound"])
|
||||
sentiment = sum(scores) / len(scores) if scores else None
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"news_sentiment": sentiment,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def run_news_phase(tickers: list[str]) -> None:
|
||||
if _VADER is None:
|
||||
print("Phase 2: Skipped — vaderSentiment not installed\n")
|
||||
return
|
||||
|
||||
print(f"Phase 2: News sentiment for {len(tickers)} tickers ...")
|
||||
total = len(tickers)
|
||||
done = 0
|
||||
pending: list[dict] = []
|
||||
start = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=NEWS_WORKERS) as ex:
|
||||
futures = {ex.submit(_fetch_news_sentiment, t): t for t in tickers}
|
||||
for fut in as_completed(futures):
|
||||
done += 1
|
||||
result = fut.result()
|
||||
if result:
|
||||
pending.append(result)
|
||||
if done % 200 == 0:
|
||||
elapsed = time.time() - start
|
||||
eta = (elapsed / done) * (total - done) if done else 0
|
||||
print(
|
||||
f"\r {done}/{total} ({done / total * 100:.0f}%)"
|
||||
f" ETA={eta:.0f}s ",
|
||||
end="", flush=True,
|
||||
)
|
||||
if len(pending) >= UPSERT_EVERY:
|
||||
_upsert(pending)
|
||||
pending.clear()
|
||||
|
||||
if pending:
|
||||
_upsert(pending)
|
||||
|
||||
print(f"\n Phase 2 complete in {time.time() - start:.0f}s\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase upsert
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _upsert(rows: list[dict]) -> None:
|
||||
"""Upsert a batch of rows into analyst_cache (merge on primary key)."""
|
||||
if not rows:
|
||||
return
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{_SUPABASE_URL}/rest/v1/analyst_cache",
|
||||
headers=_SUPABASE_HEADERS,
|
||||
json=rows,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(f" Upserted {len(rows)} rows to Supabase")
|
||||
except Exception as e:
|
||||
print(f" [WARN] Upsert failed: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 55)
|
||||
print(" ANALYST + NEWS CACHE BUILDER")
|
||||
print("=" * 55 + "\n")
|
||||
|
||||
tickers = fetch_tickers()
|
||||
if not tickers:
|
||||
print("ERROR: No tickers fetched — aborting.")
|
||||
raise SystemExit(1)
|
||||
|
||||
run_analyst_phase(tickers)
|
||||
run_news_phase(tickers)
|
||||
print("Cache build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2
hwid.py
2
hwid.py
|
|
@ -8,11 +8,13 @@ Generates a stable hardware fingerprint for this machine using:
|
|||
Returns a SHA-256 hex digest. Never changes unless hardware changes.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import subprocess
|
||||
import winreg
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def get_hwid() -> str:
|
||||
"""Return a stable SHA-256 HWID string for the current machine."""
|
||||
components = []
|
||||
|
|
|
|||
84
launcher.py
84
launcher.py
|
|
@ -6,57 +6,26 @@ Startup sequence:
|
|||
2. Load stored license key — show activation dialog if none found
|
||||
3. Verify license against Supabase (with 30-min offline grace period)
|
||||
4. Silently check for and apply updates
|
||||
5. Load and run screener_gui.py
|
||||
|
||||
Source file hygiene
|
||||
-------------------
|
||||
On every launch, the launcher scans the parent folder of the exe for stray
|
||||
copies of screener_gui.py or stock_screener.py and removes them (pulling in
|
||||
any newer version first so no edits are lost).
|
||||
5. Launch the bundled screener_gui
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
# Fix SSL certificate path for PyInstaller frozen builds.
|
||||
# Without this, requests raises "Could not find a suitable TLS CA certificate bundle".
|
||||
if getattr(sys, "frozen", False):
|
||||
import certifi
|
||||
os.environ["REQUESTS_CA_BUNDLE"] = certifi.where()
|
||||
os.environ["SSL_CERT_FILE"] = certifi.where()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _exe_dir() -> str:
|
||||
if getattr(sys, "frozen", False):
|
||||
return os.path.dirname(sys.executable)
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
_SOURCE_FILES = ("screener_gui.py", "stock_screener.py")
|
||||
|
||||
|
||||
def _sync_source_files(exe_dir: str) -> None:
|
||||
parent_dir = os.path.dirname(exe_dir)
|
||||
if parent_dir == exe_dir:
|
||||
return
|
||||
|
||||
for fname in _SOURCE_FILES:
|
||||
stray = os.path.join(parent_dir, fname)
|
||||
if not os.path.exists(stray):
|
||||
continue
|
||||
live = os.path.join(exe_dir, fname)
|
||||
if not os.path.exists(live) or os.path.getmtime(stray) > os.path.getmtime(live):
|
||||
try:
|
||||
shutil.copy2(stray, live)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.remove(stray)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _fatal(title: str, message: str) -> None:
|
||||
"""Show an error dialog and exit."""
|
||||
root = tk.Tk()
|
||||
|
|
@ -71,9 +40,6 @@ def _fatal(title: str, message: str) -> None:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
exe_dir = _exe_dir()
|
||||
_sync_source_files(exe_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: HWID
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -82,7 +48,7 @@ def main():
|
|||
hwid = get_hwid()
|
||||
except Exception as exc:
|
||||
_fatal("Ultimate Investment Tool", f"Could not generate hardware ID:\n\n{exc}")
|
||||
return # unreachable, but satisfies linters
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Load or activate license
|
||||
|
|
@ -92,12 +58,10 @@ def main():
|
|||
license_key = license_check.load_license_key(hwid)
|
||||
|
||||
if license_key is None:
|
||||
# First launch — show activation dialog
|
||||
import activation_dialog
|
||||
activated = activation_dialog.run_activation()
|
||||
activated = activation_dialog.run_activation(hwid)
|
||||
if not activated:
|
||||
sys.exit(0) # user closed the window
|
||||
# Reload after successful save
|
||||
sys.exit(0)
|
||||
license_key = license_check.load_license_key(hwid)
|
||||
if license_key is None:
|
||||
_fatal("Ultimate Investment Tool", "Activation failed. Please restart and try again.")
|
||||
|
|
@ -109,7 +73,6 @@ def main():
|
|||
valid, reason = license_check.verify_license(license_key, hwid)
|
||||
|
||||
if not valid:
|
||||
# License invalid — clear stored key so they can re-enter on next launch
|
||||
license_check.delete_license_key()
|
||||
_fatal(
|
||||
"Ultimate Investment Tool — License Error",
|
||||
|
|
@ -123,33 +86,20 @@ def main():
|
|||
try:
|
||||
import updater
|
||||
updater.check_and_apply_update()
|
||||
# If an update was applied, updater restarts the process — we never reach here.
|
||||
except Exception:
|
||||
pass # never block the app over a failed update check
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 5: Load screener_gui.py
|
||||
# Step 5: Launch bundled app
|
||||
# ------------------------------------------------------------------
|
||||
script = os.path.join(exe_dir, "screener_gui.py")
|
||||
|
||||
if os.path.exists(script):
|
||||
if exe_dir not in sys.path:
|
||||
sys.path.insert(0, exe_dir)
|
||||
spec = importlib.util.spec_from_file_location("screener_gui_live", script)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
mod.main()
|
||||
return
|
||||
|
||||
# Fallback: frozen version bundled inside the exe
|
||||
try:
|
||||
import screener_gui
|
||||
screener_gui.main()
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
import traceback
|
||||
_fatal(
|
||||
"Ultimate Investment Tool",
|
||||
f"Could not start.\n\n{exc}\n\n"
|
||||
"Make sure screener_gui.py is in the same folder as this exe."
|
||||
"Ultimate Investment Tool — Startup Error",
|
||||
f"The application failed to start:\n\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ License Check
|
|||
Validates the stored license key against Supabase on every launch.
|
||||
Falls back to a local cache if the server is unreachable (30-minute grace period).
|
||||
|
||||
Storage location: %APPDATA%\StockScreener\
|
||||
Storage location: %APPDATA%\\StockScreener\\
|
||||
license.dat — XOR-encrypted license key (machine-bound)
|
||||
auth_cache.dat — last successful auth result + timestamp
|
||||
"""
|
||||
|
|
@ -101,13 +101,21 @@ def delete_license_key() -> None:
|
|||
# Auth cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_cache(valid: bool, reason: str) -> None:
|
||||
def _save_cache(valid: bool, reason: str, channel: str = "stable") -> None:
|
||||
_ensure_dir()
|
||||
payload = {"valid": valid, "reason": reason, "timestamp": time.time()}
|
||||
payload = {"valid": valid, "reason": reason, "channel": channel, "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
|
||||
|
|
@ -142,20 +150,28 @@ def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
|
|||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
valid = bool(data.get("valid", False))
|
||||
reason = data.get("reason", "Unknown error")
|
||||
_save_cache(valid, reason)
|
||||
data = resp.json()
|
||||
valid = bool(data.get("valid", False))
|
||||
reason = data.get("reason", "Unknown error")
|
||||
channel = data.get("channel", "stable")
|
||||
_save_cache(valid, reason, channel)
|
||||
return valid, reason
|
||||
|
||||
except requests.RequestException:
|
||||
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"):
|
||||
age = time.time() - cache.get("timestamp", 0)
|
||||
if age < _GRACE_PERIOD_SECONDS:
|
||||
return True, "offline_grace"
|
||||
return (
|
||||
False,
|
||||
"Cannot reach the license server. "
|
||||
"Please check your internet connection and try again."
|
||||
)
|
||||
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"):
|
||||
age = time.time() - cache.get("timestamp", 0)
|
||||
if age < _GRACE_PERIOD_SECONDS:
|
||||
return True, "offline_grace"
|
||||
return False, f"License check failed: {e}"
|
||||
|
|
|
|||
220
updater.py
220
updater.py
|
|
@ -1,27 +1,38 @@
|
|||
"""
|
||||
Auto-Updater
|
||||
============
|
||||
Checks Supabase for a newer version of screener_gui.py and stock_screener.py.
|
||||
If found, silently downloads and replaces the files in the exe directory,
|
||||
then restarts the application.
|
||||
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.
|
||||
|
||||
Called after a successful license check on every launch.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Current app version — bump this in lockstep with new Supabase version rows
|
||||
# Current app version — kept in sync by push_update.py before each build.
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_VERSION = "1.0.0"
|
||||
APP_VERSION = "1.3.1"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supabase config (same anon key as license_check.py)
|
||||
# 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 = (
|
||||
|
|
@ -36,9 +47,6 @@ _HEADERS = {
|
|||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Files that can be updated without rebuilding the exe
|
||||
_UPDATABLE_FILES = ("screener_gui.py", "stock_screener.py")
|
||||
|
||||
|
||||
def _exe_dir() -> str:
|
||||
if getattr(sys, "frozen", False):
|
||||
|
|
@ -47,7 +55,6 @@ def _exe_dir() -> str:
|
|||
|
||||
|
||||
def _parse_version(v: str) -> tuple:
|
||||
"""Convert '1.2.3' → (1, 2, 3) for comparison."""
|
||||
try:
|
||||
return tuple(int(x) for x in v.strip().split("."))
|
||||
except Exception:
|
||||
|
|
@ -55,11 +62,18 @@ def _parse_version(v: str) -> tuple:
|
|||
|
||||
|
||||
def _download_file(url: str, dest_path: str) -> None:
|
||||
"""Download url → dest_path, using a temp file so the write is atomic."""
|
||||
"""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))
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=30) as r:
|
||||
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):
|
||||
|
|
@ -69,53 +83,173 @@ def _download_file(url: str, dest_path: str) -> None:
|
|||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _restart() -> None:
|
||||
"""Restart the current process cleanly."""
|
||||
if getattr(sys, "frozen", False):
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv[1:])
|
||||
else:
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
def _self_replace_exe(exe_url: 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.
|
||||
"""
|
||||
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
|
||||
helper = os.path.join(tmp_dir, "uit_update.bat")
|
||||
with open(helper, "w") as f:
|
||||
f.write(
|
||||
f"@echo off\n"
|
||||
f":retry\n"
|
||||
f"timeout /t 1 /nobreak >nul\n"
|
||||
f"move /Y \"{pending_exe}\" \"{current_exe}\"\n"
|
||||
f"if errorlevel 1 goto retry\n"
|
||||
f"start \"\" \"{current_exe}\"\n"
|
||||
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,
|
||||
close_fds=True,
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def get_release_notes() -> tuple:
|
||||
"""
|
||||
Returns (version, release_notes) for the current channel from Supabase.
|
||||
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},
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
version = data.get("version", APP_VERSION)
|
||||
notes = data.get("release_notes") or "No release notes available."
|
||||
return version, notes
|
||||
except Exception:
|
||||
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 if available.
|
||||
Errors are swallowed — a failed update check never blocks the app.
|
||||
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()
|
||||
|
||||
# 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={},
|
||||
json={"p_channel": channel},
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
latest = data.get("version", APP_VERSION)
|
||||
gui_url = data.get("gui_py_url")
|
||||
scr_url = data.get("screener_py_url")
|
||||
latest = data.get("version", APP_VERSION)
|
||||
|
||||
if _parse_version(latest) <= _parse_version(APP_VERSION):
|
||||
return # already up to date
|
||||
|
||||
exe_dir = _exe_dir()
|
||||
updated = False
|
||||
# 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}"
|
||||
)
|
||||
return
|
||||
|
||||
if gui_url:
|
||||
dest = os.path.join(exe_dir, "screener_gui.py")
|
||||
_download_file(gui_url, dest)
|
||||
updated = True
|
||||
# Self-replace exe if a new one is available (exits this process)
|
||||
_self_replace_exe(exe_url)
|
||||
|
||||
if scr_url:
|
||||
dest = os.path.join(exe_dir, "stock_screener.py")
|
||||
_download_file(scr_url, dest)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
# Restart so the fresh .py files are loaded
|
||||
_restart()
|
||||
|
||||
except Exception:
|
||||
# Never crash the app over a failed update check
|
||||
pass
|
||||
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