Compare commits

..

12 Commits
main ... beta

Author SHA1 Message Date
Nolan Kovacs d756bc99ce Fix detail panel: 5 bugs causing all fields to show dashes
- _derive_row_extras -> _prepare_row_dict (undefined function call)
- Define _GREEN_KEYS (NameError crashed _color_for_key loop)
- Fix Q_ARG(object,...) in chart, search, insider trades, hedge funds
- Fix total equity 100x overstatement (de_ratio already a true ratio)
- Launch maximized on primary monitor instead of centered/split

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 19:40:32 -04:00
Nolan Kovacs df032593bd Fix missing os import in stock_screener; force app to open on primary monitor 2026-04-08 18:37:38 -04:00
Nolan Kovacs 78f3a25022 a 2026-04-04 16:08:57 -04:00
Nolan Kovacs a604397c41 a 2026-04-04 15:43:52 -04:00
Nolan Kovacs a7ba0f950b a 2026-04-04 15:21:19 -04:00
Nolan Kovacs 3ce0685889 a 2026-04-04 14:49:39 -04:00
Nolan Kovacs 6bc851b03c a 2026-04-04 14:20:37 -04:00
Nolan Kovacs 0de9beec8e a 2026-04-04 13:42:46 -04:00
Nolan Kovacs f75cceefd4 Fixes 2026-04-04 13:06:21 -04:00
Nolan Kovacs e17d63ceeb test 2026-04-04 09:49:22 -04:00
Nolan Kovacs f2c8daefba Tier 1-3 fixes: value inversion, iloc→loc, D/E scale, Piotroski, Sloan accruals, sentiment confidence, early-stage double-count 2026-03-29 18:36:41 -04:00
Nolan Kovacs 5c779efecf 123 2026-03-26 14:55:05 -04:00
23 changed files with 2921 additions and 6825 deletions

View File

@ -1,16 +1 @@
{
"permissions": {
"allow": [
"Bash(python --version && pip --version)",
"Bash(python3 --version 2>/dev/null || py --version 2>/dev/null || echo \"no python\")",
"Bash(node --version 2>/dev/null && npm --version 2>/dev/null || echo \"no node\")",
"Bash(where python:*)",
"Bash(where python3:*)",
"Bash(where py:*)",
"Bash(ls /c/Python*)",
"Read(//c/Users/Nolan/AppData/Local/Programs/**)",
"Bash(ls \"/c/Users/Nolan/AppData/Local/Programs/Python\" 2>/dev/null || echo \"not found\"\nls \"/c/Users/Nolan/AppData/Local/Microsoft/WindowsApps/\"python* 2>/dev/null || echo \"not found\")",
"Bash(cd \"C:/Users/Nolan/Desktop/Stock Tool\" && python -c \"import tkinter; import numpy; import pandas; import yfinance; import requests; print\\('All imports OK'\\)\" 2>&1)"
]
}
}
{}

View File

@ -1,20 +0,0 @@
# Cache refresh is now handled by VPS cron jobs on the Hetzner server.
# See /etc/cron.d/cache_builder on the VPS:
# Stable: 2am daily → /srv/stock-tool/cache_builder.py → analyst_cache, fundamentals_cache, sector_stats
# Beta: 3am daily → /srv/stock-tool-beta/cache_builder.py → beta_analyst_cache, beta_fundamentals_cache, beta_sector_stats
#
# This workflow is intentionally disabled (no triggers).
name: Refresh Cache (disabled — handled by VPS cron)
on:
workflow_dispatch: # manual trigger only, for emergency use
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Not used
run: echo "Cache refresh runs on VPS. See /etc/cron.d/cache_builder."

BIN
.gitignore vendored

Binary file not shown.

108
CLAUDE.md Normal file
View File

@ -0,0 +1,108 @@
# Stock Tool — Claude Context
## Project Overview
A multi-factor stock screener that evaluates ~6,500 US-listed equities using a composite
score across 8 dimensions: Value, Growth, Momentum, Quality, Profitability, Sentiment,
Analyst, and Risk. Sold as a licensed desktop app (PySide6 GUI).
## File Map
| File | Purpose |
|------|---------|
| `dist/stock_screener.py` | All scoring logic, data fetching, index/ticker collection. Pure Python + pandas. |
| `dist/screener_gui.py` | PySide6 GUI. Imports from `stock_screener`. Main class: `ScreenerApp(QMainWindow)`. |
| `cache_builder.py` | Nightly job that populates PostgreSQL cache from EDGAR + yfinance. Runs on VPS via cron. |
| `api/main.py` | FastAPI server on VPS. Handles license auth, cache serving, version management. |
| `updater.py` | Client-side auto-updater. Checks `/update` endpoint, downloads new build. |
| `license_check.py` | HMAC + JWT license validation. Baked into app via PyArmor. |
| `push_update.py` | Dev tool: SCP new build to VPS + register version via `/admin/register-version`. |
| `launcher.py` | Thin launcher that checks license before starting GUI. |
| `health_monitor.py` | VPS health monitoring. |
| `discord_bot/bot.py` | Discord bot integration (separate process). |
## Architecture (3-tier)
```
[cache_builder.py] → [PostgreSQL on VPS] → [API] → [Client app]
nightly 6 tables FastAPI screener_gui + stock_screener
```
- `cache_builder.py` runs nightly (2am stable / 3am beta) via cron on the VPS
- Data flows: EDGAR XBRL Frames API + yfinance → PostgreSQL tables → served by FastAPI
- Client fetches cached data at startup via `/cache/analyst`, `/cache/fundamentals`, `/cache/sector`
- Scoring runs entirely client-side in `stock_screener.py` — no server-side scoring
## PostgreSQL Tables (database: `verimund`, user: `verimund_user`)
| Table | Contents |
|-------|----------|
| `analyst_cache` | Price history, returns, sentiment, analyst ratings, short interest (~6,975 rows) |
| `fundamentals_cache` | EDGAR-sourced financials: revenue, margins, D/E, ROA/ROE, Piotroski, accruals |
| `sector_stats` | Per-sector median/MAD for each metric. Used by `_z_score()` for normalization. |
| `app_versions` | Version registry per channel (stable/beta). Read by `/admin/versions`. |
| `licenses` | License keys + HWIDs + expiry. Validated at auth time. |
| `auth_log` | Auth attempt log. |
## Scoring Formula
```
Composite = 0.18*Value + 0.18*Growth + 0.14*Momentum + 0.11*Quality
+ 0.08*Profitability + 0.13*Sentiment + 0.10*Analyst + 0.08*Risk
```
Each score is 0100. Z-scores use sector median/MAD from `sector_stats` table.
If sector stats are missing for a metric, `_z_score()` returns neutral 50.
## Profile System (important)
`_classify_profile(row)` classifies each stock into one of:
`mature`, `high_growth`, `financial`, `capital_intensive`, `early_stage`, `turnaround`
Profiles define sub-weights per scoring dimension in `_PROFILE_WEIGHTS`.
Weights are normalized in `_profile_score_row()` — they don't need to sum to 1.0 in the dict.
**D/E handling**: yfinance returns `debtToEquity` as percentage (e.g. 150 = 1.5x).
Code divides by 100 at ingest (`stock_screener.py` line ~1356).
Negative D/E means negative book equity — replaced with 999.0 before z-scoring.
## Key Patterns
- `_z_series(series, metric, sectors, sector_stats)` — vectorized z-score using sector stats
- `_score_series(series, breakpoints)` — absolute breakpoint scoring (no sector normalization)
- `_profile_score_row(row, dimension, metric_scores)` — blend metric scores using profile weights
- All scoring functions signature: `compute_X_score(df, sector_stats=None) → pd.Series`
## VPS Infrastructure
- **Provider**: Hetzner CPX21, Ubuntu 22.04
- **Domains**: api.verimundsolutions.com (API), git.verimundsolutions.com (Gitea)
- **API**: FastAPI via uvicorn, managed by PM2
- **Secrets**: `/srv/api/.env` on VPS (DB_PASS, JWT_SECRET, HMAC_SECRET, ADMIN_KEY)
- **Backups**: nightly rclone → Cloudflare R2 bucket `verimund-backups`
- **Git**: Gitea at git.verimundsolutions.com, repo: ssz223/Stock-Tool, branches: main (stable) / beta
- **Website**: Taken down intentionally (2026-04-03), will be re-done under different brand
## Running Tests
```bash
# Fast tests (run automatically via hook on every edit):
python -m pytest tests/test_scoring.py tests/test_gui.py -q
# Full suite including network (run before pushing):
python -m pytest tests/ -v
# Network tests only:
python -m pytest tests/test_network.py -v
# Skip network tests:
python -m pytest tests/ -m "not network" -v
```
## Known Pitfalls
- `screener_gui.py` uses PySide6 (not PyQt5). Offscreen: `QT_QPA_PLATFORM=offscreen`
- `screener_gui.py` imports from `stock_screener` (same dir) — tests must `cd dist/` or add dist to sys.path
- EDGAR XBRL Frames API: one call per concept returns all US filers. Foreign/IFRS filers can have data 18 months old with no user-facing flag.
- Sector stats built from `analyst_cache` in Phase 5 of cache_builder. If Phase 1 partially fails, sector medians are biased.
- `_sum_frames` requires >= 4 quarters for valid TTM — returns None otherwise.
- Forward PE not in sector stats — `_z_score` returns neutral 50 for forward PE z-scoring.

View File

@ -3,25 +3,31 @@ from PyInstaller.utils.hooks import collect_all
datas = []
binaries = []
hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment', 'pyarmor_runtime_000000']
tmp_ret = collect_all('pandas')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('numpy')
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('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]
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_qtagg',
'matplotlib.backends.backend_tkagg',
'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker',
'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer',
'hwid', 'license_check', 'activation_dialog', 'updater',
'vaderSentiment', 'vaderSentiment.vaderSentiment',
'pyarmor_runtime_000000',
'PySide6', 'PySide6.QtCore', 'PySide6.QtWidgets', 'PySide6.QtGui',
'PySide6.QtCharts', 'PySide6.QtNetwork',
'shiboken6',
]
for pkg in ('pandas', 'numpy', 'yfinance', 'matplotlib', 'requests',
'certifi', 'charset_normalizer', 'vaderSentiment', 'tzdata',
'PySide6'):
tmp_ret = collect_all(pkg)
datas += tmp_ret[0]
binaries += tmp_ret[1]
hiddenimports += tmp_ret[2]
a = Analysis(
@ -58,5 +64,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
uac_admin=True, # embed UAC manifest: always run as Administrator
uac_admin=True,
)

View File

@ -20,7 +20,9 @@ app = FastAPI()
DB_HOST = "127.0.0.1"
DB_NAME = "verimund"
DB_USER = "verimund_user"
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
DB_PASS = os.getenv("DB_PASS")
if not DB_PASS:
raise RuntimeError("DB_PASS environment variable is not set")
JWT_SECRET = os.getenv("JWT_SECRET", "")
HMAC_SECRET = os.getenv("HMAC_SECRET", "")
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
@ -157,11 +159,14 @@ def check_update(payload=Depends(verify_jwt), db=Depends(get_db)):
@app.get("/admin/versions")
def get_versions(_=Depends(verify_admin), db=Depends(get_db)):
def get_versions(channel: str = "stable", _=Depends(verify_admin), db=Depends(get_db)):
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM app_versions ORDER BY released_at DESC LIMIT 1")
cur.execute(
"SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1",
(channel,)
)
version = cur.fetchone()
return {"version": version["version"] if version else "1.0.0"}
return {"version": version["version"] if version else "1.0.0", "channel": channel}
@app.post("/admin/register-version")

View File

@ -1,57 +0,0 @@
@echo off
cd /d "%~dp0"
echo ======================================
echo Ultimate Investment Tool - Build EXE
echo ======================================
echo.
echo Installing dependencies...
python -m pip install pyinstaller matplotlib --quiet
if errorlevel 1 (
echo ERROR: pip failed. Make sure Python is installed and on PATH.
pause
exit /b 1
)
echo.
echo Copying source files from dist\ for bundling...
copy /Y dist\screener_gui.py screener_gui.py >nul
copy /Y dist\stock_screener.py stock_screener.py >nul
echo Building executable...
echo.
python -m PyInstaller StockScreener.spec
if errorlevel 1 (
echo.
echo BUILD FAILED. See errors above.
echo.
echo Cleaning up temporary files...
if exist screener_gui.py del screener_gui.py
if exist stock_screener.py del stock_screener.py
pause
exit /b 1
)
echo.
echo Cleaning up temporary source copies from project root...
if exist screener_gui.py del screener_gui.py
if exist stock_screener.py del stock_screener.py
echo.
echo ======================================
echo SUCCESS!
echo.
echo Folder: dist\
echo - StockScreener.exe (distribute this)
echo - screener_gui.py (distribute alongside exe)
echo - stock_screener.py (distribute alongside exe)
echo.
echo DO NOT distribute:
echo - issue_license.py (admin only)
echo - supabase_schema.sql (admin only)
echo - hwid.py / license_check.py / updater.py / activation_dialog.py
echo (these are bundled inside the exe)
echo ======================================
pause

View File

@ -43,7 +43,9 @@ load_dotenv("/srv/api/.env")
_DB_HOST = "127.0.0.1"
_DB_NAME = "verimund"
_DB_USER = "verimund_user"
_DB_PASS = os.environ.get("DB_PASS", "Shlevison2k17")
_DB_PASS = os.environ.get("DB_PASS")
if not _DB_PASS:
raise RuntimeError("DB_PASS environment variable is not set")
# TABLE_PREFIX is set by the cron job: "" for stable, "beta_" for beta channel.
_TABLE_PREFIX = os.environ.get("TABLE_PREFIX", "")
@ -284,6 +286,7 @@ def _fetch_analyst(ticker: str) -> dict | None:
return {
"ticker": ticker,
"name": info.get("longName") or None,
"pe_forward": info.get("forwardPE"),
"eps_forward": info.get("forwardEps"),
"analyst_norm": analyst_norm,
@ -293,6 +296,8 @@ def _fetch_analyst(ticker: str) -> dict | None:
"recommendation": info.get("recommendationKey") or "N/A",
"sector": info.get("sector") or None,
"industry": info.get("industry") or None,
"short_percent": info.get("shortPercentOfFloat"),
"short_ratio": info.get("shortRatio"),
"dividend_yield": info.get("dividendYield"),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
@ -414,6 +419,7 @@ def _fetch_news_sentiment(ticker: str) -> dict | None:
return {
"ticker": ticker,
"news_sentiment": sentiment,
"news_headline_count": len(scores),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
except Exception:
@ -463,16 +469,17 @@ def run_news_phase(tickers: list[str]) -> None:
# Explicit column allow-lists keep extra fields (e.g. dividend_yield from
# yfinance) from causing "column does not exist" errors in PostgreSQL.
_ANALYST_COLS = (
"ticker", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
"ticker", "name", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
"analyst_count", "analyst_target", "recommendation", "news_sentiment",
"sector", "industry", "updated_at",
"news_headline_count", "sector", "industry", "short_percent", "short_ratio", "updated_at",
)
_FUND_COLS = (
"ticker", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
"ticker", "name", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
"revenue", "revenue_growth", "earnings_growth", "roe", "roa",
"debt_to_equity", "total_debt", "total_cash", "book_value", "current_ratio",
"profit_margin", "operating_margin", "fcf_yield", "dividend_yield",
"market_cap", "shares_outstanding", "ev_revenue", "short_percent",
"piotroski_score", "accruals_ratio",
"filer_type", "updated_at",
)
@ -534,8 +541,11 @@ def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
_FRAME_CACHE[key] = result
time.sleep(0.15)
return result
except Exception:
_FRAME_CACHE[key] = {}
except requests.HTTPError as e:
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: HTTP {e.response.status_code} — not cached")
return {}
except Exception as e:
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: {e} — not cached")
return {}
@ -547,7 +557,7 @@ def _sum_frames(concepts: list[str], unit: str, periods: list[str]) -> dict[int,
ttm.setdefault(cik, {})
if period not in ttm[cik]:
ttm[cik][period] = val
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
@ -613,6 +623,20 @@ def _compute_fundamentals(
if rev is not None and rev_prev and rev_prev != 0 else None)
ni_g = ((ni - ni_prev) / abs(ni_prev)
if ni is not None and ni_prev and ni_prev != 0 else None)
# Piotroski partial F-Score (4 signals available at compute time)
p = 0
if ni is not None and tot_a and tot_a > 0 and ni / tot_a > 0: p += 1 # ROA > 0
if ocf is not None and ocf > 0: p += 1 # OCF > 0
if ocf is not None and ni is not None and ocf > ni: p += 1 # OCF > NI (cash-backed)
if rev is not None and rev_prev is not None and rev_prev != 0 and rev > rev_prev: p += 1 # Revenue improving
piotroski = p
# Sloan accruals ratio: (NI - OCF) / Total Assets — negative = higher earnings quality
accruals = None
if ni is not None and ocf is not None and tot_a and tot_a > 0:
accruals = max(-0.3, min(0.3, (ni - ocf) / tot_a))
return {
"market_cap": mkt_cap,
"shares_outstanding": sh,
@ -634,6 +658,8 @@ def _compute_fundamentals(
"profit_margin": pm,
"operating_margin": om,
"fcf_yield": fcf_yield,
"piotroski_score": piotroski,
"accruals_ratio": accruals,
}
@ -721,7 +747,13 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]:
cash.get(cik), lt_debt.get(cik),
shares.get(cik),
)
try:
_info = yf.Ticker(ticker).info or {}
_name = _info.get("longName") or _info.get("shortName") or None
except Exception:
_name = None
metrics["ticker"] = ticker
metrics["name"] = _name
metrics["filer_type"] = "us-gaap-quarterly"
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
result[ticker] = metrics
@ -752,7 +784,13 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]:
cash.get(cik), lt_debt.get(cik),
shares.get(cik),
)
try:
_info = yf.Ticker(ticker).info or {}
_name = _info.get("longName") or _info.get("shortName") or None
except Exception:
_name = None
metrics["ticker"] = ticker
metrics["name"] = _name
metrics["filer_type"] = "us-gaap-quarterly"
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
result[ticker] = metrics
@ -889,7 +927,13 @@ def run_foreign_filers_phase() -> dict[str, dict]:
raw.get("cash"), raw.get("lt_debt"),
raw.get("shares"),
)
try:
_info = yf.Ticker(ticker).info or {}
_name = _info.get("longName") or _info.get("shortName") or None
except Exception:
_name = None
metrics["ticker"] = ticker
metrics["name"] = _name
metrics["filer_type"] = f"{taxonomy}-annual"
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
result[ticker] = metrics

View File

@ -1,48 +0,0 @@
@echo off
setlocal
echo ============================================================
echo Deploy to BETA (beta branch + /srv/stock-tool-beta on VPS)
echo ============================================================
echo.
set /p COMMIT_MSG=Commit message:
if "%COMMIT_MSG%"=="" (
echo Error: Commit message cannot be empty.
pause
exit /b 1
)
echo.
echo [1/4] Staging all changes...
git add -A
echo [2/4] Committing...
git commit -m "%COMMIT_MSG%"
if errorlevel 1 (
echo Nothing to commit.
pause
exit /b 0
)
echo [3/4] Pushing to Gitea beta branch...
git push gitea HEAD:beta
if errorlevel 1 (
echo Push failed. Check Gitea connection.
pause
exit /b 1
)
echo [4/4] Deploying to VPS...
ssh root@87.99.133.95 "cd /srv/stock-tool-beta && git pull origin beta && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS beta deploy OK"
if errorlevel 1 (
echo VPS deploy failed. SSH in and check manually.
pause
exit /b 1
)
echo.
echo ============================================================
echo Beta deploy complete.
echo ============================================================
pause

View File

@ -1,48 +0,0 @@
@echo off
setlocal
echo ============================================================
echo Deploy to STABLE (main branch + /srv/stock-tool on VPS)
echo ============================================================
echo.
set /p COMMIT_MSG=Commit message:
if "%COMMIT_MSG%"=="" (
echo Error: Commit message cannot be empty.
pause
exit /b 1
)
echo.
echo [1/4] Staging all changes...
git add -A
echo [2/4] Committing...
git commit -m "%COMMIT_MSG%"
if errorlevel 1 (
echo Nothing to commit.
pause
exit /b 0
)
echo [3/4] Pushing to Gitea main branch...
git push gitea main
if errorlevel 1 (
echo Push failed. Check Gitea connection.
pause
exit /b 1
)
echo [4/4] Deploying to VPS...
ssh root@87.99.133.95 "cd /srv/stock-tool && git pull && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS deploy OK"
if errorlevel 1 (
echo VPS deploy failed. SSH in and check manually.
pause
exit /b 1
)
echo.
echo ============================================================
echo Stable deploy complete.
echo ============================================================
pause

View File

@ -1,39 +0,0 @@
# ─── Discord ────────────────────────────────────────────────────────────────
# Bot token from https://discord.com/developers/applications
DISCORD_TOKEN=
# Your server's ID (right-click server icon → Copy Server ID)
GUILD_ID=
# ─── Role IDs ───────────────────────────────────────────────────────────────
# Right-click each role in Server Settings → Roles → Copy Role ID
# User role is assigned to all subscribers; Lifetime role is also assigned for lifetime purchases
ROLE_USER_ID=
ROLE_LIFETIME_ID=
# ─── Ticket channel IDs ─────────────────────────────────────────────────────
# Category where ticket channels are created (right-click category → Copy ID)
TICKET_CATEGORY_ID=
# Channel where ticket open/close events are logged (right-click channel → Copy ID)
TICKET_LOG_CHANNEL_ID=
# ─── Stripe ─────────────────────────────────────────────────────────────────
# From https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=sk_live_...
# From https://dashboard.stripe.com/webhooks (signing secret for this endpoint)
STRIPE_WEBHOOK_SECRET=whsec_...
# Where Stripe redirects after payment (can be a thank-you page or Discord DM link)
STRIPE_SUCCESS_URL=https://discord.com/channels/@me
STRIPE_CANCEL_URL=https://discord.com/channels/@me
# ─── Supabase ───────────────────────────────────────────────────────────────
SUPABASE_URL=https://yeispcpmepjelfbhfkwr.supabase.co
SUPABASE_SERVICE_ROLE_KEY=
# ─── Webhook server ─────────────────────────────────────────────────────────
# Port the bot listens on for incoming Stripe webhook POSTs
# Forward this via ngrok (dev) or expose via your server's firewall (prod)
WEBHOOK_PORT=8080

View File

@ -1,533 +0,0 @@
#!/usr/bin/env python3
"""
Discord Sales & Support Bot Ultimate Investment Tool
=======================================================
Features:
/setup (Admin only) Posts the persistent sales embed with Purchase &
Support buttons into the current channel. Run this once in your
read-only sales channel.
Purchase button Stripe Checkout (weekly $10 / monthly $25 / lifetime $150)
License key auto-generated + DM'd on successful payment
Role assigned automatically in the server
Support button Modal prompts for a subject, then opens a private ticket
channel with a Close button and audit log
Setup: copy .env.example .env and fill in all values before running.
"""
import asyncio
import datetime
import os
import uuid
import aiohttp
from aiohttp import web
import discord
from discord import app_commands
from discord.ext import commands
import requests
import stripe
from dotenv import load_dotenv
load_dotenv()
# ─────────────────────────────────────────────────────────────────────────────
# Configuration (all values from .env)
# ─────────────────────────────────────────────────────────────────────────────
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
GUILD_ID = int(os.getenv("GUILD_ID", "0"))
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
# Discord IDs — right-click channel/role → Copy ID (needs Developer Mode enabled)
TICKET_CATEGORY_ID = int(os.getenv("TICKET_CATEGORY_ID", "0"))
TICKET_LOG_CHANNEL_ID = int(os.getenv("TICKET_LOG_CHANNEL_ID", "0"))
ROLE_USER_ID = int(os.getenv("ROLE_USER_ID", "0"))
ROLE_LIFETIME_ID = int(os.getenv("ROLE_LIFETIME_ID", "0"))
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "8080"))
# Pricing tiers
PRICES = {
"weekly": {"amount": 1000, "label": "$10 / week", "days": 7},
"monthly": {"amount": 2500, "label": "$25 / month", "days": 30},
"lifetime": {"amount": 15000, "label": "$150 lifetime", "days": None},
}
stripe.api_key = STRIPE_SECRET_KEY
_SUPABASE_HEADERS = {
"apikey": SUPABASE_SERVICE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
# ─────────────────────────────────────────────────────────────────────────────
# License helpers
# ─────────────────────────────────────────────────────────────────────────────
def _generate_key() -> str:
parts = [uuid.uuid4().hex[:8].upper() for _ in range(3)]
return "UIT-" + "-".join(parts)
def issue_license(tier: str, notes: str = "") -> str:
"""Create a new license in Supabase and return the key."""
days = PRICES[tier]["days"]
expiry = None
if days is not None:
expiry = (datetime.datetime.utcnow() + datetime.timedelta(days=days)).isoformat() + "Z"
key = _generate_key()
payload = {
"license_key": key,
"tier": tier,
"expiry_date": expiry,
"active": True,
"machines_allowed": 1,
"notes": notes,
}
resp = requests.post(
f"{SUPABASE_URL}/rest/v1/licenses",
headers=_SUPABASE_HEADERS,
json=payload,
timeout=10,
)
resp.raise_for_status()
return key
# ─────────────────────────────────────────────────────────────────────────────
# Stripe helpers
# ─────────────────────────────────────────────────────────────────────────────
def create_checkout_session(tier: str, discord_user_id: str, discord_username: str) -> str:
"""Create a Stripe Checkout Session and return its URL."""
price_info = PRICES[tier]
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "usd",
"unit_amount": price_info["amount"],
"product_data": {
"name": f"Ultimate Investment Tool — {price_info['label']}",
},
},
"quantity": 1,
}],
mode="payment",
success_url=os.getenv("STRIPE_SUCCESS_URL", "https://discord.com/channels/@me"),
cancel_url=os.getenv("STRIPE_CANCEL_URL", "https://discord.com/channels/@me"),
metadata={
"discord_user_id": discord_user_id,
"discord_username": discord_username,
"tier": tier,
},
)
return session.url
# ─────────────────────────────────────────────────────────────────────────────
# Bot setup
# ─────────────────────────────────────────────────────────────────────────────
intents = discord.Intents.default()
intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)
tree = bot.tree
# ─────────────────────────────────────────────────────────────────────────────
# Ticket creation helper (shared by button and any future commands)
# ─────────────────────────────────────────────────────────────────────────────
async def create_ticket(interaction: discord.Interaction, subject: str):
guild = interaction.guild
category = guild.get_channel(TICKET_CATEGORY_ID)
overwrites = {
guild.default_role: discord.PermissionOverwrite(view_channel=False),
interaction.user: discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
),
}
for role in guild.roles:
if role.permissions.administrator or role.permissions.manage_channels:
overwrites[role] = discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
manage_channels=True,
)
safe_name = "".join(c for c in interaction.user.name if c.isalnum() or c in "-_")[:20]
channel_name = f"ticket-{safe_name}"
if category:
existing = discord.utils.get(category.text_channels, name=channel_name)
if existing:
await interaction.followup.send(
f"You already have an open ticket: {existing.mention}\n"
"Please use that channel or close it before opening a new one.",
ephemeral=True,
)
return
channel = await guild.create_text_channel(
name=channel_name,
category=category,
overwrites=overwrites,
topic=f"Ticket by {interaction.user} ({interaction.user.id}) | {subject}",
)
embed = discord.Embed(
title=f"Support Ticket — {subject}",
description=(
f"Welcome {interaction.user.mention}!\n\n"
"Please describe your issue in as much detail as possible.\n"
"A staff member will be with you shortly.\n\n"
"Press **🔒 Close Ticket** when your issue is resolved."
),
color=discord.Color.blurple(),
timestamp=discord.utils.utcnow(),
)
embed.set_footer(text=str(interaction.user), icon_url=interaction.user.display_avatar.url)
await channel.send(embed=embed, view=TicketCloseView())
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
if log_channel:
log_embed = discord.Embed(
title="Ticket Opened",
description=(
f"**User:** {interaction.user.mention} (`{interaction.user.id}`)\n"
f"**Channel:** {channel.mention}\n"
f"**Subject:** {subject}"
),
color=discord.Color.green(),
timestamp=discord.utils.utcnow(),
)
await log_channel.send(embed=log_embed)
await interaction.followup.send(
f"Your ticket has been created: {channel.mention}", ephemeral=True
)
# ─────────────────────────────────────────────────────────────────────────────
# Persistent Views (survive bot restarts)
# ─────────────────────────────────────────────────────────────────────────────
class TicketCloseView(discord.ui.View):
"""Close button that lives permanently in ticket channels."""
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(
label="🔒 Close Ticket",
style=discord.ButtonStyle.danger,
custom_id="ticket_close",
)
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
channel = interaction.channel
guild = interaction.guild
await interaction.response.send_message(
f"Ticket closed by {interaction.user.mention}. Channel will be deleted in 5 seconds.",
ephemeral=False,
)
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
if log_channel:
embed = discord.Embed(
title="Ticket Closed",
description=(
f"**Channel:** {channel.name}\n"
f"**Closed by:** {interaction.user.mention}\n"
f"**Topic:** {channel.topic or 'N/A'}"
),
color=discord.Color.red(),
timestamp=discord.utils.utcnow(),
)
await log_channel.send(embed=embed)
await asyncio.sleep(5)
await channel.delete(reason=f"Ticket closed by {interaction.user}")
class TierSelectView(discord.ui.View):
"""Three buttons for selecting a pricing tier — shown ephemerally after clicking Purchase."""
def __init__(self):
super().__init__(timeout=120)
async def _handle(self, interaction: discord.Interaction, tier: str):
await interaction.response.defer(ephemeral=True, thinking=True)
try:
url = create_checkout_session(
tier,
str(interaction.user.id),
str(interaction.user),
)
price_label = PRICES[tier]["label"]
embed = discord.Embed(
title="Complete Your Purchase",
description=(
f"You selected the **{price_label}** plan.\n\n"
f"[**→ Pay securely via Stripe**]({url})\n\n"
"Your license key will be **DM'd to you instantly** after payment.\n"
"The checkout link is valid for **24 hours**."
),
color=discord.Color.green(),
)
embed.set_footer(text="Powered by Stripe — we never store your card details.")
await interaction.followup.send(embed=embed, ephemeral=True)
except Exception as exc:
await interaction.followup.send(
f"❌ Could not create a checkout session. Please try again later.\n`{exc}`",
ephemeral=True,
)
@discord.ui.button(label="$10 / Week", style=discord.ButtonStyle.primary, custom_id="buy_weekly")
async def weekly(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._handle(interaction, "weekly")
@discord.ui.button(label="$25 / Month", style=discord.ButtonStyle.primary, custom_id="buy_monthly")
async def monthly(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._handle(interaction, "monthly")
@discord.ui.button(label="$150 Lifetime", style=discord.ButtonStyle.success, custom_id="buy_lifetime")
async def lifetime(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._handle(interaction, "lifetime")
class TicketModal(discord.ui.Modal, title="Open a Support Ticket"):
"""Modal that collects a subject before creating the ticket channel."""
subject = discord.ui.TextInput(
label="Subject",
placeholder="Brief description of your issue",
max_length=100,
required=True,
)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True, thinking=True)
await create_ticket(interaction, self.subject.value)
class SalesView(discord.ui.View):
"""
Persistent view with Purchase and Support buttons.
Posted once by /setup into the read-only sales channel.
"""
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(
label="Purchase",
style=discord.ButtonStyle.success,
custom_id="sales_purchase",
emoji="🛒",
)
async def purchase(self, interaction: discord.Interaction, button: discord.ui.Button):
embed = discord.Embed(
title="Ultimate Investment Tool — Pricing",
description=(
"Select a tier below. Payment is handled securely via **Stripe**.\n"
"Your license key will be delivered to your **DMs** the moment payment clears."
),
color=discord.Color.gold(),
)
embed.add_field(name="Weekly", value="**$10** / 7 days", inline=True)
embed.add_field(name="Monthly", value="**$25** / 30 days", inline=True)
embed.add_field(name="Lifetime", value="**$150** one-time", inline=True)
embed.set_footer(text="Having trouble? Click the Support button to open a ticket.")
await interaction.response.send_message(embed=embed, view=TierSelectView(), ephemeral=True)
@discord.ui.button(
label="Support",
style=discord.ButtonStyle.secondary,
custom_id="sales_support",
emoji="🎫",
)
async def support(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_modal(TicketModal())
# ─────────────────────────────────────────────────────────────────────────────
# Admin slash command — post the sales embed
# ─────────────────────────────────────────────────────────────────────────────
@tree.command(name="setup", description="Post the sales & support embed (admin only)")
@app_commands.checks.has_permissions(administrator=True)
async def setup(interaction: discord.Interaction):
embed = discord.Embed(
title="Ultimate Investment Tool",
description=(
"Click on the **Purchase** button to purchase a license. "
"Click on the **Support** button to create a support ticket. "
"Support tickets are checked by our staff, and can take time to get a response. "
"Please be patient when waiting for staff to respond."
),
color=discord.Color.gold(),
)
await interaction.channel.send(embed=embed, view=SalesView())
await interaction.response.send_message("Sales embed posted.", ephemeral=True)
@setup.error
async def setup_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.MissingPermissions):
await interaction.response.send_message(
"You need Administrator permission to use this command.", ephemeral=True
)
# ─────────────────────────────────────────────────────────────────────────────
# On-ready
# ─────────────────────────────────────────────────────────────────────────────
@bot.event
async def on_ready():
# Re-register all persistent views so buttons work after restarts
bot.add_view(SalesView())
bot.add_view(TicketCloseView())
# Sync slash commands to the guild instantly (no 1-hour global wait)
guild_obj = discord.Object(id=GUILD_ID)
tree.copy_global_to(guild=guild_obj)
await tree.sync(guild=guild_obj)
print(f"[Bot] Online as {bot.user} | Guild {GUILD_ID} | Slash commands synced")
# ─────────────────────────────────────────────────────────────────────────────
# Stripe webhook (aiohttp server on WEBHOOK_PORT)
# ─────────────────────────────────────────────────────────────────────────────
async def _handle_stripe_webhook(request: web.Request) -> web.Response:
payload = await request.read()
sig_header = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
print("[Webhook] Invalid Stripe signature — request rejected")
return web.Response(status=400, text="Invalid signature")
except Exception as exc:
print(f"[Webhook] Error parsing event: {exc}")
return web.Response(status=400, text=str(exc))
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
metadata = session.get("metadata", {})
discord_user_id = metadata.get("discord_user_id")
discord_username = metadata.get("discord_username", "Unknown")
tier = metadata.get("tier")
if not discord_user_id or tier not in PRICES:
print(f"[Webhook] Missing/invalid metadata: {metadata}")
return web.Response(status=200, text="OK")
# 1. Issue license in Supabase
try:
key = issue_license(
tier,
notes=f"Discord: {discord_username} ({discord_user_id})",
)
print(f"[License] Issued {key} ({tier}) for Discord user {discord_user_id}")
except Exception as exc:
print(f"[License] Failed to issue for {discord_user_id}: {exc}")
return web.Response(status=500, text="License issuance failed")
# 2. DM the license key to the buyer
price_info = PRICES[tier]
expiry_str = (
"Never (lifetime)"
if price_info["days"] is None
else f"{price_info['days']} days from today"
)
try:
user = await bot.fetch_user(int(discord_user_id))
embed = discord.Embed(
title="🎉 Purchase Confirmed — Ultimate Investment Tool",
description=(
f"Thank you for your purchase!\n\n"
f"**Your License Key:**\n```\n{key}\n```\n"
f"**Plan:** {price_info['label']}\n"
f"**Expires:** {expiry_str}\n\n"
"Paste this key into the application when prompted.\n"
"⚠️ This key is locked to one machine — keep it private."
),
color=discord.Color.green(),
)
embed.set_footer(text="Need help? Click the Support button in the server.")
await user.send(embed=embed)
print(f"[Bot] License DM'd to {discord_username} ({discord_user_id})")
except discord.Forbidden:
print(f"[Bot] Cannot DM {discord_user_id} — DMs may be disabled.")
except Exception as exc:
print(f"[Bot] DM error for {discord_user_id}: {exc}")
# 3. Assign roles in the guild
try:
guild = bot.get_guild(GUILD_ID)
if guild:
member = await guild.fetch_member(int(discord_user_id))
roles_to_add = []
user_role = guild.get_role(ROLE_USER_ID)
if user_role:
roles_to_add.append(user_role)
if tier == "lifetime":
lifetime_role = guild.get_role(ROLE_LIFETIME_ID)
if lifetime_role:
roles_to_add.append(lifetime_role)
if roles_to_add:
await member.add_roles(*roles_to_add, reason=f"Purchased {tier} via Stripe")
names = ", ".join(r.name for r in roles_to_add)
print(f"[Bot] Assigned roles '{names}' to {discord_username}")
else:
print(f"[Bot] No valid roles found to assign")
except discord.NotFound:
print(f"[Bot] Member {discord_user_id} not found in guild (may have left)")
except Exception as exc:
print(f"[Bot] Role assignment error for {discord_user_id}: {exc}")
return web.Response(status=200, text="OK")
async def _start_webhook_server():
app = web.Application()
app.router.add_post("/stripe/webhook", _handle_stripe_webhook)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT)
await site.start()
print(f"[Webhook] Stripe webhook server listening on :{WEBHOOK_PORT}/stripe/webhook")
# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────
async def main():
async with bot:
await _start_webhook_server()
await bot.start(DISCORD_TOKEN)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,5 +0,0 @@
discord.py>=2.3.0
stripe>=7.0.0
aiohttp>=3.9.0
requests>=2.31.0
python-dotenv>=1.0.0

7729
dist/screener_gui.py vendored

File diff suppressed because it is too large Load Diff

164
dist/stock_screener.py vendored
View File

@ -27,6 +27,7 @@ FORMULA (Composite Score):
import argparse
import html
import os
import random
import re
import sys
@ -791,7 +792,8 @@ def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
time.sleep(0.15) # stay comfortably under SEC's 10 req/sec limit
return result
except Exception:
_FRAME_CACHE[key] = {}
# Do not cache transient failures (network errors, timeouts, etc.)
# Only 404s (already handled above) should produce a permanent empty cache entry.
return {}
@ -808,7 +810,7 @@ def _sum_frames(concepts: list[str], unit: str,
ttm.setdefault(cik, {})
if period not in ttm[cik]:
ttm[cik][period] = val
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
def _best_frame(concepts: list[str], unit: str,
@ -995,11 +997,21 @@ def _fetch_edgar_bulk(close_map: dict[str, pd.Series]) -> dict[str, dict]:
# Analyst + Fundamentals Cache (Verimund API)
# ---------------------------------------------------------------------------
try:
from license_check import _API_URL as _API_URL, get_api_headers as _get_api_headers
except ImportError:
_API_URL = None
_get_api_headers = lambda: {}
_API_URL = "https://api.verimundsolutions.com"
def _get_api_headers() -> dict:
"""Read JWT from the auth cache file directly — no dependency on obfuscated license_check."""
try:
import json as _json
_cache = os.path.join(
os.environ.get("APPDATA", os.path.expanduser("~")),
"StockScreener", "auth_cache.dat"
)
with open(_cache, "r", encoding="utf-8") as _f:
_tok = _json.load(_f).get("jwt_token", "")
return {"Authorization": f"Bearer {_tok}", "Content-Type": "application/json"}
except Exception:
return {"Content-Type": "application/json"}
def _load_analyst_cache() -> dict[str, dict]:
@ -1298,6 +1310,8 @@ def _fetch_fundamentals(
price_now = float(close.iloc[-1])
def pct_return(days_back: int) -> float | None:
if len(close) <= days_back:
return None
idx = max(0, len(close) - days_back)
past = float(close.iloc[idx])
return (price_now - past) / past if past > 0 else None
@ -1350,7 +1364,7 @@ def _fetch_fundamentals(
"ret_12m": pct_return(252),
"roe": info.get("returnOnEquity"),
"roa": info.get("returnOnAssets"),
"debt_to_equity": info.get("debtToEquity"),
"debt_to_equity": (info["debtToEquity"] / 100.0) if info.get("debtToEquity") is not None else None,
"total_debt": info.get("totalDebt"),
"total_cash": info.get("totalCash"),
"book_value": info.get("bookValue"),
@ -1366,6 +1380,7 @@ def _fetch_fundamentals(
"analyst_target": target_price,
"recommendation": info.get("recommendationKey", "N/A"),
"short_percent": info.get("shortPercentOfFloat"),
"short_ratio": info.get("shortRatio"),
"beta": info.get("beta"),
"volatility_30d": volatility,
"market_cap": info.get("marketCap"),
@ -1500,6 +1515,8 @@ def fetch_all(
price_now = float(close.iloc[-1])
def pct_return(days_back: int) -> float | None:
if len(close) <= days_back:
return None
idx = max(0, len(close) - days_back)
past = float(close.iloc[idx])
return (price_now - past) / past if past > 0 else None
@ -1544,7 +1561,7 @@ def fetch_all(
results.append({
"ticker": ticker,
"name": edgar.get("name", ticker),
"name": analyst.get("name") or edgar.get("name") or ticker,
"sector": analyst.get("sector") or edgar.get("sector", "N/A"),
"industry": analyst.get("industry") or edgar.get("industry", "N/A"),
"price": price_now,
@ -1586,8 +1603,15 @@ def fetch_all(
"analyst_count": analyst.get("analyst_count"),
"analyst_target": analyst_target,
"recommendation": analyst.get("recommendation", "N/A"),
# Risk — FINRA cache first, then yfinance shortPercentOfFloat as display fallback
"short_percent": finra_short.get(ticker) or analyst.get("short_percent"),
# Bug 10 fix: use only shortPercentOfFloat (0.01.0 float fraction) from yfinance.
# FINRA short-volume ratio (sv/total_vol) is on a different scale (typically 0.30.6)
# and is incompatible with the short_pts breakpoints which expect % of float.
# Do NOT fall back to finra_short here; leave as None when yfinance data is absent.
"short_percent": analyst.get("short_percent"),
"short_ratio": analyst.get("short_ratio"),
"piotroski_score": edgar.get("piotroski_score"),
"accruals_ratio": edgar.get("accruals_ratio"),
"news_headline_count": analyst.get("news_headline_count"),
"beta": beta_map.get(ticker),
"volatility_30d": volatility,
# Market data
@ -1803,39 +1827,40 @@ _PROFILE_WEIGHTS: dict[str, dict[str, dict[str, float]]] = {
"mature": {
"value": {"pe": 0.35, "pb": 0.20, "ev_ebitda": 0.45},
"growth": {"revenue_growth": 0.30, "earnings_growth": 0.45, "eps_growth": 0.25},
"quality": {"roa": 0.40, "roe": 0.20, "debt_to_equity": 0.30, "current_ratio": 0.10},
"profitability": {"fcf_yield": 0.40, "operating_margin": 0.38, "profit_margin": 0.22},
"quality": {"roa": 0.40, "roe": 0.20, "debt_to_equity": 0.30, "current_ratio": 0.10, "piotroski": 0.20},
"profitability": {"fcf_yield": 0.40, "operating_margin": 0.38, "profit_margin": 0.22, "accruals": 0.15},
},
"high_growth": {
"value": {"pe": 0.00, "pb": 0.20, "ev_ebitda": 0.35, "ev_revenue": 0.45},
"growth": {"revenue_growth": 0.50, "earnings_growth": 0.30, "eps_growth": 0.20},
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.15, "current_ratio": 0.35},
"profitability": {"fcf_yield": 0.15, "operating_margin": 0.50, "profit_margin": 0.35},
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.15, "current_ratio": 0.35, "piotroski": 0.20},
"profitability": {"fcf_yield": 0.15, "operating_margin": 0.50, "profit_margin": 0.35, "accruals": 0.15},
},
"financial": {
"value": {"pe": 0.35, "pb": 0.50, "ev_ebitda": 0.15},
"growth": {"revenue_growth": 0.30, "earnings_growth": 0.50, "eps_growth": 0.20},
"quality": {"roa": 0.50, "roe": 0.30, "debt_to_equity": 0.00, "current_ratio": 0.20},
"profitability": {"fcf_yield": 0.30, "operating_margin": 0.00, "profit_margin": 0.70},
"quality": {"roa": 0.50, "roe": 0.30, "debt_to_equity": 0.00, "current_ratio": 0.20, "piotroski": 0.15},
"profitability": {"fcf_yield": 0.30, "operating_margin": 0.00, "profit_margin": 0.70, "accruals": 0.15},
},
"capital_intensive": {
"value": {"pe": 0.15, "pb": 0.20, "ev_ebitda": 0.65},
"growth": {"revenue_growth": 0.35, "earnings_growth": 0.40, "eps_growth": 0.25},
"quality": {"roa": 0.30, "roe": 0.15, "debt_to_equity": 0.40, "current_ratio": 0.15},
"profitability": {"fcf_yield": 0.35, "operating_margin": 0.45, "profit_margin": 0.20},
"quality": {"roa": 0.30, "roe": 0.15, "debt_to_equity": 0.40, "current_ratio": 0.15, "piotroski": 0.15},
"profitability": {"fcf_yield": 0.35, "operating_margin": 0.45, "profit_margin": 0.20, "accruals": 0.15},
},
"early_stage": {
"value": {"pe": 0.00, "pb": 0.15, "ev_ebitda": 0.00, "ev_revenue": 0.85},
"growth": {"revenue_growth": 0.70, "earnings_growth": 0.20, "eps_growth": 0.10},
"quality": {"roa": 0.20, "roe": 0.00, "debt_to_equity": 0.00, "current_ratio": 0.80},
"profitability": {"fcf_yield": 0.00, "operating_margin": 0.40, "profit_margin": 0.00,
"revenue_growth_proxy": 0.60},
"quality": {"roa": 0.20, "roe": 0.00, "debt_to_equity": 0.00, "current_ratio": 0.80, "piotroski": 0.10},
# revenue_growth_proxy removed — revenue growth already carries 70% of the growth score
"profitability": {"fcf_yield": 0.00, "operating_margin": 1.00, "profit_margin": 0.00,
"revenue_growth_proxy": 0.00},
},
"turnaround": {
"value": {"pe": 0.00, "pb": 0.30, "ev_ebitda": 0.70},
"growth": {"revenue_growth": 0.15, "earnings_growth": 0.80, "eps_growth": 0.05},
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.35, "current_ratio": 0.15},
"profitability": {"fcf_yield": 0.25, "operating_margin": 0.45, "profit_margin": 0.30},
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.35, "current_ratio": 0.15, "piotroski": 0.20},
"profitability": {"fcf_yield": 0.25, "operating_margin": 0.45, "profit_margin": 0.30, "accruals": 0.15},
},
}
@ -1871,23 +1896,24 @@ def compute_value_score(df: pd.DataFrame,
"""
ss = sector_stats or {}
sectors = df.get("sector", pd.Series(["N/A"] * len(df), index=df.index))
pe = df["pe_forward"].combine_first(df["pe_trailing"]).where(lambda x: x > 0)
# Pre-compute z-scores for each metric
pe_s = _z_series(pe, "pe_trailing", sectors, ss)
pb_s = _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
ev_s = _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
evr_s = _z_series(df.get("ev_revenue", pd.Series(
# Z-score forward and trailing PE against their own sector distributions, then combine
# Lower valuation ratios are better — invert z-scores so cheap stocks rank high
fwd_s = 100.0 - _z_series(df["pe_forward"].where(df["pe_forward"] > 0), "pe_forward", sectors, ss)
trl_s = 100.0 - _z_series(df["pe_trailing"].where(df["pe_trailing"] > 0), "pe_trailing", sectors, ss)
pe_s = fwd_s.combine_first(trl_s)
pb_s = 100.0 - _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
ev_s = 100.0 - _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
evr_s = 100.0 - _z_series(df.get("ev_revenue", pd.Series(
[None]*len(df), index=df.index)),
"ev_revenue", sectors, ss)
scores = []
for i, row in df.iterrows():
ms = {
"pe": float(pe_s.iloc[i]),
"pb": float(pb_s.iloc[i]),
"ev_ebitda": float(ev_s.iloc[i]),
"ev_revenue": float(evr_s.iloc[i]),
"pe": float(pe_s.loc[i]),
"pb": float(pb_s.loc[i]),
"ev_ebitda": float(ev_s.loc[i]),
"ev_revenue": float(evr_s.loc[i]),
}
scores.append(_profile_score_row(row, "value", ms))
return pd.Series(scores, index=df.index)
@ -1914,9 +1940,9 @@ def compute_growth_score(df: pd.DataFrame,
scores = []
for i, row in df.iterrows():
ms = {
"revenue_growth": float(rev_s.iloc[i]),
"earnings_growth": float(ear_s.iloc[i]),
"eps_growth": float(eps_s.iloc[i]),
"revenue_growth": float(rev_s.loc[i]),
"earnings_growth": float(ear_s.loc[i]),
"eps_growth": float(eps_s.loc[i]),
}
scores.append(_profile_score_row(row, "growth", ms))
return pd.Series(scores, index=df.index)
@ -1967,19 +1993,27 @@ def compute_quality_score(df: pd.DataFrame,
roa_s = _z_series(df["roa"], "roa", sectors, ss)
roe_s = _z_series(df["roe"], "roe", sectors, ss)
de_s = _z_series(df["debt_to_equity"], "debt_to_equity", sectors, ss)
cr_s = _z_series(df["current_ratio"], "current_ratio", sectors, ss)
# Negative D/E means negative equity (insolvency risk) — treat as extremely high leverage
de_raw = df["debt_to_equity"].where(df["debt_to_equity"] >= 0, 999.0)
# D/E z-score is inverted — lower D/E is better, so flip the z-score
de_s = 100.0 - de_s
de_s = 100.0 - _z_series(de_raw, "debt_to_equity", sectors, ss)
# Piotroski partial F-Score (0-4) → 0-100 via absolute breakpoints
p_raw = pd.to_numeric(df.get("piotroski_score",
pd.Series([None] * len(df), index=df.index)), errors="coerce")
p_pts = [(0, 5), (1, 30), (2, 50), (3, 72), (4, 95)]
piotroski_s = _score_series(p_raw, p_pts)
scores = []
for i, row in df.iterrows():
ms = {
"roa": float(roa_s.iloc[i]),
"roe": float(roe_s.iloc[i]),
"debt_to_equity": float(de_s.iloc[i]),
"current_ratio": float(cr_s.iloc[i]),
"roa": float(roa_s.loc[i]),
"roe": float(roe_s.loc[i]),
"debt_to_equity": float(de_s.loc[i]),
"current_ratio": float(cr_s.loc[i]),
"piotroski": float(piotroski_s.loc[i]),
}
scores.append(_profile_score_row(row, "quality", ms))
return pd.Series(scores, index=df.index)
@ -1999,13 +2033,21 @@ def compute_profitability_score(df: pd.DataFrame,
pm_s = _z_series(df["profit_margin"], "profit_margin", sectors, ss)
rev_s = _z_series(df["revenue_growth"], "revenue_growth", sectors, ss)
# Sloan accruals ratio: more negative = earnings are cash-backed = better quality
acc_raw = pd.to_numeric(df.get("accruals_ratio",
pd.Series([None] * len(df), index=df.index)), errors="coerce")
acc_pts = [(-0.25, 95), (-0.10, 78), (-0.03, 62), (0.0, 50),
(0.03, 38), (0.10, 22), (0.25, 5)]
accruals_s = _score_series(acc_raw, acc_pts)
scores = []
for i, row in df.iterrows():
ms = {
"fcf_yield": float(fcf_s.iloc[i]),
"operating_margin": float(om_s.iloc[i]),
"profit_margin": float(pm_s.iloc[i]),
"revenue_growth_proxy": float(rev_s.iloc[i]), # used by early_stage
"fcf_yield": float(fcf_s.loc[i]),
"operating_margin": float(om_s.loc[i]),
"profit_margin": float(pm_s.loc[i]),
"revenue_growth_proxy": float(rev_s.loc[i]), # used by early_stage
"accruals": float(accruals_s.loc[i]),
}
scores.append(_profile_score_row(row, "profitability", ms))
return pd.Series(scores, index=df.index)
@ -2013,12 +2055,27 @@ def compute_profitability_score(df: pd.DataFrame,
def compute_sentiment_score(df: pd.DataFrame) -> pd.Series:
"""
News Sentiment Score VADER compound score mapped to 0100 (absolute scale).
Sentiment is inherently comparable across all stocks; no z-scoring applied.
News Sentiment Score VADER compound score mapped to 0100, confidence-weighted
by headline count. Fewer headlines blend toward neutral (50) to avoid overreacting
to a single article.
"""
sent_pts = [(-1.0, 0), (-0.40, 20), (-0.15, 36), (0.0, 50),
(0.15, 64), (0.40, 80), (1.0, 100)]
return _score_series(df["news_sentiment"], sent_pts)
base = _score_series(df["news_sentiment"], sent_pts)
# Derive headline count from stored field or from news_headlines list
if "news_headline_count" in df.columns:
count = pd.to_numeric(df["news_headline_count"], errors="coerce").fillna(0)
elif "news_headlines" in df.columns:
count = df["news_headlines"].apply(
lambda h: len(h) if isinstance(h, list) else 0
).astype(float)
else:
return base
# 5+ headlines = full confidence; 0 headlines = 5% confidence (nearly neutral)
confidence = (count.clip(upper=5) / 5.0).clip(lower=0.05)
return base * confidence + 50.0 * (1.0 - confidence)
def compute_analyst_score(df: pd.DataFrame,
@ -2092,8 +2149,9 @@ def score_stocks(df: pd.DataFrame, weights: dict | None = None,
"ret_1m", "ret_3m", "ret_6m", "ret_12m",
"roe", "roa", "debt_to_equity", "current_ratio",
"profit_margin", "operating_margin", "fcf_yield",
"news_sentiment", "analyst_norm", "analyst_upside", "analyst_count",
"short_percent", "volatility_30d", "beta", "price", "market_cap",
"news_sentiment", "news_headline_count", "analyst_norm", "analyst_upside", "analyst_count",
"short_percent", "short_ratio", "volatility_30d", "beta", "price", "market_cap",
"piotroski_score", "accruals_ratio",
]
for col in _NUMERIC_COLS:
if col in df.columns:

View File

@ -1,2 +0,0 @@
[2026-03-16 14:10:05] No rows returned for channel=beta
[2026-03-16 14:18:03] RPC response for channel=beta: version=1.1.4, exe_url=present

View File

@ -1,391 +0,0 @@
"""
Health Monitor
==============
Runs every 5 minutes via cron. Checks processes, resources, SSL, cache logs,
and runs nightly database backups. Alerts via Discord webhook on any failure.
Auto-fixes safe/known issues (Tier 1). Alerts and waits for human decision on
everything else (Tier 2/3).
Deploy to: /srv/health_monitor.py
Cron entry (/etc/cron.d/health_monitor):
*/5 * * * * root python3 /srv/health_monitor.py >> /var/log/health_monitor.log 2>&1
Secrets loaded from /srv/api/.env:
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
DB_PASS=...
"""
import json
import os
import shutil
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
load_dotenv("/srv/api/.env")
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DISCORD_WEBHOOK = os.getenv("DISCORD_WEBHOOK", "")
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
DB_USER = "verimund_user"
DB_NAME = "verimund"
BACKUP_DIR = Path("/srv/backups/db")
BACKUP_KEEP_DAYS = 7
LOG_DIR = Path("/var/log")
STATE_FILE = Path("/srv/health_monitor_state.json")
DISK_WARN_PCT = 80
RAM_WARN_PCT = 90
CPU_WARN_PCT = 90
SSL_WARN_DAYS = 14
DOMAINS = [
"verimundsolutions.com",
"api.verimundsolutions.com",
"git.verimundsolutions.com",
"admin.verimundsolutions.com",
]
CACHE_LOGS = {
"stable": LOG_DIR / "cache_builder_stable.log",
"beta": LOG_DIR / "cache_builder_beta.log",
}
# ---------------------------------------------------------------------------
# State (tracks last daily/weekly run so we don't repeat within the window)
# ---------------------------------------------------------------------------
def _load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
pass
return {}
def _save_state(state: dict) -> None:
STATE_FILE.write_text(json.dumps(state, indent=2))
def _should_run(state: dict, key: str, interval_seconds: int) -> bool:
last = state.get(key, 0)
return (time.time() - last) >= interval_seconds
def _mark_run(state: dict, key: str) -> None:
state[key] = time.time()
# ---------------------------------------------------------------------------
# Discord alerts
# ---------------------------------------------------------------------------
def alert(message: str, tier: int = 2) -> None:
"""Send a Discord webhook message. Never raises — alerting must not crash the monitor."""
prefix = {1: "✅ [AUTO-FIXED]", 2: "⚠ [TIER 2]", 3: "🚨 [TIER 3 — ACTION REQUIRED]"}.get(tier, "")
full = f"{prefix} {message}"
print(full, flush=True)
if not DISCORD_WEBHOOK:
return
try:
import urllib.request
payload = json.dumps({"content": full}).encode()
req = urllib.request.Request(
DISCORD_WEBHOOK,
data=payload,
headers={"Content-Type": "application/json", "User-Agent": "DiscordBot (https://verimundsolutions.com, 1.0)"},
method="POST",
)
urllib.request.urlopen(req, timeout=10)
except Exception as e:
print(f" [alert] Discord send failed: {e}", flush=True)
def log(message: str) -> None:
print(f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC] {message}", flush=True)
# ---------------------------------------------------------------------------
# Process checks
# ---------------------------------------------------------------------------
def _pm2_list() -> list[str]:
"""Return list of PM2 process names that are 'online'."""
try:
out = subprocess.check_output(
["pm2", "jlist"], stderr=subprocess.DEVNULL, text=True
)
procs = json.loads(out)
return [p["name"] for p in procs if p.get("pm2_env", {}).get("status") == "online"]
except Exception:
return []
def check_processes() -> None:
log("Checking processes...")
# Nginx
r = subprocess.run(["systemctl", "is-active", "nginx"], capture_output=True, text=True)
if r.stdout.strip() != "active":
subprocess.run(["systemctl", "start", "nginx"], capture_output=True)
alert("Nginx was down — restarted.", tier=1)
# PostgreSQL
r = subprocess.run(["systemctl", "is-active", "postgresql"], capture_output=True, text=True)
if r.stdout.strip() != "active":
subprocess.run(["systemctl", "start", "postgresql"], capture_output=True)
alert("PostgreSQL was down — restarted.", tier=1)
# PM2 itself
r = subprocess.run(["systemctl", "is-active", "pm2-root"], capture_output=True, text=True)
if r.stdout.strip() != "active":
subprocess.run(["systemctl", "start", "pm2-root"], capture_output=True)
alert("PM2 service was down — restarted.", tier=1)
# PM2-managed processes
online = _pm2_list()
for name in ("api",):
if name not in online:
subprocess.run(["pm2", "restart", name], capture_output=True)
alert(f"PM2 process '{name}' was down — restarted.", tier=1)
# API health check (HTTP)
try:
import urllib.request
urllib.request.urlopen("http://127.0.0.1:8000/docs", timeout=5)
except Exception:
subprocess.run(["pm2", "restart", "api"], capture_output=True)
alert("API server not responding on port 8000 — restarted via PM2.", tier=1)
# ---------------------------------------------------------------------------
# Resource checks
# ---------------------------------------------------------------------------
def check_resources() -> None:
log("Checking resources...")
# Disk
usage = shutil.disk_usage("/")
pct = usage.used / usage.total * 100
if pct >= DISK_WARN_PCT:
_clean_old_logs()
# Re-check after cleanup
usage = shutil.disk_usage("/")
pct = usage.used / usage.total * 100
if pct >= DISK_WARN_PCT:
alert(
f"Disk usage is {pct:.1f}% after auto-cleanup. Manual intervention may be needed.",
tier=3 if pct >= 90 else 2,
)
else:
alert(f"Disk was {pct:.1f}% — old logs cleaned, now {pct:.1f}%.", tier=1)
# RAM
with open("/proc/meminfo") as f:
lines = {line.split(":")[0]: int(line.split()[1]) for line in f if ":" in line}
total = lines.get("MemTotal", 1)
avail = lines.get("MemAvailable", 1)
ram_pct = (total - avail) / total * 100
if ram_pct >= RAM_WARN_PCT:
alert(f"RAM usage is {ram_pct:.1f}% ({(total - avail) // 1024} MB used of {total // 1024} MB).", tier=2)
# CPU (1-minute load average vs core count)
load1 = os.getloadavg()[0]
cores = os.cpu_count() or 1
cpu_pct = load1 / cores * 100
if cpu_pct >= CPU_WARN_PCT:
alert(f"CPU load is {cpu_pct:.1f}% (load avg {load1:.2f} on {cores} cores).", tier=2)
def _clean_old_logs() -> None:
"""Delete log files older than 30 days to free disk space."""
now = time.time()
cutoff = now - 30 * 86400
cleaned = 0
for path in LOG_DIR.glob("*.log*"):
if path.stat().st_mtime < cutoff:
try:
path.unlink()
cleaned += 1
except Exception:
pass
if cleaned:
log(f" Cleaned {cleaned} old log file(s).")
# ---------------------------------------------------------------------------
# SSL certificate checks (daily)
# ---------------------------------------------------------------------------
def check_ssl(state: dict) -> None:
if not _should_run(state, "ssl_check", 86400):
return
log("Checking SSL certificates...")
_mark_run(state, "ssl_check")
for domain in DOMAINS:
try:
r = subprocess.run(
["certbot", "certificates", "--domain", domain],
capture_output=True, text=True,
)
for line in r.stdout.splitlines():
if "VALID:" in line:
days = int(line.split("VALID:")[1].split("day")[0].strip())
if days <= SSL_WARN_DAYS:
log(f" SSL for {domain} expires in {days} days — renewing...")
result = subprocess.run(
["certbot", "renew", "--cert-name", domain, "--non-interactive"],
capture_output=True, text=True,
)
if result.returncode == 0:
alert(f"SSL cert for {domain} renewed ({days} days remaining).", tier=1)
else:
alert(
f"SSL cert for {domain} expires in {days} days but renewal FAILED.\n"
f"```{result.stderr[-500:]}```",
tier=3,
)
except Exception as e:
log(f" SSL check failed for {domain}: {e}")
# ---------------------------------------------------------------------------
# Cache log checks (daily)
# ---------------------------------------------------------------------------
def check_cache_logs(state: dict) -> None:
if not _should_run(state, "cache_log_check", 86400):
return
log("Checking cache builder logs...")
_mark_run(state, "cache_log_check")
for channel, log_path in CACHE_LOGS.items():
if not log_path.exists():
continue
try:
content = log_path.read_text(errors="replace")
lines = content.splitlines()
recent = "\n".join(lines[-50:])
if "Traceback" in recent or "Error" in recent:
alert(
f"Cache builder ({channel}) log contains errors:\n```{recent[-800:]}```",
tier=2,
)
elif "0 rows" in recent or "0 tickers" in recent.lower():
alert(
f"Cache builder ({channel}) may have produced 0 rows:\n```{recent[-800:]}```",
tier=2,
)
except Exception as e:
log(f" Could not read cache log for {channel}: {e}")
# ---------------------------------------------------------------------------
# Database backup (daily)
# ---------------------------------------------------------------------------
def run_backup(state: dict) -> None:
if not _should_run(state, "db_backup", 86400):
return
log("Running database backup...")
_mark_run(state, "db_backup")
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
out_path = BACKUP_DIR / f"verimund_{date_str}.sql.gz"
env = os.environ.copy()
env["PGPASSWORD"] = DB_PASS
dump = subprocess.run(
["pg_dump", "-U", DB_USER, "-h", "127.0.0.1", DB_NAME],
capture_output=True, env=env,
)
if dump.returncode != 0:
alert(
f"pg_dump failed:\n```{dump.stderr.decode()[-500:]}```",
tier=3,
)
return
import gzip
with gzip.open(out_path, "wb") as f:
f.write(dump.stdout)
size_mb = out_path.stat().st_size / 1024 / 1024
log(f" Backup written: {out_path} ({size_mb:.1f} MB)")
# Sync to R2 if rclone is configured
r = subprocess.run(
["rclone", "sync", str(BACKUP_DIR), "r2:verimund-backups/db/"],
capture_output=True, text=True,
)
if r.returncode != 0:
alert(f"rclone sync to R2 failed:\n```{r.stderr[-400:]}```", tier=2)
else:
log(" R2 sync complete.")
# Remove backups older than BACKUP_KEEP_DAYS
cutoff = time.time() - BACKUP_KEEP_DAYS * 86400
for f in BACKUP_DIR.glob("verimund_*.sql.gz"):
if f.stat().st_mtime < cutoff:
f.unlink()
log(f" Deleted old backup: {f.name}")
# ---------------------------------------------------------------------------
# Log compression (weekly)
# ---------------------------------------------------------------------------
def compress_old_logs(state: dict) -> None:
if not _should_run(state, "log_compress", 7 * 86400):
return
log("Compressing old logs...")
_mark_run(state, "log_compress")
import gzip
cutoff = time.time() - 7 * 86400
compressed = 0
for path in LOG_DIR.glob("*.log"):
if path.stat().st_mtime < cutoff:
gz_path = path.with_suffix(".log.gz")
try:
with open(path, "rb") as f_in, gzip.open(gz_path, "wb") as f_out:
f_out.write(f_in.read())
path.unlink()
compressed += 1
except Exception as e:
log(f" Could not compress {path.name}: {e}")
if compressed:
log(f" Compressed {compressed} log file(s).")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
log("=== Health monitor start ===")
state = _load_state()
check_processes()
check_resources()
check_ssl(state)
check_cache_logs(state)
run_backup(state)
compress_old_logs(state)
_save_state(state)
log("=== Health monitor done ===")
if __name__ == "__main__":
main()

View File

@ -14,6 +14,9 @@ import sys
import tkinter as tk
from tkinter import messagebox
# Add dist/ to path so screener_gui and stock_screener are importable
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist"))
# 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):

View File

@ -1,81 +0,0 @@
# verimundsolutions.com - website (Next.js, coming later)
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name verimundsolutions.com;
ssl_certificate /etc/letsencrypt/live/verimundsolutions.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/verimundsolutions.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
return 200 'Coming soon';
add_header Content-Type text/plain;
}
}
# api.verimundsolutions.com - API server
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name api.verimundsolutions.com;
ssl_certificate /etc/letsencrypt/live/verimundsolutions.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/verimundsolutions.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# git.verimundsolutions.com - Gitea
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name git.verimundsolutions.com;
ssl_certificate /etc/letsencrypt/live/git.verimundsolutions.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.verimundsolutions.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# admin.verimundsolutions.com - Admin panel (coming later)
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name admin.verimundsolutions.com;
ssl_certificate /etc/letsencrypt/live/git.verimundsolutions.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.verimundsolutions.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
return 200 'Admin coming soon';
add_header Content-Type text/plain;
}
}
# Redirect all HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name verimundsolutions.com api.verimundsolutions.com git.verimundsolutions.com admin.verimundsolutions.com;
return 301 https://$host$request_uri;
}

View File

@ -1,40 +0,0 @@
[BUG]: out of license
## Command Line
C:\Users\noach\AppData\Local\Programs\Python\Python314\Scripts\pyarmor gen --platform windows.x86_64 --output C:\Users\noach\Desktop\Stock Tool\Stock Tool\_pyarmor_build C:\Users\noach\Desktop\Stock Tool\Stock Tool\launcher.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\license_check.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\updater.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\activation_dialog.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\hwid.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\screener_gui.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\stock_screener.py
## Environments
Python 3.14.3
Pyarmor 9.2.4 (trial), 000000, non-profits
Platform windows.x86_64
Native windows.amd64
Home C:\Users\noach\.pyarmor
## Traceback
Traceback (most recent call last):
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 804, in main
main_entry(sys.argv[1:])
~~~~~~~~~~^^^^^^^^^^^^^^
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 789, in main_entry
return args.func(ctx, args)
~~~~~~~~~^^^^^^^^^^^
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 248, in cmd_gen
builder.process(options)
~~~~~~~~~~~~~~~^^^^^^^^^
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 190, in process
async_obfuscate_scripts(self, n) if n else self._obfuscate_scripts()
~~~~~~~~~~~~~~~~~~~~~~~^^
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 145, in _obfuscate_scripts
code = Pytransform3.generate_obfuscated_script(self.ctx, r)
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\core\__init__.py", line 95, in generate_obfuscated_script
return m.generate_obfuscated_script(ctx, res)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
File "<maker>", line 728, in generate_obfuscated_script
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__init__.py", line 16, in process
return meth(self, res, *args, **kwargs)
File "<maker>", line 553, in process
File "<maker>", line 559, in coserialize
File "<maker>", line 609, in _build_ast_body
RuntimeError: out of license

View File

@ -1,16 +0,0 @@
#!/bin/bash
DATE=$(date +%Y-%m-%d)
BACKUP_DIR=/srv/backups/db
R2_BUCKET=r2:verimund-backups
# Database backup
pg_dump -h 127.0.0.1 -U verimund_user verimund | gzip > $BACKUP_DIR/verimund_$DATE.sql.gz
# Upload to R2
rclone sync $BACKUP_DIR $R2_BUCKET/db
# Keep only last 7 local backups
cd $BACKUP_DIR && ls -t | tail -n +8 | xargs -r rm
echo "Backup complete: $DATE"

View File

@ -1,298 +0,0 @@
-- =============================================================================
-- Ultimate Investment Tool — Supabase Schema
-- Paste this entire file into Supabase SQL Editor and click Run.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. TABLES
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS licenses (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
license_key TEXT UNIQUE NOT NULL,
hwid TEXT, -- NULL until first activation
tier TEXT NOT NULL DEFAULT 'monthly', -- 'weekly' | 'monthly' | 'lifetime'
channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta'
expiry_date TIMESTAMPTZ, -- NULL = lifetime
active BOOLEAN NOT NULL DEFAULT true,
machines_allowed INTEGER NOT NULL DEFAULT 1,
notes TEXT, -- customer name / order ref
created_at TIMESTAMPTZ DEFAULT NOW(),
activated_at TIMESTAMPTZ
);
-- Add channel column to existing tables (safe to run on already-created tables)
ALTER TABLE licenses ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable';
CREATE TABLE IF NOT EXISTS app_versions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
version TEXT UNIQUE NOT NULL, -- e.g. '1.0.1'
channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta'
gui_py_url TEXT, -- Supabase Storage URL for screener_gui.py
screener_py_url TEXT, -- Supabase Storage URL for stock_screener.py
release_notes TEXT,
is_latest BOOLEAN NOT NULL DEFAULT true,
released_at TIMESTAMPTZ DEFAULT NOW()
);
-- Add channel column to existing app_versions tables (safe to run on already-created tables)
ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable';
CREATE TABLE IF NOT EXISTS auth_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
license_key TEXT,
hwid TEXT,
result TEXT, -- 'valid' | 'invalid'
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS analyst_cache (
ticker TEXT PRIMARY KEY,
pe_forward DOUBLE PRECISION,
eps_forward DOUBLE PRECISION,
analyst_norm DOUBLE PRECISION,
analyst_upside DOUBLE PRECISION,
analyst_count INTEGER,
analyst_target DOUBLE PRECISION,
recommendation TEXT,
news_sentiment DOUBLE PRECISION,
sector TEXT,
industry TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fundamentals_cache (
ticker TEXT PRIMARY KEY,
-- Valuation
pe_trailing DOUBLE PRECISION,
pb_ratio DOUBLE PRECISION,
ev_ebitda DOUBLE PRECISION,
eps_trailing DOUBLE PRECISION,
-- Growth
revenue DOUBLE PRECISION,
revenue_growth DOUBLE PRECISION,
earnings_growth DOUBLE PRECISION,
-- Quality
roe DOUBLE PRECISION,
roa DOUBLE PRECISION,
debt_to_equity DOUBLE PRECISION,
total_debt DOUBLE PRECISION,
total_cash DOUBLE PRECISION,
book_value DOUBLE PRECISION,
current_ratio DOUBLE PRECISION,
-- Profitability
profit_margin DOUBLE PRECISION,
operating_margin DOUBLE PRECISION,
fcf_yield DOUBLE PRECISION,
dividend_yield DOUBLE PRECISION,
-- Market
market_cap DOUBLE PRECISION,
shares_outstanding DOUBLE PRECISION,
ev_revenue DOUBLE PRECISION,
-- Risk
short_percent DOUBLE PRECISION,
-- Meta
filer_type TEXT, -- 'us-gaap-quarterly' | 'us-gaap-annual' | 'ifrs-annual' | 'none'
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sector_stats (
sector TEXT PRIMARY KEY,
-- Valuation
pe_trailing_med DOUBLE PRECISION, pe_trailing_mad DOUBLE PRECISION,
pb_ratio_med DOUBLE PRECISION, pb_ratio_mad DOUBLE PRECISION,
ev_ebitda_med DOUBLE PRECISION, ev_ebitda_mad DOUBLE PRECISION,
ev_revenue_med DOUBLE PRECISION, ev_revenue_mad DOUBLE PRECISION,
-- Growth
revenue_growth_med DOUBLE PRECISION, revenue_growth_mad DOUBLE PRECISION,
earnings_growth_med DOUBLE PRECISION, earnings_growth_mad DOUBLE PRECISION,
eps_growth_med DOUBLE PRECISION, eps_growth_mad DOUBLE PRECISION,
-- Quality
roe_med DOUBLE PRECISION, roe_mad DOUBLE PRECISION,
roa_med DOUBLE PRECISION, roa_mad DOUBLE PRECISION,
debt_to_equity_med DOUBLE PRECISION, debt_to_equity_mad DOUBLE PRECISION,
current_ratio_med DOUBLE PRECISION, current_ratio_mad DOUBLE PRECISION,
-- Profitability
profit_margin_med DOUBLE PRECISION, profit_margin_mad DOUBLE PRECISION,
operating_margin_med DOUBLE PRECISION, operating_margin_mad DOUBLE PRECISION,
fcf_yield_med DOUBLE PRECISION, fcf_yield_mad DOUBLE PRECISION,
-- Analyst
analyst_upside_med DOUBLE PRECISION, analyst_upside_mad DOUBLE PRECISION,
-- Meta
ticker_count INTEGER,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Add columns that may be missing if sector_stats was created before the current schema
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_med DOUBLE PRECISION;
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_mad DOUBLE PRECISION;
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS ticker_count INTEGER;
ALTER TABLE sector_stats ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'sector_stats' AND policyname = 'anon_select'
) THEN
CREATE POLICY anon_select ON sector_stats FOR SELECT TO anon USING (true);
END IF;
END $$;
ALTER TABLE fundamentals_cache ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'fundamentals_cache' AND policyname = 'anon_select'
) THEN
CREATE POLICY anon_select ON fundamentals_cache FOR SELECT TO anon USING (true);
END IF;
END $$;
-- Seed the initial version row so the updater has something to compare against.
INSERT INTO app_versions (version, is_latest, release_notes)
SELECT '1.0.0', true, 'Initial release'
WHERE NOT EXISTS (SELECT 1 FROM app_versions WHERE version = '1.0.0');
-- -----------------------------------------------------------------------------
-- 2. ROW LEVEL SECURITY — no direct table access for anon
-- -----------------------------------------------------------------------------
ALTER TABLE licenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE analyst_cache ENABLE ROW LEVEL SECURITY;
-- No RLS policies on licenses/app_versions/auth_log = anon cannot access directly.
-- All sensitive access goes through SECURITY DEFINER functions below.
-- analyst_cache is public market data — allow the anon key to read it.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'analyst_cache' AND policyname = 'anon_select'
) THEN
CREATE POLICY anon_select ON analyst_cache FOR SELECT TO anon USING (true);
END IF;
END $$;
-- -----------------------------------------------------------------------------
-- 3. RPC: verify_license(p_key, p_hwid) → JSON
-- -----------------------------------------------------------------------------
-- Called by the app on every launch with the stored license key + machine HWID.
-- Handles: first-time HWID binding, expiry, disabled keys, HWID mismatch.
-- Returns: { valid, reason, tier, expiry_date }
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION verify_license(p_key TEXT, p_hwid TEXT)
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
lic RECORD;
BEGIN
-- Look up the key
SELECT * INTO lic FROM licenses WHERE license_key = p_key;
IF NOT FOUND THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'key_not_found');
RETURN json_build_object(
'valid', false,
'reason', 'License key not found.'
);
END IF;
-- Check disabled
IF NOT lic.active THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'key_disabled');
RETURN json_build_object(
'valid', false,
'reason', 'This license has been disabled. Please contact support.'
);
END IF;
-- Check expiry (NULL = lifetime, never expires)
IF lic.expiry_date IS NOT NULL AND lic.expiry_date < NOW() THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'expired');
RETURN json_build_object(
'valid', false,
'reason', 'Your subscription has expired. Please renew to continue.'
);
END IF;
-- HWID check
IF lic.hwid IS NULL THEN
-- First activation — bind this machine
UPDATE licenses
SET hwid = p_hwid, activated_at = NOW()
WHERE license_key = p_key;
ELSIF lic.hwid <> p_hwid THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'hwid_mismatch');
RETURN json_build_object(
'valid', false,
'reason', 'This license is registered to a different machine. Contact support to transfer.'
);
END IF;
-- All checks passed
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'valid', 'ok');
RETURN json_build_object(
'valid', true,
'reason', 'ok',
'tier', lic.tier,
'channel', lic.channel,
'expiry_date', lic.expiry_date
);
END;
$$;
-- -----------------------------------------------------------------------------
-- 4. RPC: get_latest_version() → JSON
-- -----------------------------------------------------------------------------
-- Called by the updater after a successful license check.
-- Returns: { version, gui_py_url, screener_py_url, release_notes }
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION get_latest_version(p_channel TEXT DEFAULT 'stable')
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
ver RECORD;
BEGIN
SELECT * INTO ver
FROM app_versions
WHERE is_latest = true
AND channel = p_channel
ORDER BY released_at DESC
LIMIT 1;
IF NOT FOUND THEN
RETURN json_build_object('version', '1.0.0');
END IF;
RETURN json_build_object(
'version', ver.version,
'gui_py_url', ver.gui_py_url,
'screener_py_url', ver.screener_py_url,
'release_notes', ver.release_notes
);
END;
$$;
-- -----------------------------------------------------------------------------
-- 5. GRANTS — allow anon key to call the two RPC functions only
-- -----------------------------------------------------------------------------
GRANT EXECUTE ON FUNCTION verify_license(TEXT, TEXT) TO anon;
GRANT EXECUTE ON FUNCTION get_latest_version(TEXT) TO anon;

View File

@ -19,7 +19,7 @@ import requests
# ---------------------------------------------------------------------------
# Current app version — kept in sync by push_update.py before each build.
# ---------------------------------------------------------------------------
APP_VERSION = "1.3.9"
APP_VERSION = "1.4.9"
def _exe_dir() -> str: