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 = []
|
datas = []
|
||||||
binaries = []
|
binaries = []
|
||||||
hiddenimports = [
|
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']
|
||||||
'pandas', 'numpy', 'yfinance', 'requests',
|
tmp_ret = collect_all('pandas')
|
||||||
'lxml', 'lxml.etree', 'html5lib', 'bs4',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'appdirs', 'platformdirs',
|
tmp_ret = collect_all('numpy')
|
||||||
'tkinter', 'tkinter.ttk', 'tkinter.messagebox',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'screener_gui', 'stock_screener',
|
|
||||||
'matplotlib', 'matplotlib.backends.backend_tkagg',
|
|
||||||
'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker',
|
|
||||||
'winreg',
|
|
||||||
'hwid', 'license_check', 'activation_dialog', 'updater',
|
|
||||||
]
|
|
||||||
|
|
||||||
tmp_ret = collect_all('yfinance')
|
tmp_ret = collect_all('yfinance')
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
|
|
||||||
tmp_ret = collect_all('matplotlib')
|
tmp_ret = collect_all('matplotlib')
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
|
tmp_ret = collect_all('requests')
|
||||||
tmp_ret = collect_all('pandas')
|
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]
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,10 +37,7 @@ a = Analysis(
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
# 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
|
|
||||||
|
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
|
|
@ -53,7 +49,7 @@ exe = EXE(
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=False,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=False,
|
console=False,
|
||||||
|
|
@ -62,4 +58,5 @@ exe = EXE(
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=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
|
import tkinter as tk
|
||||||
from tkinter import ttk
|
|
||||||
|
|
||||||
import license_check
|
import license_check
|
||||||
from hwid import get_hwid
|
|
||||||
|
|
||||||
# ---- Colors matching the screener's dark theme ----
|
# ---- Colors — exact match to the main app theme ----
|
||||||
_BG = "#1e1e2e"
|
_BG = "#000000"
|
||||||
_PANEL = "#2a2a3e"
|
_ACCENT = "#00e676"
|
||||||
_ACCENT = "#4f8ef7"
|
_TEXT = "#ffffff"
|
||||||
_TEXT = "#e0e0f0"
|
_SUBTEXT = "#555555"
|
||||||
_SUBTEXT = "#888899"
|
_ERROR = "#ff4545"
|
||||||
_ERROR = "#ff6b6b"
|
_SUCCESS = "#00e676"
|
||||||
_SUCCESS = "#50fa7b"
|
_BORDER = "#0d0d0d"
|
||||||
_BORDER = "#3a3a5e"
|
|
||||||
_ENTRY_BG = "#12121f"
|
|
||||||
|
|
||||||
|
|
||||||
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
|
Open the activation dialog and block until the user either activates
|
||||||
successfully or closes the window.
|
successfully or closes the window.
|
||||||
|
|
@ -34,7 +94,7 @@ def run_activation() -> bool:
|
||||||
activated = [False]
|
activated = [False]
|
||||||
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.title("Ultimate Investment Tool — Activation")
|
root.title("Ultimate Investment Tool — License Activation")
|
||||||
root.configure(bg=_BG)
|
root.configure(bg=_BG)
|
||||||
root.resizable(False, False)
|
root.resizable(False, False)
|
||||||
|
|
||||||
|
|
@ -45,108 +105,78 @@ def run_activation() -> bool:
|
||||||
y = (root.winfo_screenheight() - h) // 2
|
y = (root.winfo_screenheight() - h) // 2
|
||||||
root.geometry(f"{w}x{h}+{x}+{y}")
|
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))
|
root.protocol("WM_DELETE_WINDOW", lambda: _on_exit(root, activated))
|
||||||
|
|
||||||
# ---- Header ----
|
# ---- Header accent bar ----
|
||||||
header = tk.Frame(root, bg=_ACCENT, height=4)
|
tk.Frame(root, bg=_ACCENT, height=4).pack(fill="x")
|
||||||
header.pack(fill="x")
|
|
||||||
|
|
||||||
tk.Label(
|
tk.Label(root, text="Ultimate Investment Tool",
|
||||||
root, text="Ultimate Investment Tool",
|
font=("Segoe UI", 16, "bold"),
|
||||||
font=("Segoe UI", 16, "bold"),
|
bg=_BG, fg=_TEXT).pack(pady=(28, 4))
|
||||||
bg=_BG, fg=_TEXT
|
|
||||||
).pack(pady=(28, 4))
|
|
||||||
|
|
||||||
tk.Label(
|
tk.Label(root, text="Enter your license key to activate this software.",
|
||||||
root, text="Enter your license key to activate this software.",
|
font=("Segoe UI", 9),
|
||||||
font=("Segoe UI", 9),
|
bg=_BG, fg=_SUBTEXT).pack()
|
||||||
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")
|
|
||||||
|
|
||||||
|
# ---- Pill key entry ----
|
||||||
key_var = tk.StringVar()
|
key_var = tk.StringVar()
|
||||||
key_entry = tk.Entry(
|
ef, key_entry = _make_pill_entry(root, key_var, width_px=400, height_px=42)
|
||||||
entry_frame, textvariable=key_var,
|
ef.pack(pady=22)
|
||||||
font=("Consolas", 11), bg=_ENTRY_BG, fg=_TEXT,
|
|
||||||
insertbackground=_TEXT, relief="flat",
|
|
||||||
bd=10, width=36
|
|
||||||
)
|
|
||||||
key_entry.pack(fill="x")
|
|
||||||
key_entry.focus()
|
key_entry.focus()
|
||||||
|
|
||||||
# ---- Status label ----
|
# ---- Status label ----
|
||||||
status_var = tk.StringVar(value="")
|
status_var = tk.StringVar(value="")
|
||||||
status_label = tk.Label(
|
status_label = tk.Label(root, textvariable=status_var,
|
||||||
root, textvariable=status_var,
|
font=("Segoe UI", 9),
|
||||||
font=("Segoe UI", 9),
|
bg=_BG, fg=_ERROR, wraplength=420)
|
||||||
bg=_BG, fg=_ERROR, wraplength=400
|
|
||||||
)
|
|
||||||
status_label.pack(pady=(0, 8))
|
status_label.pack(pady=(0, 8))
|
||||||
|
|
||||||
# ---- Buttons ----
|
# ---- Pill buttons ----
|
||||||
btn_frame = tk.Frame(root, bg=_BG)
|
btn_frame = tk.Frame(root, bg=_BG)
|
||||||
btn_frame.pack(pady=4)
|
btn_frame.pack(pady=4)
|
||||||
|
|
||||||
activate_btn = tk.Button(
|
activate_btn = _PillBtn(btn_frame, "Activate",
|
||||||
btn_frame, text="Activate",
|
command=lambda: _on_activate(
|
||||||
font=("Segoe UI", 10, "bold"),
|
root, key_var, status_var, status_label,
|
||||||
bg=_ACCENT, fg="white",
|
activate_btn, activated, hwid),
|
||||||
activebackground="#3a7ae0", activeforeground="white",
|
width=130, height=38, bg=_ACCENT, fg="#000000")
|
||||||
relief="flat", cursor="hand2", padx=24, pady=8,
|
activate_btn.grid(row=0, column=0, padx=10)
|
||||||
command=lambda: _on_activate(root, key_var, status_var, status_label,
|
|
||||||
activate_btn, activated)
|
|
||||||
)
|
|
||||||
activate_btn.grid(row=0, column=0, padx=8)
|
|
||||||
|
|
||||||
exit_btn = tk.Button(
|
exit_btn = _PillBtn(btn_frame, "Exit",
|
||||||
btn_frame, text="Exit",
|
command=lambda: _on_exit(root, activated),
|
||||||
font=("Segoe UI", 10),
|
width=110, height=38, bg=_BORDER, fg=_SUBTEXT)
|
||||||
bg=_PANEL, fg=_SUBTEXT,
|
exit_btn.grid(row=0, column=1, padx=10)
|
||||||
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)
|
|
||||||
|
|
||||||
# Allow Enter key to trigger activation
|
|
||||||
root.bind("<Return>", lambda e: _on_activate(
|
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 ----
|
# ---- Footer ----
|
||||||
tk.Label(
|
tk.Label(root, text="Need a license? Contact us to subscribe.",
|
||||||
root, text="Need a license? Contact us to subscribe.",
|
font=("Segoe UI", 8),
|
||||||
font=("Segoe UI", 8),
|
bg=_BG, fg=_SUBTEXT).pack(side="bottom", pady=14)
|
||||||
bg=_BG, fg=_SUBTEXT
|
|
||||||
).pack(side="bottom", pady=14)
|
|
||||||
|
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
return activated[0]
|
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()
|
key = key_var.get().strip()
|
||||||
|
|
||||||
if not key:
|
if not key:
|
||||||
status_var.set("Please enter a license key.")
|
status_var.set("Please enter a license key.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Show checking state
|
activate_btn.set_enabled(False)
|
||||||
activate_btn.config(state="disabled", text="Checking…")
|
activate_btn.set_text("Checking…")
|
||||||
status_var.set("Contacting license server…")
|
status_var.set("Contacting license server…")
|
||||||
status_label.config(fg=_SUBTEXT)
|
status_label.config(fg=_SUBTEXT)
|
||||||
root.update()
|
root.update()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
hwid = get_hwid()
|
|
||||||
valid, reason = license_check.verify_license(key, hwid)
|
valid, reason = license_check.verify_license(key, hwid)
|
||||||
except Exception as exc:
|
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_var.set(f"Error: {exc}")
|
||||||
status_label.config(fg=_ERROR)
|
status_label.config(fg=_ERROR)
|
||||||
return
|
return
|
||||||
|
|
@ -158,7 +188,8 @@ def _on_activate(root, key_var, status_var, status_label, activate_btn, activate
|
||||||
activated[0] = True
|
activated[0] = True
|
||||||
root.after(900, root.destroy)
|
root.after(900, root.destroy)
|
||||||
else:
|
else:
|
||||||
activate_btn.config(state="normal", text="Activate")
|
activate_btn.set_enabled(True)
|
||||||
|
activate_btn.set_text("Activate")
|
||||||
status_var.set(reason)
|
status_var.set(reason)
|
||||||
status_label.config(fg=_ERROR)
|
status_label.config(fg=_ERROR)
|
||||||
|
|
||||||
|
|
|
||||||
40
build.bat
40
build.bat
|
|
@ -6,7 +6,7 @@ echo ======================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
echo Installing dependencies...
|
echo Installing dependencies...
|
||||||
python -m pip install pyinstaller tinyaes matplotlib --quiet
|
python -m pip install pyinstaller matplotlib --quiet
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo ERROR: pip failed. Make sure Python is installed and on PATH.
|
echo ERROR: pip failed. Make sure Python is installed and on PATH.
|
||||||
pause
|
pause
|
||||||
|
|
@ -21,48 +21,12 @@ copy /Y dist\stock_screener.py stock_screener.py >nul
|
||||||
echo Building executable...
|
echo Building executable...
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
python -m PyInstaller ^
|
python -m PyInstaller StockScreener.spec
|
||||||
--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
|
|
||||||
|
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo.
|
echo.
|
||||||
echo BUILD FAILED. See errors above.
|
echo BUILD FAILED. See errors above.
|
||||||
echo.
|
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...
|
echo Cleaning up temporary files...
|
||||||
if exist screener_gui.py del screener_gui.py
|
if exist screener_gui.py del screener_gui.py
|
||||||
if exist stock_screener.py del stock_screener.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.
|
Returns a SHA-256 hex digest. Never changes unless hardware changes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import hashlib
|
import hashlib
|
||||||
import subprocess
|
import subprocess
|
||||||
import winreg
|
import winreg
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=None)
|
||||||
def get_hwid() -> str:
|
def get_hwid() -> str:
|
||||||
"""Return a stable SHA-256 HWID string for the current machine."""
|
"""Return a stable SHA-256 HWID string for the current machine."""
|
||||||
components = []
|
components = []
|
||||||
|
|
|
||||||
84
launcher.py
84
launcher.py
|
|
@ -6,57 +6,26 @@ Startup sequence:
|
||||||
2. Load stored license key — show activation dialog if none found
|
2. Load stored license key — show activation dialog if none found
|
||||||
3. Verify license against Supabase (with 30-min offline grace period)
|
3. Verify license against Supabase (with 30-min offline grace period)
|
||||||
4. Silently check for and apply updates
|
4. Silently check for and apply updates
|
||||||
5. Load and run screener_gui.py
|
5. Launch the bundled screener_gui
|
||||||
|
|
||||||
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).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox
|
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
|
# 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:
|
def _fatal(title: str, message: str) -> None:
|
||||||
"""Show an error dialog and exit."""
|
"""Show an error dialog and exit."""
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
|
|
@ -71,9 +40,6 @@ def _fatal(title: str, message: str) -> None:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
exe_dir = _exe_dir()
|
|
||||||
_sync_source_files(exe_dir)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Step 1: HWID
|
# Step 1: HWID
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -82,7 +48,7 @@ def main():
|
||||||
hwid = get_hwid()
|
hwid = get_hwid()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_fatal("Ultimate Investment Tool", f"Could not generate hardware ID:\n\n{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
|
# Step 2: Load or activate license
|
||||||
|
|
@ -92,12 +58,10 @@ def main():
|
||||||
license_key = license_check.load_license_key(hwid)
|
license_key = license_check.load_license_key(hwid)
|
||||||
|
|
||||||
if license_key is None:
|
if license_key is None:
|
||||||
# First launch — show activation dialog
|
|
||||||
import activation_dialog
|
import activation_dialog
|
||||||
activated = activation_dialog.run_activation()
|
activated = activation_dialog.run_activation(hwid)
|
||||||
if not activated:
|
if not activated:
|
||||||
sys.exit(0) # user closed the window
|
sys.exit(0)
|
||||||
# Reload after successful save
|
|
||||||
license_key = license_check.load_license_key(hwid)
|
license_key = license_check.load_license_key(hwid)
|
||||||
if license_key is None:
|
if license_key is None:
|
||||||
_fatal("Ultimate Investment Tool", "Activation failed. Please restart and try again.")
|
_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)
|
valid, reason = license_check.verify_license(license_key, hwid)
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
# License invalid — clear stored key so they can re-enter on next launch
|
|
||||||
license_check.delete_license_key()
|
license_check.delete_license_key()
|
||||||
_fatal(
|
_fatal(
|
||||||
"Ultimate Investment Tool — License Error",
|
"Ultimate Investment Tool — License Error",
|
||||||
|
|
@ -123,33 +86,20 @@ def main():
|
||||||
try:
|
try:
|
||||||
import updater
|
import updater
|
||||||
updater.check_and_apply_update()
|
updater.check_and_apply_update()
|
||||||
# If an update was applied, updater restarts the process — we never reach here.
|
|
||||||
except Exception:
|
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:
|
try:
|
||||||
import screener_gui
|
import screener_gui
|
||||||
screener_gui.main()
|
screener_gui.main()
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
|
import traceback
|
||||||
_fatal(
|
_fatal(
|
||||||
"Ultimate Investment Tool",
|
"Ultimate Investment Tool — Startup Error",
|
||||||
f"Could not start.\n\n{exc}\n\n"
|
f"The application failed to start:\n\n{traceback.format_exc()}"
|
||||||
"Make sure screener_gui.py is in the same folder as this exe."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ License Check
|
||||||
Validates the stored license key against Supabase on every launch.
|
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).
|
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)
|
license.dat — XOR-encrypted license key (machine-bound)
|
||||||
auth_cache.dat — last successful auth result + timestamp
|
auth_cache.dat — last successful auth result + timestamp
|
||||||
"""
|
"""
|
||||||
|
|
@ -101,13 +101,21 @@ def delete_license_key() -> None:
|
||||||
# Auth cache
|
# Auth cache
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _save_cache(valid: bool, reason: str) -> None:
|
def _save_cache(valid: bool, reason: str, channel: str = "stable") -> None:
|
||||||
_ensure_dir()
|
_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:
|
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(payload, 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:
|
def _load_cache() -> dict | None:
|
||||||
if not os.path.exists(_CACHE_FILE):
|
if not os.path.exists(_CACHE_FILE):
|
||||||
return None
|
return None
|
||||||
|
|
@ -142,20 +150,28 @@ def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
valid = bool(data.get("valid", False))
|
valid = bool(data.get("valid", False))
|
||||||
reason = data.get("reason", "Unknown error")
|
reason = data.get("reason", "Unknown error")
|
||||||
_save_cache(valid, reason)
|
channel = data.get("channel", "stable")
|
||||||
|
_save_cache(valid, reason, channel)
|
||||||
return valid, reason
|
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()
|
cache = _load_cache()
|
||||||
if cache and cache.get("valid"):
|
if cache and cache.get("valid"):
|
||||||
age = time.time() - cache.get("timestamp", 0)
|
age = time.time() - cache.get("timestamp", 0)
|
||||||
if age < _GRACE_PERIOD_SECONDS:
|
if age < _GRACE_PERIOD_SECONDS:
|
||||||
return True, "offline_grace"
|
return True, "offline_grace"
|
||||||
return (
|
return False, f"Cannot connect to license server. Check your internet connection.\n\n({e})"
|
||||||
False,
|
except requests.exceptions.HTTPError as e:
|
||||||
"Cannot reach the license server. "
|
return False, f"License server error: {e}"
|
||||||
"Please check your internet connection and try again."
|
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
|
Auto-Updater
|
||||||
============
|
============
|
||||||
Checks Supabase for a newer version of screener_gui.py and stock_screener.py.
|
On every launch (after a successful license check), checks Supabase for a
|
||||||
If found, silently downloads and replaces the files in the exe directory,
|
newer version. If found:
|
||||||
then restarts the application.
|
- 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.
|
Called after a successful license check on every launch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
|
||||||
|
|
||||||
import requests
|
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_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
|
||||||
_SUPABASE_ANON = (
|
_SUPABASE_ANON = (
|
||||||
|
|
@ -36,9 +47,6 @@ _HEADERS = {
|
||||||
"Content-Type": "application/json",
|
"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:
|
def _exe_dir() -> str:
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
|
|
@ -47,7 +55,6 @@ def _exe_dir() -> str:
|
||||||
|
|
||||||
|
|
||||||
def _parse_version(v: str) -> tuple:
|
def _parse_version(v: str) -> tuple:
|
||||||
"""Convert '1.2.3' → (1, 2, 3) for comparison."""
|
|
||||||
try:
|
try:
|
||||||
return tuple(int(x) for x in v.strip().split("."))
|
return tuple(int(x) for x in v.strip().split("."))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -55,11 +62,18 @@ def _parse_version(v: str) -> tuple:
|
||||||
|
|
||||||
|
|
||||||
def _download_file(url: str, dest_path: str) -> None:
|
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_dir = tempfile.mkdtemp()
|
||||||
tmp_path = os.path.join(tmp_dir, os.path.basename(dest_path))
|
tmp_path = os.path.join(tmp_dir, os.path.basename(dest_path))
|
||||||
try:
|
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()
|
r.raise_for_status()
|
||||||
with open(tmp_path, "wb") as f:
|
with open(tmp_path, "wb") as f:
|
||||||
for chunk in r.iter_content(chunk_size=65536):
|
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)
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _restart() -> None:
|
def _self_replace_exe(exe_url: str) -> None:
|
||||||
"""Restart the current process cleanly."""
|
"""
|
||||||
if getattr(sys, "frozen", False):
|
Download the new exe to %TEMP%, then launch a helper batch script that
|
||||||
os.execv(sys.executable, [sys.executable] + sys.argv[1:])
|
waits for this process to exit, moves the file into place, and restarts
|
||||||
else:
|
the app. Nothing is left in the application directory. Never returns.
|
||||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
"""
|
||||||
|
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:
|
def check_and_apply_update() -> None:
|
||||||
"""
|
"""
|
||||||
Silently check for updates and apply them if available.
|
Silently check for updates and apply them. Errors are swallowed so a
|
||||||
Errors are swallowed — a failed update check never blocks the app.
|
failed update check never blocks the app from launching.
|
||||||
"""
|
"""
|
||||||
try:
|
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(
|
resp = requests.post(
|
||||||
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
|
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
|
||||||
headers=_HEADERS,
|
headers=_HEADERS,
|
||||||
json={},
|
json={"p_channel": channel},
|
||||||
timeout=8,
|
timeout=8,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
latest = data.get("version", APP_VERSION)
|
latest = data.get("version", APP_VERSION)
|
||||||
gui_url = data.get("gui_py_url")
|
|
||||||
scr_url = data.get("screener_py_url")
|
|
||||||
|
|
||||||
if _parse_version(latest) <= _parse_version(APP_VERSION):
|
if _parse_version(latest) <= _parse_version(APP_VERSION):
|
||||||
return # already up to date
|
return # already up to date
|
||||||
|
|
||||||
exe_dir = _exe_dir()
|
# Always resolve the download URL via the GitHub API — direct
|
||||||
updated = False
|
# 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:
|
# Self-replace exe if a new one is available (exits this process)
|
||||||
dest = os.path.join(exe_dir, "screener_gui.py")
|
_self_replace_exe(exe_url)
|
||||||
_download_file(gui_url, dest)
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if scr_url:
|
except Exception as exc:
|
||||||
dest = os.path.join(exe_dir, "stock_screener.py")
|
_log_update_error(f"check_and_apply_update failed: {exc}")
|
||||||
_download_file(scr_url, dest)
|
# never crash the app over a failed update check
|
||||||
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
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue