Compare commits
No commits in common. "beta" and "main" have entirely different histories.
|
|
@ -1 +1,16 @@
|
||||||
{}
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python --version && pip --version)",
|
||||||
|
"Bash(python3 --version 2>/dev/null || py --version 2>/dev/null || echo \"no python\")",
|
||||||
|
"Bash(node --version 2>/dev/null && npm --version 2>/dev/null || echo \"no node\")",
|
||||||
|
"Bash(where python:*)",
|
||||||
|
"Bash(where python3:*)",
|
||||||
|
"Bash(where py:*)",
|
||||||
|
"Bash(ls /c/Python*)",
|
||||||
|
"Read(//c/Users/Nolan/AppData/Local/Programs/**)",
|
||||||
|
"Bash(ls \"/c/Users/Nolan/AppData/Local/Programs/Python\" 2>/dev/null || echo \"not found\"\nls \"/c/Users/Nolan/AppData/Local/Microsoft/WindowsApps/\"python* 2>/dev/null || echo \"not found\")",
|
||||||
|
"Bash(cd \"C:/Users/Nolan/Desktop/Stock Tool\" && python -c \"import tkinter; import numpy; import pandas; import yfinance; import requests; print\\('All imports OK'\\)\" 2>&1)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 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."
|
||||||
Binary file not shown.
108
CLAUDE.md
108
CLAUDE.md
|
|
@ -1,108 +0,0 @@
|
||||||
# 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 0–100. 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.
|
|
||||||
|
|
@ -3,31 +3,25 @@ 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', 'pyarmor_runtime_000000']
|
||||||
'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',
|
tmp_ret = collect_all('yfinance')
|
||||||
'matplotlib', 'matplotlib.backends.backend_qtagg',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'matplotlib.backends.backend_tkagg',
|
tmp_ret = collect_all('matplotlib')
|
||||||
'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer',
|
tmp_ret = collect_all('requests')
|
||||||
'hwid', 'license_check', 'activation_dialog', 'updater',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'vaderSentiment', 'vaderSentiment.vaderSentiment',
|
tmp_ret = collect_all('certifi')
|
||||||
'pyarmor_runtime_000000',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'PySide6', 'PySide6.QtCore', 'PySide6.QtWidgets', 'PySide6.QtGui',
|
tmp_ret = collect_all('charset_normalizer')
|
||||||
'PySide6.QtCharts', 'PySide6.QtNetwork',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'shiboken6',
|
tmp_ret = collect_all('vaderSentiment')
|
||||||
]
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
|
tmp_ret = collect_all('tzdata')
|
||||||
for pkg in ('pandas', 'numpy', 'yfinance', 'matplotlib', 'requests',
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
'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(
|
a = Analysis(
|
||||||
|
|
@ -64,5 +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,
|
uac_admin=True, # embed UAC manifest: always run as Administrator
|
||||||
)
|
)
|
||||||
|
|
|
||||||
13
api/main.py
13
api/main.py
|
|
@ -20,9 +20,7 @@ app = FastAPI()
|
||||||
DB_HOST = "127.0.0.1"
|
DB_HOST = "127.0.0.1"
|
||||||
DB_NAME = "verimund"
|
DB_NAME = "verimund"
|
||||||
DB_USER = "verimund_user"
|
DB_USER = "verimund_user"
|
||||||
DB_PASS = os.getenv("DB_PASS")
|
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
|
||||||
if not DB_PASS:
|
|
||||||
raise RuntimeError("DB_PASS environment variable is not set")
|
|
||||||
JWT_SECRET = os.getenv("JWT_SECRET", "")
|
JWT_SECRET = os.getenv("JWT_SECRET", "")
|
||||||
HMAC_SECRET = os.getenv("HMAC_SECRET", "")
|
HMAC_SECRET = os.getenv("HMAC_SECRET", "")
|
||||||
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
|
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
|
||||||
|
|
@ -159,14 +157,11 @@ def check_update(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/versions")
|
@app.get("/admin/versions")
|
||||||
def get_versions(channel: str = "stable", _=Depends(verify_admin), db=Depends(get_db)):
|
def get_versions(_=Depends(verify_admin), db=Depends(get_db)):
|
||||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute(
|
cur.execute("SELECT * FROM app_versions ORDER BY released_at DESC LIMIT 1")
|
||||||
"SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1",
|
|
||||||
(channel,)
|
|
||||||
)
|
|
||||||
version = cur.fetchone()
|
version = cur.fetchone()
|
||||||
return {"version": version["version"] if version else "1.0.0", "channel": channel}
|
return {"version": version["version"] if version else "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/register-version")
|
@app.post("/admin/register-version")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
@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
|
||||||
|
|
@ -43,9 +43,7 @@ load_dotenv("/srv/api/.env")
|
||||||
_DB_HOST = "127.0.0.1"
|
_DB_HOST = "127.0.0.1"
|
||||||
_DB_NAME = "verimund"
|
_DB_NAME = "verimund"
|
||||||
_DB_USER = "verimund_user"
|
_DB_USER = "verimund_user"
|
||||||
_DB_PASS = os.environ.get("DB_PASS")
|
_DB_PASS = os.environ.get("DB_PASS", "Shlevison2k17")
|
||||||
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 is set by the cron job: "" for stable, "beta_" for beta channel.
|
||||||
_TABLE_PREFIX = os.environ.get("TABLE_PREFIX", "")
|
_TABLE_PREFIX = os.environ.get("TABLE_PREFIX", "")
|
||||||
|
|
@ -286,7 +284,6 @@ def _fetch_analyst(ticker: str) -> dict | None:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"name": info.get("longName") or None,
|
|
||||||
"pe_forward": info.get("forwardPE"),
|
"pe_forward": info.get("forwardPE"),
|
||||||
"eps_forward": info.get("forwardEps"),
|
"eps_forward": info.get("forwardEps"),
|
||||||
"analyst_norm": analyst_norm,
|
"analyst_norm": analyst_norm,
|
||||||
|
|
@ -296,8 +293,6 @@ def _fetch_analyst(ticker: str) -> dict | None:
|
||||||
"recommendation": info.get("recommendationKey") or "N/A",
|
"recommendation": info.get("recommendationKey") or "N/A",
|
||||||
"sector": info.get("sector") or None,
|
"sector": info.get("sector") or None,
|
||||||
"industry": info.get("industry") or None,
|
"industry": info.get("industry") or None,
|
||||||
"short_percent": info.get("shortPercentOfFloat"),
|
|
||||||
"short_ratio": info.get("shortRatio"),
|
|
||||||
"dividend_yield": info.get("dividendYield"),
|
"dividend_yield": info.get("dividendYield"),
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
@ -417,10 +412,9 @@ def _fetch_news_sentiment(ticker: str) -> dict | None:
|
||||||
scores.append(_VADER.polarity_scores(title)["compound"])
|
scores.append(_VADER.polarity_scores(title)["compound"])
|
||||||
sentiment = sum(scores) / len(scores) if scores else None
|
sentiment = sum(scores) / len(scores) if scores else None
|
||||||
return {
|
return {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"news_sentiment": sentiment,
|
"news_sentiment": sentiment,
|
||||||
"news_headline_count": len(scores),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
@ -469,17 +463,16 @@ def run_news_phase(tickers: list[str]) -> None:
|
||||||
# Explicit column allow-lists keep extra fields (e.g. dividend_yield from
|
# Explicit column allow-lists keep extra fields (e.g. dividend_yield from
|
||||||
# yfinance) from causing "column does not exist" errors in PostgreSQL.
|
# yfinance) from causing "column does not exist" errors in PostgreSQL.
|
||||||
_ANALYST_COLS = (
|
_ANALYST_COLS = (
|
||||||
"ticker", "name", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
"ticker", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
||||||
"analyst_count", "analyst_target", "recommendation", "news_sentiment",
|
"analyst_count", "analyst_target", "recommendation", "news_sentiment",
|
||||||
"news_headline_count", "sector", "industry", "short_percent", "short_ratio", "updated_at",
|
"sector", "industry", "updated_at",
|
||||||
)
|
)
|
||||||
_FUND_COLS = (
|
_FUND_COLS = (
|
||||||
"ticker", "name", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
|
"ticker", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
|
||||||
"revenue", "revenue_growth", "earnings_growth", "roe", "roa",
|
"revenue", "revenue_growth", "earnings_growth", "roe", "roa",
|
||||||
"debt_to_equity", "total_debt", "total_cash", "book_value", "current_ratio",
|
"debt_to_equity", "total_debt", "total_cash", "book_value", "current_ratio",
|
||||||
"profit_margin", "operating_margin", "fcf_yield", "dividend_yield",
|
"profit_margin", "operating_margin", "fcf_yield", "dividend_yield",
|
||||||
"market_cap", "shares_outstanding", "ev_revenue", "short_percent",
|
"market_cap", "shares_outstanding", "ev_revenue", "short_percent",
|
||||||
"piotroski_score", "accruals_ratio",
|
|
||||||
"filer_type", "updated_at",
|
"filer_type", "updated_at",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -541,11 +534,8 @@ def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
|
||||||
_FRAME_CACHE[key] = result
|
_FRAME_CACHE[key] = result
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
return result
|
return result
|
||||||
except requests.HTTPError as e:
|
except Exception:
|
||||||
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: HTTP {e.response.status_code} — not cached")
|
_FRAME_CACHE[key] = {}
|
||||||
return {}
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: {e} — not cached")
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -557,7 +547,7 @@ def _sum_frames(concepts: list[str], unit: str, periods: list[str]) -> dict[int,
|
||||||
ttm.setdefault(cik, {})
|
ttm.setdefault(cik, {})
|
||||||
if period not in ttm[cik]:
|
if period not in ttm[cik]:
|
||||||
ttm[cik][period] = val
|
ttm[cik][period] = val
|
||||||
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
|
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
|
||||||
|
|
||||||
|
|
||||||
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
||||||
|
|
@ -623,20 +613,6 @@ def _compute_fundamentals(
|
||||||
if rev is not None and rev_prev and rev_prev != 0 else None)
|
if rev is not None and rev_prev and rev_prev != 0 else None)
|
||||||
ni_g = ((ni - ni_prev) / abs(ni_prev)
|
ni_g = ((ni - ni_prev) / abs(ni_prev)
|
||||||
if ni is not None and ni_prev and ni_prev != 0 else None)
|
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 {
|
return {
|
||||||
"market_cap": mkt_cap,
|
"market_cap": mkt_cap,
|
||||||
"shares_outstanding": sh,
|
"shares_outstanding": sh,
|
||||||
|
|
@ -658,8 +634,6 @@ def _compute_fundamentals(
|
||||||
"profit_margin": pm,
|
"profit_margin": pm,
|
||||||
"operating_margin": om,
|
"operating_margin": om,
|
||||||
"fcf_yield": fcf_yield,
|
"fcf_yield": fcf_yield,
|
||||||
"piotroski_score": piotroski,
|
|
||||||
"accruals_ratio": accruals,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -747,13 +721,7 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]:
|
||||||
cash.get(cik), lt_debt.get(cik),
|
cash.get(cik), lt_debt.get(cik),
|
||||||
shares.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["ticker"] = ticker
|
||||||
metrics["name"] = _name
|
|
||||||
metrics["filer_type"] = "us-gaap-quarterly"
|
metrics["filer_type"] = "us-gaap-quarterly"
|
||||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
result[ticker] = metrics
|
result[ticker] = metrics
|
||||||
|
|
@ -784,13 +752,7 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]:
|
||||||
cash.get(cik), lt_debt.get(cik),
|
cash.get(cik), lt_debt.get(cik),
|
||||||
shares.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["ticker"] = ticker
|
||||||
metrics["name"] = _name
|
|
||||||
metrics["filer_type"] = "us-gaap-quarterly"
|
metrics["filer_type"] = "us-gaap-quarterly"
|
||||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
result[ticker] = metrics
|
result[ticker] = metrics
|
||||||
|
|
@ -927,13 +889,7 @@ def run_foreign_filers_phase() -> dict[str, dict]:
|
||||||
raw.get("cash"), raw.get("lt_debt"),
|
raw.get("cash"), raw.get("lt_debt"),
|
||||||
raw.get("shares"),
|
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["ticker"] = ticker
|
||||||
metrics["name"] = _name
|
|
||||||
metrics["filer_type"] = f"{taxonomy}-annual"
|
metrics["filer_type"] = f"{taxonomy}-annual"
|
||||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
result[ticker] = metrics
|
result[ticker] = metrics
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo Deploy to BETA (beta branch + /srv/stock-tool-beta on VPS)
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set /p COMMIT_MSG=Commit message:
|
||||||
|
if "%COMMIT_MSG%"=="" (
|
||||||
|
echo Error: Commit message cannot be empty.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [1/4] Staging all changes...
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
echo [2/4] Committing...
|
||||||
|
git commit -m "%COMMIT_MSG%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Nothing to commit.
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [3/4] Pushing to Gitea beta branch...
|
||||||
|
git push gitea HEAD:beta
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Push failed. Check Gitea connection.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [4/4] Deploying to VPS...
|
||||||
|
ssh root@87.99.133.95 "cd /srv/stock-tool-beta && git pull origin beta && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS beta deploy OK"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo VPS deploy failed. SSH in and check manually.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo Beta deploy complete.
|
||||||
|
echo ============================================================
|
||||||
|
pause
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo Deploy to STABLE (main branch + /srv/stock-tool on VPS)
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set /p COMMIT_MSG=Commit message:
|
||||||
|
if "%COMMIT_MSG%"=="" (
|
||||||
|
echo Error: Commit message cannot be empty.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [1/4] Staging all changes...
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
echo [2/4] Committing...
|
||||||
|
git commit -m "%COMMIT_MSG%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Nothing to commit.
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [3/4] Pushing to Gitea main branch...
|
||||||
|
git push gitea main
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Push failed. Check Gitea connection.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [4/4] Deploying to VPS...
|
||||||
|
ssh root@87.99.133.95 "cd /srv/stock-tool && git pull && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS deploy OK"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo VPS deploy failed. SSH in and check manually.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo Stable deploy complete.
|
||||||
|
echo ============================================================
|
||||||
|
pause
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# ─── Discord ────────────────────────────────────────────────────────────────
|
||||||
|
# Bot token from https://discord.com/developers/applications
|
||||||
|
DISCORD_TOKEN=
|
||||||
|
|
||||||
|
# Your server's ID (right-click server icon → Copy Server ID)
|
||||||
|
GUILD_ID=
|
||||||
|
|
||||||
|
# ─── Role IDs ───────────────────────────────────────────────────────────────
|
||||||
|
# Right-click each role in Server Settings → Roles → Copy Role ID
|
||||||
|
# User role is assigned to all subscribers; Lifetime role is also assigned for lifetime purchases
|
||||||
|
ROLE_USER_ID=
|
||||||
|
ROLE_LIFETIME_ID=
|
||||||
|
|
||||||
|
# ─── Ticket channel IDs ─────────────────────────────────────────────────────
|
||||||
|
# Category where ticket channels are created (right-click category → Copy ID)
|
||||||
|
TICKET_CATEGORY_ID=
|
||||||
|
|
||||||
|
# Channel where ticket open/close events are logged (right-click channel → Copy ID)
|
||||||
|
TICKET_LOG_CHANNEL_ID=
|
||||||
|
|
||||||
|
# ─── Stripe ─────────────────────────────────────────────────────────────────
|
||||||
|
# From https://dashboard.stripe.com/apikeys
|
||||||
|
STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
|
||||||
|
# From https://dashboard.stripe.com/webhooks (signing secret for this endpoint)
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
# Where Stripe redirects after payment (can be a thank-you page or Discord DM link)
|
||||||
|
STRIPE_SUCCESS_URL=https://discord.com/channels/@me
|
||||||
|
STRIPE_CANCEL_URL=https://discord.com/channels/@me
|
||||||
|
|
||||||
|
# ─── Supabase ───────────────────────────────────────────────────────────────
|
||||||
|
SUPABASE_URL=https://yeispcpmepjelfbhfkwr.supabase.co
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# ─── Webhook server ─────────────────────────────────────────────────────────
|
||||||
|
# Port the bot listens on for incoming Stripe webhook POSTs
|
||||||
|
# Forward this via ngrok (dev) or expose via your server's firewall (prod)
|
||||||
|
WEBHOOK_PORT=8080
|
||||||
|
|
@ -0,0 +1,533 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Discord Sales & Support Bot — Ultimate Investment Tool
|
||||||
|
=======================================================
|
||||||
|
Features:
|
||||||
|
/setup → (Admin only) Posts the persistent sales embed with Purchase &
|
||||||
|
Support buttons into the current channel. Run this once in your
|
||||||
|
read-only sales channel.
|
||||||
|
Purchase button → Stripe Checkout (weekly $10 / monthly $25 / lifetime $150)
|
||||||
|
License key auto-generated + DM'd on successful payment
|
||||||
|
Role assigned automatically in the server
|
||||||
|
Support button → Modal prompts for a subject, then opens a private ticket
|
||||||
|
channel with a Close button and audit log
|
||||||
|
|
||||||
|
Setup: copy .env.example → .env and fill in all values before running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
from discord.ext import commands
|
||||||
|
import requests
|
||||||
|
import stripe
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Configuration (all values from .env)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
|
||||||
|
GUILD_ID = int(os.getenv("GUILD_ID", "0"))
|
||||||
|
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
||||||
|
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
||||||
|
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
||||||
|
|
||||||
|
# Discord IDs — right-click channel/role → Copy ID (needs Developer Mode enabled)
|
||||||
|
TICKET_CATEGORY_ID = int(os.getenv("TICKET_CATEGORY_ID", "0"))
|
||||||
|
TICKET_LOG_CHANNEL_ID = int(os.getenv("TICKET_LOG_CHANNEL_ID", "0"))
|
||||||
|
ROLE_USER_ID = int(os.getenv("ROLE_USER_ID", "0"))
|
||||||
|
ROLE_LIFETIME_ID = int(os.getenv("ROLE_LIFETIME_ID", "0"))
|
||||||
|
|
||||||
|
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "8080"))
|
||||||
|
|
||||||
|
# Pricing tiers
|
||||||
|
PRICES = {
|
||||||
|
"weekly": {"amount": 1000, "label": "$10 / week", "days": 7},
|
||||||
|
"monthly": {"amount": 2500, "label": "$25 / month", "days": 30},
|
||||||
|
"lifetime": {"amount": 15000, "label": "$150 lifetime", "days": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
stripe.api_key = STRIPE_SECRET_KEY
|
||||||
|
|
||||||
|
_SUPABASE_HEADERS = {
|
||||||
|
"apikey": SUPABASE_SERVICE_KEY,
|
||||||
|
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Prefer": "return=representation",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# License helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _generate_key() -> str:
|
||||||
|
parts = [uuid.uuid4().hex[:8].upper() for _ in range(3)]
|
||||||
|
return "UIT-" + "-".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_license(tier: str, notes: str = "") -> str:
|
||||||
|
"""Create a new license in Supabase and return the key."""
|
||||||
|
days = PRICES[tier]["days"]
|
||||||
|
expiry = None
|
||||||
|
if days is not None:
|
||||||
|
expiry = (datetime.datetime.utcnow() + datetime.timedelta(days=days)).isoformat() + "Z"
|
||||||
|
|
||||||
|
key = _generate_key()
|
||||||
|
payload = {
|
||||||
|
"license_key": key,
|
||||||
|
"tier": tier,
|
||||||
|
"expiry_date": expiry,
|
||||||
|
"active": True,
|
||||||
|
"machines_allowed": 1,
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SUPABASE_URL}/rest/v1/licenses",
|
||||||
|
headers=_SUPABASE_HEADERS,
|
||||||
|
json=payload,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Stripe helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_checkout_session(tier: str, discord_user_id: str, discord_username: str) -> str:
|
||||||
|
"""Create a Stripe Checkout Session and return its URL."""
|
||||||
|
price_info = PRICES[tier]
|
||||||
|
session = stripe.checkout.Session.create(
|
||||||
|
payment_method_types=["card"],
|
||||||
|
line_items=[{
|
||||||
|
"price_data": {
|
||||||
|
"currency": "usd",
|
||||||
|
"unit_amount": price_info["amount"],
|
||||||
|
"product_data": {
|
||||||
|
"name": f"Ultimate Investment Tool — {price_info['label']}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"quantity": 1,
|
||||||
|
}],
|
||||||
|
mode="payment",
|
||||||
|
success_url=os.getenv("STRIPE_SUCCESS_URL", "https://discord.com/channels/@me"),
|
||||||
|
cancel_url=os.getenv("STRIPE_CANCEL_URL", "https://discord.com/channels/@me"),
|
||||||
|
metadata={
|
||||||
|
"discord_user_id": discord_user_id,
|
||||||
|
"discord_username": discord_username,
|
||||||
|
"tier": tier,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return session.url
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Bot setup
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.members = True
|
||||||
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
tree = bot.tree
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Ticket creation helper (shared by button and any future commands)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_ticket(interaction: discord.Interaction, subject: str):
|
||||||
|
guild = interaction.guild
|
||||||
|
category = guild.get_channel(TICKET_CATEGORY_ID)
|
||||||
|
|
||||||
|
overwrites = {
|
||||||
|
guild.default_role: discord.PermissionOverwrite(view_channel=False),
|
||||||
|
interaction.user: discord.PermissionOverwrite(
|
||||||
|
view_channel=True,
|
||||||
|
send_messages=True,
|
||||||
|
read_message_history=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
for role in guild.roles:
|
||||||
|
if role.permissions.administrator or role.permissions.manage_channels:
|
||||||
|
overwrites[role] = discord.PermissionOverwrite(
|
||||||
|
view_channel=True,
|
||||||
|
send_messages=True,
|
||||||
|
read_message_history=True,
|
||||||
|
manage_channels=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_name = "".join(c for c in interaction.user.name if c.isalnum() or c in "-_")[:20]
|
||||||
|
channel_name = f"ticket-{safe_name}"
|
||||||
|
|
||||||
|
if category:
|
||||||
|
existing = discord.utils.get(category.text_channels, name=channel_name)
|
||||||
|
if existing:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"You already have an open ticket: {existing.mention}\n"
|
||||||
|
"Please use that channel or close it before opening a new one.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = await guild.create_text_channel(
|
||||||
|
name=channel_name,
|
||||||
|
category=category,
|
||||||
|
overwrites=overwrites,
|
||||||
|
topic=f"Ticket by {interaction.user} ({interaction.user.id}) | {subject}",
|
||||||
|
)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"Support Ticket — {subject}",
|
||||||
|
description=(
|
||||||
|
f"Welcome {interaction.user.mention}!\n\n"
|
||||||
|
"Please describe your issue in as much detail as possible.\n"
|
||||||
|
"A staff member will be with you shortly.\n\n"
|
||||||
|
"Press **🔒 Close Ticket** when your issue is resolved."
|
||||||
|
),
|
||||||
|
color=discord.Color.blurple(),
|
||||||
|
timestamp=discord.utils.utcnow(),
|
||||||
|
)
|
||||||
|
embed.set_footer(text=str(interaction.user), icon_url=interaction.user.display_avatar.url)
|
||||||
|
await channel.send(embed=embed, view=TicketCloseView())
|
||||||
|
|
||||||
|
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
|
||||||
|
if log_channel:
|
||||||
|
log_embed = discord.Embed(
|
||||||
|
title="Ticket Opened",
|
||||||
|
description=(
|
||||||
|
f"**User:** {interaction.user.mention} (`{interaction.user.id}`)\n"
|
||||||
|
f"**Channel:** {channel.mention}\n"
|
||||||
|
f"**Subject:** {subject}"
|
||||||
|
),
|
||||||
|
color=discord.Color.green(),
|
||||||
|
timestamp=discord.utils.utcnow(),
|
||||||
|
)
|
||||||
|
await log_channel.send(embed=log_embed)
|
||||||
|
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"Your ticket has been created: {channel.mention}", ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Persistent Views (survive bot restarts)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TicketCloseView(discord.ui.View):
|
||||||
|
"""Close button that lives permanently in ticket channels."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=None)
|
||||||
|
|
||||||
|
@discord.ui.button(
|
||||||
|
label="🔒 Close Ticket",
|
||||||
|
style=discord.ButtonStyle.danger,
|
||||||
|
custom_id="ticket_close",
|
||||||
|
)
|
||||||
|
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
channel = interaction.channel
|
||||||
|
guild = interaction.guild
|
||||||
|
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"Ticket closed by {interaction.user.mention}. Channel will be deleted in 5 seconds.",
|
||||||
|
ephemeral=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
|
||||||
|
if log_channel:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Ticket Closed",
|
||||||
|
description=(
|
||||||
|
f"**Channel:** {channel.name}\n"
|
||||||
|
f"**Closed by:** {interaction.user.mention}\n"
|
||||||
|
f"**Topic:** {channel.topic or 'N/A'}"
|
||||||
|
),
|
||||||
|
color=discord.Color.red(),
|
||||||
|
timestamp=discord.utils.utcnow(),
|
||||||
|
)
|
||||||
|
await log_channel.send(embed=embed)
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
await channel.delete(reason=f"Ticket closed by {interaction.user}")
|
||||||
|
|
||||||
|
|
||||||
|
class TierSelectView(discord.ui.View):
|
||||||
|
"""Three buttons for selecting a pricing tier — shown ephemerally after clicking Purchase."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=120)
|
||||||
|
|
||||||
|
async def _handle(self, interaction: discord.Interaction, tier: str):
|
||||||
|
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||||
|
try:
|
||||||
|
url = create_checkout_session(
|
||||||
|
tier,
|
||||||
|
str(interaction.user.id),
|
||||||
|
str(interaction.user),
|
||||||
|
)
|
||||||
|
price_label = PRICES[tier]["label"]
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Complete Your Purchase",
|
||||||
|
description=(
|
||||||
|
f"You selected the **{price_label}** plan.\n\n"
|
||||||
|
f"[**→ Pay securely via Stripe**]({url})\n\n"
|
||||||
|
"Your license key will be **DM'd to you instantly** after payment.\n"
|
||||||
|
"The checkout link is valid for **24 hours**."
|
||||||
|
),
|
||||||
|
color=discord.Color.green(),
|
||||||
|
)
|
||||||
|
embed.set_footer(text="Powered by Stripe — we never store your card details.")
|
||||||
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
|
except Exception as exc:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"❌ Could not create a checkout session. Please try again later.\n`{exc}`",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@discord.ui.button(label="$10 / Week", style=discord.ButtonStyle.primary, custom_id="buy_weekly")
|
||||||
|
async def weekly(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
await self._handle(interaction, "weekly")
|
||||||
|
|
||||||
|
@discord.ui.button(label="$25 / Month", style=discord.ButtonStyle.primary, custom_id="buy_monthly")
|
||||||
|
async def monthly(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
await self._handle(interaction, "monthly")
|
||||||
|
|
||||||
|
@discord.ui.button(label="$150 Lifetime", style=discord.ButtonStyle.success, custom_id="buy_lifetime")
|
||||||
|
async def lifetime(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
await self._handle(interaction, "lifetime")
|
||||||
|
|
||||||
|
|
||||||
|
class TicketModal(discord.ui.Modal, title="Open a Support Ticket"):
|
||||||
|
"""Modal that collects a subject before creating the ticket channel."""
|
||||||
|
|
||||||
|
subject = discord.ui.TextInput(
|
||||||
|
label="Subject",
|
||||||
|
placeholder="Brief description of your issue",
|
||||||
|
max_length=100,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_submit(self, interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||||
|
await create_ticket(interaction, self.subject.value)
|
||||||
|
|
||||||
|
|
||||||
|
class SalesView(discord.ui.View):
|
||||||
|
"""
|
||||||
|
Persistent view with Purchase and Support buttons.
|
||||||
|
Posted once by /setup into the read-only sales channel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=None)
|
||||||
|
|
||||||
|
@discord.ui.button(
|
||||||
|
label="Purchase",
|
||||||
|
style=discord.ButtonStyle.success,
|
||||||
|
custom_id="sales_purchase",
|
||||||
|
emoji="🛒",
|
||||||
|
)
|
||||||
|
async def purchase(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Ultimate Investment Tool — Pricing",
|
||||||
|
description=(
|
||||||
|
"Select a tier below. Payment is handled securely via **Stripe**.\n"
|
||||||
|
"Your license key will be delivered to your **DMs** the moment payment clears."
|
||||||
|
),
|
||||||
|
color=discord.Color.gold(),
|
||||||
|
)
|
||||||
|
embed.add_field(name="Weekly", value="**$10** / 7 days", inline=True)
|
||||||
|
embed.add_field(name="Monthly", value="**$25** / 30 days", inline=True)
|
||||||
|
embed.add_field(name="Lifetime", value="**$150** one-time", inline=True)
|
||||||
|
embed.set_footer(text="Having trouble? Click the Support button to open a ticket.")
|
||||||
|
await interaction.response.send_message(embed=embed, view=TierSelectView(), ephemeral=True)
|
||||||
|
|
||||||
|
@discord.ui.button(
|
||||||
|
label="Support",
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
custom_id="sales_support",
|
||||||
|
emoji="🎫",
|
||||||
|
)
|
||||||
|
async def support(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
await interaction.response.send_modal(TicketModal())
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Admin slash command — post the sales embed
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tree.command(name="setup", description="Post the sales & support embed (admin only)")
|
||||||
|
@app_commands.checks.has_permissions(administrator=True)
|
||||||
|
async def setup(interaction: discord.Interaction):
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Ultimate Investment Tool",
|
||||||
|
description=(
|
||||||
|
"Click on the **Purchase** button to purchase a license. "
|
||||||
|
"Click on the **Support** button to create a support ticket. "
|
||||||
|
"Support tickets are checked by our staff, and can take time to get a response. "
|
||||||
|
"Please be patient when waiting for staff to respond."
|
||||||
|
),
|
||||||
|
color=discord.Color.gold(),
|
||||||
|
)
|
||||||
|
await interaction.channel.send(embed=embed, view=SalesView())
|
||||||
|
await interaction.response.send_message("Sales embed posted.", ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
@setup.error
|
||||||
|
async def setup_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
|
||||||
|
if isinstance(error, app_commands.MissingPermissions):
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"You need Administrator permission to use this command.", ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# On-ready
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bot.event
|
||||||
|
async def on_ready():
|
||||||
|
# Re-register all persistent views so buttons work after restarts
|
||||||
|
bot.add_view(SalesView())
|
||||||
|
bot.add_view(TicketCloseView())
|
||||||
|
|
||||||
|
# Sync slash commands to the guild instantly (no 1-hour global wait)
|
||||||
|
guild_obj = discord.Object(id=GUILD_ID)
|
||||||
|
tree.copy_global_to(guild=guild_obj)
|
||||||
|
await tree.sync(guild=guild_obj)
|
||||||
|
|
||||||
|
print(f"[Bot] Online as {bot.user} | Guild {GUILD_ID} | Slash commands synced")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Stripe webhook (aiohttp server on WEBHOOK_PORT)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_stripe_webhook(request: web.Request) -> web.Response:
|
||||||
|
payload = await request.read()
|
||||||
|
sig_header = request.headers.get("stripe-signature", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
|
||||||
|
except stripe.error.SignatureVerificationError:
|
||||||
|
print("[Webhook] Invalid Stripe signature — request rejected")
|
||||||
|
return web.Response(status=400, text="Invalid signature")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Webhook] Error parsing event: {exc}")
|
||||||
|
return web.Response(status=400, text=str(exc))
|
||||||
|
|
||||||
|
if event["type"] == "checkout.session.completed":
|
||||||
|
session = event["data"]["object"]
|
||||||
|
metadata = session.get("metadata", {})
|
||||||
|
|
||||||
|
discord_user_id = metadata.get("discord_user_id")
|
||||||
|
discord_username = metadata.get("discord_username", "Unknown")
|
||||||
|
tier = metadata.get("tier")
|
||||||
|
|
||||||
|
if not discord_user_id or tier not in PRICES:
|
||||||
|
print(f"[Webhook] Missing/invalid metadata: {metadata}")
|
||||||
|
return web.Response(status=200, text="OK")
|
||||||
|
|
||||||
|
# 1. Issue license in Supabase
|
||||||
|
try:
|
||||||
|
key = issue_license(
|
||||||
|
tier,
|
||||||
|
notes=f"Discord: {discord_username} ({discord_user_id})",
|
||||||
|
)
|
||||||
|
print(f"[License] Issued {key} ({tier}) for Discord user {discord_user_id}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[License] Failed to issue for {discord_user_id}: {exc}")
|
||||||
|
return web.Response(status=500, text="License issuance failed")
|
||||||
|
|
||||||
|
# 2. DM the license key to the buyer
|
||||||
|
price_info = PRICES[tier]
|
||||||
|
expiry_str = (
|
||||||
|
"Never (lifetime)"
|
||||||
|
if price_info["days"] is None
|
||||||
|
else f"{price_info['days']} days from today"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
user = await bot.fetch_user(int(discord_user_id))
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="🎉 Purchase Confirmed — Ultimate Investment Tool",
|
||||||
|
description=(
|
||||||
|
f"Thank you for your purchase!\n\n"
|
||||||
|
f"**Your License Key:**\n```\n{key}\n```\n"
|
||||||
|
f"**Plan:** {price_info['label']}\n"
|
||||||
|
f"**Expires:** {expiry_str}\n\n"
|
||||||
|
"Paste this key into the application when prompted.\n"
|
||||||
|
"⚠️ This key is locked to one machine — keep it private."
|
||||||
|
),
|
||||||
|
color=discord.Color.green(),
|
||||||
|
)
|
||||||
|
embed.set_footer(text="Need help? Click the Support button in the server.")
|
||||||
|
await user.send(embed=embed)
|
||||||
|
print(f"[Bot] License DM'd to {discord_username} ({discord_user_id})")
|
||||||
|
except discord.Forbidden:
|
||||||
|
print(f"[Bot] Cannot DM {discord_user_id} — DMs may be disabled.")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Bot] DM error for {discord_user_id}: {exc}")
|
||||||
|
|
||||||
|
# 3. Assign roles in the guild
|
||||||
|
try:
|
||||||
|
guild = bot.get_guild(GUILD_ID)
|
||||||
|
if guild:
|
||||||
|
member = await guild.fetch_member(int(discord_user_id))
|
||||||
|
roles_to_add = []
|
||||||
|
|
||||||
|
user_role = guild.get_role(ROLE_USER_ID)
|
||||||
|
if user_role:
|
||||||
|
roles_to_add.append(user_role)
|
||||||
|
|
||||||
|
if tier == "lifetime":
|
||||||
|
lifetime_role = guild.get_role(ROLE_LIFETIME_ID)
|
||||||
|
if lifetime_role:
|
||||||
|
roles_to_add.append(lifetime_role)
|
||||||
|
|
||||||
|
if roles_to_add:
|
||||||
|
await member.add_roles(*roles_to_add, reason=f"Purchased {tier} via Stripe")
|
||||||
|
names = ", ".join(r.name for r in roles_to_add)
|
||||||
|
print(f"[Bot] Assigned roles '{names}' to {discord_username}")
|
||||||
|
else:
|
||||||
|
print(f"[Bot] No valid roles found to assign")
|
||||||
|
except discord.NotFound:
|
||||||
|
print(f"[Bot] Member {discord_user_id} not found in guild (may have left)")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Bot] Role assignment error for {discord_user_id}: {exc}")
|
||||||
|
|
||||||
|
return web.Response(status=200, text="OK")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_webhook_server():
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/stripe/webhook", _handle_stripe_webhook)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT)
|
||||||
|
await site.start()
|
||||||
|
print(f"[Webhook] Stripe webhook server listening on :{WEBHOOK_PORT}/stripe/webhook")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Entry point
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with bot:
|
||||||
|
await _start_webhook_server()
|
||||||
|
await bot.start(DISCORD_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
discord.py>=2.3.0
|
||||||
|
stripe>=7.0.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
requests>=2.31.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -27,7 +27,6 @@ FORMULA (Composite Score):
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import html
|
import html
|
||||||
import os
|
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -792,8 +791,7 @@ 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
|
time.sleep(0.15) # stay comfortably under SEC's 10 req/sec limit
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
# Do not cache transient failures (network errors, timeouts, etc.)
|
_FRAME_CACHE[key] = {}
|
||||||
# Only 404s (already handled above) should produce a permanent empty cache entry.
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -810,7 +808,7 @@ def _sum_frames(concepts: list[str], unit: str,
|
||||||
ttm.setdefault(cik, {})
|
ttm.setdefault(cik, {})
|
||||||
if period not in ttm[cik]:
|
if period not in ttm[cik]:
|
||||||
ttm[cik][period] = val
|
ttm[cik][period] = val
|
||||||
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
|
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
|
||||||
|
|
||||||
|
|
||||||
def _best_frame(concepts: list[str], unit: str,
|
def _best_frame(concepts: list[str], unit: str,
|
||||||
|
|
@ -997,21 +995,11 @@ def _fetch_edgar_bulk(close_map: dict[str, pd.Series]) -> dict[str, dict]:
|
||||||
# Analyst + Fundamentals Cache (Verimund API)
|
# Analyst + Fundamentals Cache (Verimund API)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_API_URL = "https://api.verimundsolutions.com"
|
try:
|
||||||
|
from license_check import _API_URL as _API_URL, get_api_headers as _get_api_headers
|
||||||
def _get_api_headers() -> dict:
|
except ImportError:
|
||||||
"""Read JWT from the auth cache file directly — no dependency on obfuscated license_check."""
|
_API_URL = None
|
||||||
try:
|
_get_api_headers = lambda: {}
|
||||||
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]:
|
def _load_analyst_cache() -> dict[str, dict]:
|
||||||
|
|
@ -1310,8 +1298,6 @@ def _fetch_fundamentals(
|
||||||
price_now = float(close.iloc[-1])
|
price_now = float(close.iloc[-1])
|
||||||
|
|
||||||
def pct_return(days_back: int) -> float | None:
|
def pct_return(days_back: int) -> float | None:
|
||||||
if len(close) <= days_back:
|
|
||||||
return None
|
|
||||||
idx = max(0, len(close) - days_back)
|
idx = max(0, len(close) - days_back)
|
||||||
past = float(close.iloc[idx])
|
past = float(close.iloc[idx])
|
||||||
return (price_now - past) / past if past > 0 else None
|
return (price_now - past) / past if past > 0 else None
|
||||||
|
|
@ -1364,7 +1350,7 @@ def _fetch_fundamentals(
|
||||||
"ret_12m": pct_return(252),
|
"ret_12m": pct_return(252),
|
||||||
"roe": info.get("returnOnEquity"),
|
"roe": info.get("returnOnEquity"),
|
||||||
"roa": info.get("returnOnAssets"),
|
"roa": info.get("returnOnAssets"),
|
||||||
"debt_to_equity": (info["debtToEquity"] / 100.0) if info.get("debtToEquity") is not None else None,
|
"debt_to_equity": info.get("debtToEquity"),
|
||||||
"total_debt": info.get("totalDebt"),
|
"total_debt": info.get("totalDebt"),
|
||||||
"total_cash": info.get("totalCash"),
|
"total_cash": info.get("totalCash"),
|
||||||
"book_value": info.get("bookValue"),
|
"book_value": info.get("bookValue"),
|
||||||
|
|
@ -1380,7 +1366,6 @@ def _fetch_fundamentals(
|
||||||
"analyst_target": target_price,
|
"analyst_target": target_price,
|
||||||
"recommendation": info.get("recommendationKey", "N/A"),
|
"recommendation": info.get("recommendationKey", "N/A"),
|
||||||
"short_percent": info.get("shortPercentOfFloat"),
|
"short_percent": info.get("shortPercentOfFloat"),
|
||||||
"short_ratio": info.get("shortRatio"),
|
|
||||||
"beta": info.get("beta"),
|
"beta": info.get("beta"),
|
||||||
"volatility_30d": volatility,
|
"volatility_30d": volatility,
|
||||||
"market_cap": info.get("marketCap"),
|
"market_cap": info.get("marketCap"),
|
||||||
|
|
@ -1515,8 +1500,6 @@ def fetch_all(
|
||||||
price_now = float(close.iloc[-1])
|
price_now = float(close.iloc[-1])
|
||||||
|
|
||||||
def pct_return(days_back: int) -> float | None:
|
def pct_return(days_back: int) -> float | None:
|
||||||
if len(close) <= days_back:
|
|
||||||
return None
|
|
||||||
idx = max(0, len(close) - days_back)
|
idx = max(0, len(close) - days_back)
|
||||||
past = float(close.iloc[idx])
|
past = float(close.iloc[idx])
|
||||||
return (price_now - past) / past if past > 0 else None
|
return (price_now - past) / past if past > 0 else None
|
||||||
|
|
@ -1561,7 +1544,7 @@ def fetch_all(
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"name": analyst.get("name") or edgar.get("name") or ticker,
|
"name": edgar.get("name", ticker),
|
||||||
"sector": analyst.get("sector") or edgar.get("sector", "N/A"),
|
"sector": analyst.get("sector") or edgar.get("sector", "N/A"),
|
||||||
"industry": analyst.get("industry") or edgar.get("industry", "N/A"),
|
"industry": analyst.get("industry") or edgar.get("industry", "N/A"),
|
||||||
"price": price_now,
|
"price": price_now,
|
||||||
|
|
@ -1603,15 +1586,8 @@ def fetch_all(
|
||||||
"analyst_count": analyst.get("analyst_count"),
|
"analyst_count": analyst.get("analyst_count"),
|
||||||
"analyst_target": analyst_target,
|
"analyst_target": analyst_target,
|
||||||
"recommendation": analyst.get("recommendation", "N/A"),
|
"recommendation": analyst.get("recommendation", "N/A"),
|
||||||
# Bug 10 fix: use only shortPercentOfFloat (0.0–1.0 float fraction) from yfinance.
|
# Risk — FINRA cache first, then yfinance shortPercentOfFloat as display fallback
|
||||||
# FINRA short-volume ratio (sv/total_vol) is on a different scale (typically 0.3–0.6)
|
"short_percent": finra_short.get(ticker) or analyst.get("short_percent"),
|
||||||
# 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),
|
"beta": beta_map.get(ticker),
|
||||||
"volatility_30d": volatility,
|
"volatility_30d": volatility,
|
||||||
# Market data
|
# Market data
|
||||||
|
|
@ -1827,40 +1803,39 @@ _PROFILE_WEIGHTS: dict[str, dict[str, dict[str, float]]] = {
|
||||||
"mature": {
|
"mature": {
|
||||||
"value": {"pe": 0.35, "pb": 0.20, "ev_ebitda": 0.45},
|
"value": {"pe": 0.35, "pb": 0.20, "ev_ebitda": 0.45},
|
||||||
"growth": {"revenue_growth": 0.30, "earnings_growth": 0.45, "eps_growth": 0.25},
|
"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, "piotroski": 0.20},
|
"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, "accruals": 0.15},
|
"profitability": {"fcf_yield": 0.40, "operating_margin": 0.38, "profit_margin": 0.22},
|
||||||
},
|
},
|
||||||
"high_growth": {
|
"high_growth": {
|
||||||
"value": {"pe": 0.00, "pb": 0.20, "ev_ebitda": 0.35, "ev_revenue": 0.45},
|
"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},
|
"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, "piotroski": 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, "accruals": 0.15},
|
"profitability": {"fcf_yield": 0.15, "operating_margin": 0.50, "profit_margin": 0.35},
|
||||||
},
|
},
|
||||||
"financial": {
|
"financial": {
|
||||||
"value": {"pe": 0.35, "pb": 0.50, "ev_ebitda": 0.15},
|
"value": {"pe": 0.35, "pb": 0.50, "ev_ebitda": 0.15},
|
||||||
"growth": {"revenue_growth": 0.30, "earnings_growth": 0.50, "eps_growth": 0.20},
|
"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, "piotroski": 0.15},
|
"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, "accruals": 0.15},
|
"profitability": {"fcf_yield": 0.30, "operating_margin": 0.00, "profit_margin": 0.70},
|
||||||
},
|
},
|
||||||
"capital_intensive": {
|
"capital_intensive": {
|
||||||
"value": {"pe": 0.15, "pb": 0.20, "ev_ebitda": 0.65},
|
"value": {"pe": 0.15, "pb": 0.20, "ev_ebitda": 0.65},
|
||||||
"growth": {"revenue_growth": 0.35, "earnings_growth": 0.40, "eps_growth": 0.25},
|
"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, "piotroski": 0.15},
|
"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, "accruals": 0.15},
|
"profitability": {"fcf_yield": 0.35, "operating_margin": 0.45, "profit_margin": 0.20},
|
||||||
},
|
},
|
||||||
"early_stage": {
|
"early_stage": {
|
||||||
"value": {"pe": 0.00, "pb": 0.15, "ev_ebitda": 0.00, "ev_revenue": 0.85},
|
"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},
|
"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, "piotroski": 0.10},
|
"quality": {"roa": 0.20, "roe": 0.00, "debt_to_equity": 0.00, "current_ratio": 0.80},
|
||||||
# revenue_growth_proxy removed — revenue growth already carries 70% of the growth score
|
"profitability": {"fcf_yield": 0.00, "operating_margin": 0.40, "profit_margin": 0.00,
|
||||||
"profitability": {"fcf_yield": 0.00, "operating_margin": 1.00, "profit_margin": 0.00,
|
"revenue_growth_proxy": 0.60},
|
||||||
"revenue_growth_proxy": 0.00},
|
|
||||||
},
|
},
|
||||||
"turnaround": {
|
"turnaround": {
|
||||||
"value": {"pe": 0.00, "pb": 0.30, "ev_ebitda": 0.70},
|
"value": {"pe": 0.00, "pb": 0.30, "ev_ebitda": 0.70},
|
||||||
"growth": {"revenue_growth": 0.15, "earnings_growth": 0.80, "eps_growth": 0.05},
|
"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, "piotroski": 0.20},
|
"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, "accruals": 0.15},
|
"profitability": {"fcf_yield": 0.25, "operating_margin": 0.45, "profit_margin": 0.30},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1896,24 +1871,23 @@ def compute_value_score(df: pd.DataFrame,
|
||||||
"""
|
"""
|
||||||
ss = sector_stats or {}
|
ss = sector_stats or {}
|
||||||
sectors = df.get("sector", pd.Series(["N/A"] * len(df), index=df.index))
|
sectors = df.get("sector", pd.Series(["N/A"] * len(df), index=df.index))
|
||||||
# Z-score forward and trailing PE against their own sector distributions, then combine
|
pe = df["pe_forward"].combine_first(df["pe_trailing"]).where(lambda x: x > 0)
|
||||||
# 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)
|
# Pre-compute z-scores for each metric
|
||||||
trl_s = 100.0 - _z_series(df["pe_trailing"].where(df["pe_trailing"] > 0), "pe_trailing", sectors, ss)
|
pe_s = _z_series(pe, "pe_trailing", sectors, ss)
|
||||||
pe_s = fwd_s.combine_first(trl_s)
|
pb_s = _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
|
||||||
pb_s = 100.0 - _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
|
ev_s = _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
|
||||||
ev_s = 100.0 - _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
|
evr_s = _z_series(df.get("ev_revenue", pd.Series(
|
||||||
evr_s = 100.0 - _z_series(df.get("ev_revenue", pd.Series(
|
|
||||||
[None]*len(df), index=df.index)),
|
[None]*len(df), index=df.index)),
|
||||||
"ev_revenue", sectors, ss)
|
"ev_revenue", sectors, ss)
|
||||||
|
|
||||||
scores = []
|
scores = []
|
||||||
for i, row in df.iterrows():
|
for i, row in df.iterrows():
|
||||||
ms = {
|
ms = {
|
||||||
"pe": float(pe_s.loc[i]),
|
"pe": float(pe_s.iloc[i]),
|
||||||
"pb": float(pb_s.loc[i]),
|
"pb": float(pb_s.iloc[i]),
|
||||||
"ev_ebitda": float(ev_s.loc[i]),
|
"ev_ebitda": float(ev_s.iloc[i]),
|
||||||
"ev_revenue": float(evr_s.loc[i]),
|
"ev_revenue": float(evr_s.iloc[i]),
|
||||||
}
|
}
|
||||||
scores.append(_profile_score_row(row, "value", ms))
|
scores.append(_profile_score_row(row, "value", ms))
|
||||||
return pd.Series(scores, index=df.index)
|
return pd.Series(scores, index=df.index)
|
||||||
|
|
@ -1940,9 +1914,9 @@ def compute_growth_score(df: pd.DataFrame,
|
||||||
scores = []
|
scores = []
|
||||||
for i, row in df.iterrows():
|
for i, row in df.iterrows():
|
||||||
ms = {
|
ms = {
|
||||||
"revenue_growth": float(rev_s.loc[i]),
|
"revenue_growth": float(rev_s.iloc[i]),
|
||||||
"earnings_growth": float(ear_s.loc[i]),
|
"earnings_growth": float(ear_s.iloc[i]),
|
||||||
"eps_growth": float(eps_s.loc[i]),
|
"eps_growth": float(eps_s.iloc[i]),
|
||||||
}
|
}
|
||||||
scores.append(_profile_score_row(row, "growth", ms))
|
scores.append(_profile_score_row(row, "growth", ms))
|
||||||
return pd.Series(scores, index=df.index)
|
return pd.Series(scores, index=df.index)
|
||||||
|
|
@ -1993,27 +1967,19 @@ def compute_quality_score(df: pd.DataFrame,
|
||||||
|
|
||||||
roa_s = _z_series(df["roa"], "roa", sectors, ss)
|
roa_s = _z_series(df["roa"], "roa", sectors, ss)
|
||||||
roe_s = _z_series(df["roe"], "roe", 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)
|
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
|
# D/E z-score is inverted — lower D/E is better, so flip the z-score
|
||||||
de_s = 100.0 - _z_series(de_raw, "debt_to_equity", sectors, ss)
|
de_s = 100.0 - de_s
|
||||||
|
|
||||||
# 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 = []
|
scores = []
|
||||||
for i, row in df.iterrows():
|
for i, row in df.iterrows():
|
||||||
ms = {
|
ms = {
|
||||||
"roa": float(roa_s.loc[i]),
|
"roa": float(roa_s.iloc[i]),
|
||||||
"roe": float(roe_s.loc[i]),
|
"roe": float(roe_s.iloc[i]),
|
||||||
"debt_to_equity": float(de_s.loc[i]),
|
"debt_to_equity": float(de_s.iloc[i]),
|
||||||
"current_ratio": float(cr_s.loc[i]),
|
"current_ratio": float(cr_s.iloc[i]),
|
||||||
"piotroski": float(piotroski_s.loc[i]),
|
|
||||||
}
|
}
|
||||||
scores.append(_profile_score_row(row, "quality", ms))
|
scores.append(_profile_score_row(row, "quality", ms))
|
||||||
return pd.Series(scores, index=df.index)
|
return pd.Series(scores, index=df.index)
|
||||||
|
|
@ -2033,21 +1999,13 @@ def compute_profitability_score(df: pd.DataFrame,
|
||||||
pm_s = _z_series(df["profit_margin"], "profit_margin", sectors, ss)
|
pm_s = _z_series(df["profit_margin"], "profit_margin", sectors, ss)
|
||||||
rev_s = _z_series(df["revenue_growth"], "revenue_growth", 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 = []
|
scores = []
|
||||||
for i, row in df.iterrows():
|
for i, row in df.iterrows():
|
||||||
ms = {
|
ms = {
|
||||||
"fcf_yield": float(fcf_s.loc[i]),
|
"fcf_yield": float(fcf_s.iloc[i]),
|
||||||
"operating_margin": float(om_s.loc[i]),
|
"operating_margin": float(om_s.iloc[i]),
|
||||||
"profit_margin": float(pm_s.loc[i]),
|
"profit_margin": float(pm_s.iloc[i]),
|
||||||
"revenue_growth_proxy": float(rev_s.loc[i]), # used by early_stage
|
"revenue_growth_proxy": float(rev_s.iloc[i]), # used by early_stage
|
||||||
"accruals": float(accruals_s.loc[i]),
|
|
||||||
}
|
}
|
||||||
scores.append(_profile_score_row(row, "profitability", ms))
|
scores.append(_profile_score_row(row, "profitability", ms))
|
||||||
return pd.Series(scores, index=df.index)
|
return pd.Series(scores, index=df.index)
|
||||||
|
|
@ -2055,27 +2013,12 @@ def compute_profitability_score(df: pd.DataFrame,
|
||||||
|
|
||||||
def compute_sentiment_score(df: pd.DataFrame) -> pd.Series:
|
def compute_sentiment_score(df: pd.DataFrame) -> pd.Series:
|
||||||
"""
|
"""
|
||||||
News Sentiment Score — VADER compound score mapped to 0–100, confidence-weighted
|
News Sentiment Score — VADER compound score mapped to 0–100 (absolute scale).
|
||||||
by headline count. Fewer headlines blend toward neutral (50) to avoid overreacting
|
Sentiment is inherently comparable across all stocks; no z-scoring applied.
|
||||||
to a single article.
|
|
||||||
"""
|
"""
|
||||||
sent_pts = [(-1.0, 0), (-0.40, 20), (-0.15, 36), (0.0, 50),
|
sent_pts = [(-1.0, 0), (-0.40, 20), (-0.15, 36), (0.0, 50),
|
||||||
(0.15, 64), (0.40, 80), (1.0, 100)]
|
(0.15, 64), (0.40, 80), (1.0, 100)]
|
||||||
base = _score_series(df["news_sentiment"], sent_pts)
|
return _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,
|
def compute_analyst_score(df: pd.DataFrame,
|
||||||
|
|
@ -2149,9 +2092,8 @@ def score_stocks(df: pd.DataFrame, weights: dict | None = None,
|
||||||
"ret_1m", "ret_3m", "ret_6m", "ret_12m",
|
"ret_1m", "ret_3m", "ret_6m", "ret_12m",
|
||||||
"roe", "roa", "debt_to_equity", "current_ratio",
|
"roe", "roa", "debt_to_equity", "current_ratio",
|
||||||
"profit_margin", "operating_margin", "fcf_yield",
|
"profit_margin", "operating_margin", "fcf_yield",
|
||||||
"news_sentiment", "news_headline_count", "analyst_norm", "analyst_upside", "analyst_count",
|
"news_sentiment", "analyst_norm", "analyst_upside", "analyst_count",
|
||||||
"short_percent", "short_ratio", "volatility_30d", "beta", "price", "market_cap",
|
"short_percent", "volatility_30d", "beta", "price", "market_cap",
|
||||||
"piotroski_score", "accruals_ratio",
|
|
||||||
]
|
]
|
||||||
for col in _NUMERIC_COLS:
|
for col in _NUMERIC_COLS:
|
||||||
if col in df.columns:
|
if col in df.columns:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[2026-03-16 14:10:05] No rows returned for channel=beta
|
||||||
|
[2026-03-16 14:18:03] RPC response for channel=beta: version=1.1.4, exe_url=present
|
||||||
|
|
@ -0,0 +1,391 @@
|
||||||
|
"""
|
||||||
|
Health Monitor
|
||||||
|
==============
|
||||||
|
Runs every 5 minutes via cron. Checks processes, resources, SSL, cache logs,
|
||||||
|
and runs nightly database backups. Alerts via Discord webhook on any failure.
|
||||||
|
Auto-fixes safe/known issues (Tier 1). Alerts and waits for human decision on
|
||||||
|
everything else (Tier 2/3).
|
||||||
|
|
||||||
|
Deploy to: /srv/health_monitor.py
|
||||||
|
Cron entry (/etc/cron.d/health_monitor):
|
||||||
|
*/5 * * * * root python3 /srv/health_monitor.py >> /var/log/health_monitor.log 2>&1
|
||||||
|
|
||||||
|
Secrets loaded from /srv/api/.env:
|
||||||
|
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
|
||||||
|
DB_PASS=...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv("/srv/api/.env")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DISCORD_WEBHOOK = os.getenv("DISCORD_WEBHOOK", "")
|
||||||
|
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
|
||||||
|
DB_USER = "verimund_user"
|
||||||
|
DB_NAME = "verimund"
|
||||||
|
BACKUP_DIR = Path("/srv/backups/db")
|
||||||
|
BACKUP_KEEP_DAYS = 7
|
||||||
|
LOG_DIR = Path("/var/log")
|
||||||
|
STATE_FILE = Path("/srv/health_monitor_state.json")
|
||||||
|
|
||||||
|
DISK_WARN_PCT = 80
|
||||||
|
RAM_WARN_PCT = 90
|
||||||
|
CPU_WARN_PCT = 90
|
||||||
|
SSL_WARN_DAYS = 14
|
||||||
|
DOMAINS = [
|
||||||
|
"verimundsolutions.com",
|
||||||
|
"api.verimundsolutions.com",
|
||||||
|
"git.verimundsolutions.com",
|
||||||
|
"admin.verimundsolutions.com",
|
||||||
|
]
|
||||||
|
CACHE_LOGS = {
|
||||||
|
"stable": LOG_DIR / "cache_builder_stable.log",
|
||||||
|
"beta": LOG_DIR / "cache_builder_beta.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State (tracks last daily/weekly run so we don't repeat within the window)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_state() -> dict:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict) -> None:
|
||||||
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def _should_run(state: dict, key: str, interval_seconds: int) -> bool:
|
||||||
|
last = state.get(key, 0)
|
||||||
|
return (time.time() - last) >= interval_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_run(state: dict, key: str) -> None:
|
||||||
|
state[key] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discord alerts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def alert(message: str, tier: int = 2) -> None:
|
||||||
|
"""Send a Discord webhook message. Never raises — alerting must not crash the monitor."""
|
||||||
|
prefix = {1: "✅ [AUTO-FIXED]", 2: "⚠ [TIER 2]", 3: "🚨 [TIER 3 — ACTION REQUIRED]"}.get(tier, "ℹ")
|
||||||
|
full = f"{prefix} {message}"
|
||||||
|
print(full, flush=True)
|
||||||
|
if not DISCORD_WEBHOOK:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
payload = json.dumps({"content": full}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
DISCORD_WEBHOOK,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json", "User-Agent": "DiscordBot (https://verimundsolutions.com, 1.0)"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [alert] Discord send failed: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
print(f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Process checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pm2_list() -> list[str]:
|
||||||
|
"""Return list of PM2 process names that are 'online'."""
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["pm2", "jlist"], stderr=subprocess.DEVNULL, text=True
|
||||||
|
)
|
||||||
|
procs = json.loads(out)
|
||||||
|
return [p["name"] for p in procs if p.get("pm2_env", {}).get("status") == "online"]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check_processes() -> None:
|
||||||
|
log("Checking processes...")
|
||||||
|
|
||||||
|
# Nginx
|
||||||
|
r = subprocess.run(["systemctl", "is-active", "nginx"], capture_output=True, text=True)
|
||||||
|
if r.stdout.strip() != "active":
|
||||||
|
subprocess.run(["systemctl", "start", "nginx"], capture_output=True)
|
||||||
|
alert("Nginx was down — restarted.", tier=1)
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
r = subprocess.run(["systemctl", "is-active", "postgresql"], capture_output=True, text=True)
|
||||||
|
if r.stdout.strip() != "active":
|
||||||
|
subprocess.run(["systemctl", "start", "postgresql"], capture_output=True)
|
||||||
|
alert("PostgreSQL was down — restarted.", tier=1)
|
||||||
|
|
||||||
|
# PM2 itself
|
||||||
|
r = subprocess.run(["systemctl", "is-active", "pm2-root"], capture_output=True, text=True)
|
||||||
|
if r.stdout.strip() != "active":
|
||||||
|
subprocess.run(["systemctl", "start", "pm2-root"], capture_output=True)
|
||||||
|
alert("PM2 service was down — restarted.", tier=1)
|
||||||
|
|
||||||
|
# PM2-managed processes
|
||||||
|
online = _pm2_list()
|
||||||
|
for name in ("api",):
|
||||||
|
if name not in online:
|
||||||
|
subprocess.run(["pm2", "restart", name], capture_output=True)
|
||||||
|
alert(f"PM2 process '{name}' was down — restarted.", tier=1)
|
||||||
|
|
||||||
|
# API health check (HTTP)
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
urllib.request.urlopen("http://127.0.0.1:8000/docs", timeout=5)
|
||||||
|
except Exception:
|
||||||
|
subprocess.run(["pm2", "restart", "api"], capture_output=True)
|
||||||
|
alert("API server not responding on port 8000 — restarted via PM2.", tier=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resource checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_resources() -> None:
|
||||||
|
log("Checking resources...")
|
||||||
|
|
||||||
|
# Disk
|
||||||
|
usage = shutil.disk_usage("/")
|
||||||
|
pct = usage.used / usage.total * 100
|
||||||
|
if pct >= DISK_WARN_PCT:
|
||||||
|
_clean_old_logs()
|
||||||
|
# Re-check after cleanup
|
||||||
|
usage = shutil.disk_usage("/")
|
||||||
|
pct = usage.used / usage.total * 100
|
||||||
|
if pct >= DISK_WARN_PCT:
|
||||||
|
alert(
|
||||||
|
f"Disk usage is {pct:.1f}% after auto-cleanup. Manual intervention may be needed.",
|
||||||
|
tier=3 if pct >= 90 else 2,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
alert(f"Disk was {pct:.1f}% — old logs cleaned, now {pct:.1f}%.", tier=1)
|
||||||
|
|
||||||
|
# RAM
|
||||||
|
with open("/proc/meminfo") as f:
|
||||||
|
lines = {line.split(":")[0]: int(line.split()[1]) for line in f if ":" in line}
|
||||||
|
total = lines.get("MemTotal", 1)
|
||||||
|
avail = lines.get("MemAvailable", 1)
|
||||||
|
ram_pct = (total - avail) / total * 100
|
||||||
|
if ram_pct >= RAM_WARN_PCT:
|
||||||
|
alert(f"RAM usage is {ram_pct:.1f}% ({(total - avail) // 1024} MB used of {total // 1024} MB).", tier=2)
|
||||||
|
|
||||||
|
# CPU (1-minute load average vs core count)
|
||||||
|
load1 = os.getloadavg()[0]
|
||||||
|
cores = os.cpu_count() or 1
|
||||||
|
cpu_pct = load1 / cores * 100
|
||||||
|
if cpu_pct >= CPU_WARN_PCT:
|
||||||
|
alert(f"CPU load is {cpu_pct:.1f}% (load avg {load1:.2f} on {cores} cores).", tier=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_old_logs() -> None:
|
||||||
|
"""Delete log files older than 30 days to free disk space."""
|
||||||
|
now = time.time()
|
||||||
|
cutoff = now - 30 * 86400
|
||||||
|
cleaned = 0
|
||||||
|
for path in LOG_DIR.glob("*.log*"):
|
||||||
|
if path.stat().st_mtime < cutoff:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
cleaned += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if cleaned:
|
||||||
|
log(f" Cleaned {cleaned} old log file(s).")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSL certificate checks (daily)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_ssl(state: dict) -> None:
|
||||||
|
if not _should_run(state, "ssl_check", 86400):
|
||||||
|
return
|
||||||
|
log("Checking SSL certificates...")
|
||||||
|
_mark_run(state, "ssl_check")
|
||||||
|
|
||||||
|
for domain in DOMAINS:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["certbot", "certificates", "--domain", domain],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
if "VALID:" in line:
|
||||||
|
days = int(line.split("VALID:")[1].split("day")[0].strip())
|
||||||
|
if days <= SSL_WARN_DAYS:
|
||||||
|
log(f" SSL for {domain} expires in {days} days — renewing...")
|
||||||
|
result = subprocess.run(
|
||||||
|
["certbot", "renew", "--cert-name", domain, "--non-interactive"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
alert(f"SSL cert for {domain} renewed ({days} days remaining).", tier=1)
|
||||||
|
else:
|
||||||
|
alert(
|
||||||
|
f"SSL cert for {domain} expires in {days} days but renewal FAILED.\n"
|
||||||
|
f"```{result.stderr[-500:]}```",
|
||||||
|
tier=3,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" SSL check failed for {domain}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache log checks (daily)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_cache_logs(state: dict) -> None:
|
||||||
|
if not _should_run(state, "cache_log_check", 86400):
|
||||||
|
return
|
||||||
|
log("Checking cache builder logs...")
|
||||||
|
_mark_run(state, "cache_log_check")
|
||||||
|
|
||||||
|
for channel, log_path in CACHE_LOGS.items():
|
||||||
|
if not log_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = log_path.read_text(errors="replace")
|
||||||
|
lines = content.splitlines()
|
||||||
|
recent = "\n".join(lines[-50:])
|
||||||
|
|
||||||
|
if "Traceback" in recent or "Error" in recent:
|
||||||
|
alert(
|
||||||
|
f"Cache builder ({channel}) log contains errors:\n```{recent[-800:]}```",
|
||||||
|
tier=2,
|
||||||
|
)
|
||||||
|
elif "0 rows" in recent or "0 tickers" in recent.lower():
|
||||||
|
alert(
|
||||||
|
f"Cache builder ({channel}) may have produced 0 rows:\n```{recent[-800:]}```",
|
||||||
|
tier=2,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Could not read cache log for {channel}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database backup (daily)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_backup(state: dict) -> None:
|
||||||
|
if not _should_run(state, "db_backup", 86400):
|
||||||
|
return
|
||||||
|
log("Running database backup...")
|
||||||
|
_mark_run(state, "db_backup")
|
||||||
|
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
out_path = BACKUP_DIR / f"verimund_{date_str}.sql.gz"
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = DB_PASS
|
||||||
|
|
||||||
|
dump = subprocess.run(
|
||||||
|
["pg_dump", "-U", DB_USER, "-h", "127.0.0.1", DB_NAME],
|
||||||
|
capture_output=True, env=env,
|
||||||
|
)
|
||||||
|
if dump.returncode != 0:
|
||||||
|
alert(
|
||||||
|
f"pg_dump failed:\n```{dump.stderr.decode()[-500:]}```",
|
||||||
|
tier=3,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
with gzip.open(out_path, "wb") as f:
|
||||||
|
f.write(dump.stdout)
|
||||||
|
|
||||||
|
size_mb = out_path.stat().st_size / 1024 / 1024
|
||||||
|
log(f" Backup written: {out_path} ({size_mb:.1f} MB)")
|
||||||
|
|
||||||
|
# Sync to R2 if rclone is configured
|
||||||
|
r = subprocess.run(
|
||||||
|
["rclone", "sync", str(BACKUP_DIR), "r2:verimund-backups/db/"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
alert(f"rclone sync to R2 failed:\n```{r.stderr[-400:]}```", tier=2)
|
||||||
|
else:
|
||||||
|
log(" R2 sync complete.")
|
||||||
|
|
||||||
|
# Remove backups older than BACKUP_KEEP_DAYS
|
||||||
|
cutoff = time.time() - BACKUP_KEEP_DAYS * 86400
|
||||||
|
for f in BACKUP_DIR.glob("verimund_*.sql.gz"):
|
||||||
|
if f.stat().st_mtime < cutoff:
|
||||||
|
f.unlink()
|
||||||
|
log(f" Deleted old backup: {f.name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log compression (weekly)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compress_old_logs(state: dict) -> None:
|
||||||
|
if not _should_run(state, "log_compress", 7 * 86400):
|
||||||
|
return
|
||||||
|
log("Compressing old logs...")
|
||||||
|
_mark_run(state, "log_compress")
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
cutoff = time.time() - 7 * 86400
|
||||||
|
compressed = 0
|
||||||
|
for path in LOG_DIR.glob("*.log"):
|
||||||
|
if path.stat().st_mtime < cutoff:
|
||||||
|
gz_path = path.with_suffix(".log.gz")
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f_in, gzip.open(gz_path, "wb") as f_out:
|
||||||
|
f_out.write(f_in.read())
|
||||||
|
path.unlink()
|
||||||
|
compressed += 1
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Could not compress {path.name}: {e}")
|
||||||
|
if compressed:
|
||||||
|
log(f" Compressed {compressed} log file(s).")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
log("=== Health monitor start ===")
|
||||||
|
state = _load_state()
|
||||||
|
|
||||||
|
check_processes()
|
||||||
|
check_resources()
|
||||||
|
check_ssl(state)
|
||||||
|
check_cache_logs(state)
|
||||||
|
run_backup(state)
|
||||||
|
compress_old_logs(state)
|
||||||
|
|
||||||
|
_save_state(state)
|
||||||
|
log("=== Health monitor done ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -14,9 +14,6 @@ import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox
|
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.
|
# Fix SSL certificate path for PyInstaller frozen builds.
|
||||||
# Without this, requests raises "Could not find a suitable TLS CA certificate bundle".
|
# Without this, requests raises "Could not find a suitable TLS CA certificate bundle".
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
# 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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
[BUG]: out of license
|
||||||
|
|
||||||
|
## Command Line
|
||||||
|
C:\Users\noach\AppData\Local\Programs\Python\Python314\Scripts\pyarmor gen --platform windows.x86_64 --output C:\Users\noach\Desktop\Stock Tool\Stock Tool\_pyarmor_build C:\Users\noach\Desktop\Stock Tool\Stock Tool\launcher.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\license_check.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\updater.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\activation_dialog.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\hwid.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\screener_gui.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\stock_screener.py
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
Python 3.14.3
|
||||||
|
Pyarmor 9.2.4 (trial), 000000, non-profits
|
||||||
|
Platform windows.x86_64
|
||||||
|
Native windows.amd64
|
||||||
|
Home C:\Users\noach\.pyarmor
|
||||||
|
|
||||||
|
## Traceback
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 804, in main
|
||||||
|
main_entry(sys.argv[1:])
|
||||||
|
~~~~~~~~~~^^^^^^^^^^^^^^
|
||||||
|
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 789, in main_entry
|
||||||
|
return args.func(ctx, args)
|
||||||
|
~~~~~~~~~^^^^^^^^^^^
|
||||||
|
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 248, in cmd_gen
|
||||||
|
builder.process(options)
|
||||||
|
~~~~~~~~~~~~~~~^^^^^^^^^
|
||||||
|
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 190, in process
|
||||||
|
async_obfuscate_scripts(self, n) if n else self._obfuscate_scripts()
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~^^
|
||||||
|
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 145, in _obfuscate_scripts
|
||||||
|
code = Pytransform3.generate_obfuscated_script(self.ctx, r)
|
||||||
|
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\core\__init__.py", line 95, in generate_obfuscated_script
|
||||||
|
return m.generate_obfuscated_script(ctx, res)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
|
||||||
|
File "<maker>", line 728, in generate_obfuscated_script
|
||||||
|
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__init__.py", line 16, in process
|
||||||
|
return meth(self, res, *args, **kwargs)
|
||||||
|
File "<maker>", line 553, in process
|
||||||
|
File "<maker>", line 559, in coserialize
|
||||||
|
File "<maker>", line 609, in _build_ast_body
|
||||||
|
RuntimeError: out of license
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
BACKUP_DIR=/srv/backups/db
|
||||||
|
R2_BUCKET=r2:verimund-backups
|
||||||
|
|
||||||
|
# Database backup
|
||||||
|
pg_dump -h 127.0.0.1 -U verimund_user verimund | gzip > $BACKUP_DIR/verimund_$DATE.sql.gz
|
||||||
|
|
||||||
|
# Upload to R2
|
||||||
|
rclone sync $BACKUP_DIR $R2_BUCKET/db
|
||||||
|
|
||||||
|
# Keep only last 7 local backups
|
||||||
|
cd $BACKUP_DIR && ls -t | tail -n +8 | xargs -r rm
|
||||||
|
|
||||||
|
echo "Backup complete: $DATE"
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
-- =============================================================================
|
||||||
|
-- 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;
|
||||||
|
|
@ -19,7 +19,7 @@ import requests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Current app version — kept in sync by push_update.py before each build.
|
# Current app version — kept in sync by push_update.py before each build.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
APP_VERSION = "1.4.9"
|
APP_VERSION = "1.3.9"
|
||||||
|
|
||||||
|
|
||||||
def _exe_dir() -> str:
|
def _exe_dir() -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue