Compare commits
12 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
d756bc99ce | |
|
|
df032593bd | |
|
|
78f3a25022 | |
|
|
a604397c41 | |
|
|
a7ba0f950b | |
|
|
3ce0685889 | |
|
|
6bc851b03c | |
|
|
0de9beec8e | |
|
|
f75cceefd4 | |
|
|
e17d63ceeb | |
|
|
f2c8daefba | |
|
|
5c779efecf |
|
|
@ -1,16 +1 @@
|
||||||
{
|
{}
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(python --version && pip --version)",
|
|
||||||
"Bash(python3 --version 2>/dev/null || py --version 2>/dev/null || echo \"no python\")",
|
|
||||||
"Bash(node --version 2>/dev/null && npm --version 2>/dev/null || echo \"no node\")",
|
|
||||||
"Bash(where python:*)",
|
|
||||||
"Bash(where python3:*)",
|
|
||||||
"Bash(where py:*)",
|
|
||||||
"Bash(ls /c/Python*)",
|
|
||||||
"Read(//c/Users/Nolan/AppData/Local/Programs/**)",
|
|
||||||
"Bash(ls \"/c/Users/Nolan/AppData/Local/Programs/Python\" 2>/dev/null || echo \"not found\"\nls \"/c/Users/Nolan/AppData/Local/Microsoft/WindowsApps/\"python* 2>/dev/null || echo \"not found\")",
|
|
||||||
"Bash(cd \"C:/Users/Nolan/Desktop/Stock Tool\" && python -c \"import tkinter; import numpy; import pandas; import yfinance; import requests; print\\('All imports OK'\\)\" 2>&1)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# Cache refresh is now handled by VPS cron jobs on the Hetzner server.
|
|
||||||
# See /etc/cron.d/cache_builder on the VPS:
|
|
||||||
# Stable: 2am daily → /srv/stock-tool/cache_builder.py → analyst_cache, fundamentals_cache, sector_stats
|
|
||||||
# Beta: 3am daily → /srv/stock-tool-beta/cache_builder.py → beta_analyst_cache, beta_fundamentals_cache, beta_sector_stats
|
|
||||||
#
|
|
||||||
# This workflow is intentionally disabled (no triggers).
|
|
||||||
|
|
||||||
name: Refresh Cache (disabled — handled by VPS cron)
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch: # manual trigger only, for emergency use
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
refresh:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 120
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Not used
|
|
||||||
run: echo "Cache refresh runs on VPS. See /etc/cron.d/cache_builder."
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Stock Tool — Claude Context
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
A multi-factor stock screener that evaluates ~6,500 US-listed equities using a composite
|
||||||
|
score across 8 dimensions: Value, Growth, Momentum, Quality, Profitability, Sentiment,
|
||||||
|
Analyst, and Risk. Sold as a licensed desktop app (PySide6 GUI).
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `dist/stock_screener.py` | All scoring logic, data fetching, index/ticker collection. Pure Python + pandas. |
|
||||||
|
| `dist/screener_gui.py` | PySide6 GUI. Imports from `stock_screener`. Main class: `ScreenerApp(QMainWindow)`. |
|
||||||
|
| `cache_builder.py` | Nightly job that populates PostgreSQL cache from EDGAR + yfinance. Runs on VPS via cron. |
|
||||||
|
| `api/main.py` | FastAPI server on VPS. Handles license auth, cache serving, version management. |
|
||||||
|
| `updater.py` | Client-side auto-updater. Checks `/update` endpoint, downloads new build. |
|
||||||
|
| `license_check.py` | HMAC + JWT license validation. Baked into app via PyArmor. |
|
||||||
|
| `push_update.py` | Dev tool: SCP new build to VPS + register version via `/admin/register-version`. |
|
||||||
|
| `launcher.py` | Thin launcher that checks license before starting GUI. |
|
||||||
|
| `health_monitor.py` | VPS health monitoring. |
|
||||||
|
| `discord_bot/bot.py` | Discord bot integration (separate process). |
|
||||||
|
|
||||||
|
## Architecture (3-tier)
|
||||||
|
|
||||||
|
```
|
||||||
|
[cache_builder.py] → [PostgreSQL on VPS] → [API] → [Client app]
|
||||||
|
nightly 6 tables FastAPI screener_gui + stock_screener
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cache_builder.py` runs nightly (2am stable / 3am beta) via cron on the VPS
|
||||||
|
- Data flows: EDGAR XBRL Frames API + yfinance → PostgreSQL tables → served by FastAPI
|
||||||
|
- Client fetches cached data at startup via `/cache/analyst`, `/cache/fundamentals`, `/cache/sector`
|
||||||
|
- Scoring runs entirely client-side in `stock_screener.py` — no server-side scoring
|
||||||
|
|
||||||
|
## PostgreSQL Tables (database: `verimund`, user: `verimund_user`)
|
||||||
|
|
||||||
|
| Table | Contents |
|
||||||
|
|-------|----------|
|
||||||
|
| `analyst_cache` | Price history, returns, sentiment, analyst ratings, short interest (~6,975 rows) |
|
||||||
|
| `fundamentals_cache` | EDGAR-sourced financials: revenue, margins, D/E, ROA/ROE, Piotroski, accruals |
|
||||||
|
| `sector_stats` | Per-sector median/MAD for each metric. Used by `_z_score()` for normalization. |
|
||||||
|
| `app_versions` | Version registry per channel (stable/beta). Read by `/admin/versions`. |
|
||||||
|
| `licenses` | License keys + HWIDs + expiry. Validated at auth time. |
|
||||||
|
| `auth_log` | Auth attempt log. |
|
||||||
|
|
||||||
|
## Scoring Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
Composite = 0.18*Value + 0.18*Growth + 0.14*Momentum + 0.11*Quality
|
||||||
|
+ 0.08*Profitability + 0.13*Sentiment + 0.10*Analyst + 0.08*Risk
|
||||||
|
```
|
||||||
|
|
||||||
|
Each score is 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,25 +3,31 @@ from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
binaries = []
|
binaries = []
|
||||||
hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment', 'pyarmor_runtime_000000']
|
hiddenimports = [
|
||||||
tmp_ret = collect_all('pandas')
|
'pandas', 'numpy', 'yfinance', 'requests',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
'lxml', 'lxml.etree', 'html5lib', 'bs4',
|
||||||
tmp_ret = collect_all('numpy')
|
'appdirs', 'platformdirs',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
'tkinter', 'tkinter.ttk', 'tkinter.messagebox',
|
||||||
tmp_ret = collect_all('yfinance')
|
'screener_gui', 'stock_screener',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
'matplotlib', 'matplotlib.backends.backend_qtagg',
|
||||||
tmp_ret = collect_all('matplotlib')
|
'matplotlib.backends.backend_tkagg',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker',
|
||||||
tmp_ret = collect_all('requests')
|
'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
'hwid', 'license_check', 'activation_dialog', 'updater',
|
||||||
tmp_ret = collect_all('certifi')
|
'vaderSentiment', 'vaderSentiment.vaderSentiment',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
'pyarmor_runtime_000000',
|
||||||
tmp_ret = collect_all('charset_normalizer')
|
'PySide6', 'PySide6.QtCore', 'PySide6.QtWidgets', 'PySide6.QtGui',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
'PySide6.QtCharts', 'PySide6.QtNetwork',
|
||||||
tmp_ret = collect_all('vaderSentiment')
|
'shiboken6',
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
]
|
||||||
tmp_ret = collect_all('tzdata')
|
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
for pkg in ('pandas', 'numpy', 'yfinance', 'matplotlib', 'requests',
|
||||||
|
'certifi', 'charset_normalizer', 'vaderSentiment', 'tzdata',
|
||||||
|
'PySide6'):
|
||||||
|
tmp_ret = collect_all(pkg)
|
||||||
|
datas += tmp_ret[0]
|
||||||
|
binaries += tmp_ret[1]
|
||||||
|
hiddenimports += tmp_ret[2]
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
|
|
@ -58,5 +64,5 @@ exe = EXE(
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
uac_admin=True, # embed UAC manifest: always run as Administrator
|
uac_admin=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
13
api/main.py
13
api/main.py
|
|
@ -20,7 +20,9 @@ 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", "Shlevison2k17")
|
DB_PASS = os.getenv("DB_PASS")
|
||||||
|
if not DB_PASS:
|
||||||
|
raise RuntimeError("DB_PASS environment variable is not set")
|
||||||
JWT_SECRET = os.getenv("JWT_SECRET", "")
|
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", "")
|
||||||
|
|
@ -157,11 +159,14 @@ def check_update(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/versions")
|
@app.get("/admin/versions")
|
||||||
def get_versions(_=Depends(verify_admin), db=Depends(get_db)):
|
def get_versions(channel: str = "stable", _=Depends(verify_admin), db=Depends(get_db)):
|
||||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute("SELECT * FROM app_versions ORDER BY released_at DESC LIMIT 1")
|
cur.execute(
|
||||||
|
"SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1",
|
||||||
|
(channel,)
|
||||||
|
)
|
||||||
version = cur.fetchone()
|
version = cur.fetchone()
|
||||||
return {"version": version["version"] if version else "1.0.0"}
|
return {"version": version["version"] if version else "1.0.0", "channel": channel}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/register-version")
|
@app.post("/admin/register-version")
|
||||||
|
|
|
||||||
57
build.bat
57
build.bat
|
|
@ -1,57 +0,0 @@
|
||||||
@echo off
|
|
||||||
cd /d "%~dp0"
|
|
||||||
echo ======================================
|
|
||||||
echo Ultimate Investment Tool - Build EXE
|
|
||||||
echo ======================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo Installing dependencies...
|
|
||||||
python -m pip install pyinstaller matplotlib --quiet
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo ERROR: pip failed. Make sure Python is installed and on PATH.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo Copying source files from dist\ for bundling...
|
|
||||||
copy /Y dist\screener_gui.py screener_gui.py >nul
|
|
||||||
copy /Y dist\stock_screener.py stock_screener.py >nul
|
|
||||||
|
|
||||||
echo Building executable...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
python -m PyInstaller StockScreener.spec
|
|
||||||
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo.
|
|
||||||
echo BUILD FAILED. See errors above.
|
|
||||||
echo.
|
|
||||||
echo Cleaning up temporary files...
|
|
||||||
if exist screener_gui.py del screener_gui.py
|
|
||||||
if exist stock_screener.py del stock_screener.py
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo Cleaning up temporary source copies from project root...
|
|
||||||
if exist screener_gui.py del screener_gui.py
|
|
||||||
if exist stock_screener.py del stock_screener.py
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ======================================
|
|
||||||
echo SUCCESS!
|
|
||||||
echo.
|
|
||||||
echo Folder: dist\
|
|
||||||
echo - StockScreener.exe (distribute this)
|
|
||||||
echo - screener_gui.py (distribute alongside exe)
|
|
||||||
echo - stock_screener.py (distribute alongside exe)
|
|
||||||
echo.
|
|
||||||
echo DO NOT distribute:
|
|
||||||
echo - issue_license.py (admin only)
|
|
||||||
echo - supabase_schema.sql (admin only)
|
|
||||||
echo - hwid.py / license_check.py / updater.py / activation_dialog.py
|
|
||||||
echo (these are bundled inside the exe)
|
|
||||||
echo ======================================
|
|
||||||
pause
|
|
||||||
|
|
@ -43,7 +43,9 @@ 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", "Shlevison2k17")
|
_DB_PASS = os.environ.get("DB_PASS")
|
||||||
|
if not _DB_PASS:
|
||||||
|
raise RuntimeError("DB_PASS environment variable is not set")
|
||||||
|
|
||||||
# TABLE_PREFIX is set by the cron job: "" for stable, "beta_" for beta channel.
|
# TABLE_PREFIX 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", "")
|
||||||
|
|
@ -284,6 +286,7 @@ 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,
|
||||||
|
|
@ -293,6 +296,8 @@ 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(),
|
||||||
}
|
}
|
||||||
|
|
@ -414,6 +419,7 @@ def _fetch_news_sentiment(ticker: str) -> dict | 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:
|
||||||
|
|
@ -463,16 +469,17 @@ 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", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
"ticker", "name", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
||||||
"analyst_count", "analyst_target", "recommendation", "news_sentiment",
|
"analyst_count", "analyst_target", "recommendation", "news_sentiment",
|
||||||
"sector", "industry", "updated_at",
|
"news_headline_count", "sector", "industry", "short_percent", "short_ratio", "updated_at",
|
||||||
)
|
)
|
||||||
_FUND_COLS = (
|
_FUND_COLS = (
|
||||||
"ticker", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
|
"ticker", "name", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
|
||||||
"revenue", "revenue_growth", "earnings_growth", "roe", "roa",
|
"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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -534,8 +541,11 @@ 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 Exception:
|
except requests.HTTPError as e:
|
||||||
_FRAME_CACHE[key] = {}
|
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: HTTP {e.response.status_code} — not cached")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: {e} — not cached")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -547,7 +557,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()}
|
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
|
||||||
|
|
||||||
|
|
||||||
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
||||||
|
|
@ -613,6 +623,20 @@ def _compute_fundamentals(
|
||||||
if rev is not None and rev_prev and rev_prev != 0 else None)
|
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,
|
||||||
|
|
@ -634,6 +658,8 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -721,7 +747,13 @@ 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
|
||||||
|
|
@ -752,7 +784,13 @@ 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
|
||||||
|
|
@ -889,7 +927,13 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
echo ============================================================
|
|
||||||
echo Deploy to BETA (beta branch + /srv/stock-tool-beta on VPS)
|
|
||||||
echo ============================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
set /p COMMIT_MSG=Commit message:
|
|
||||||
if "%COMMIT_MSG%"=="" (
|
|
||||||
echo Error: Commit message cannot be empty.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [1/4] Staging all changes...
|
|
||||||
git add -A
|
|
||||||
|
|
||||||
echo [2/4] Committing...
|
|
||||||
git commit -m "%COMMIT_MSG%"
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Nothing to commit.
|
|
||||||
pause
|
|
||||||
exit /b 0
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [3/4] Pushing to Gitea beta branch...
|
|
||||||
git push gitea HEAD:beta
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Push failed. Check Gitea connection.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [4/4] Deploying to VPS...
|
|
||||||
ssh root@87.99.133.95 "cd /srv/stock-tool-beta && git pull origin beta && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS beta deploy OK"
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo VPS deploy failed. SSH in and check manually.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ============================================================
|
|
||||||
echo Beta deploy complete.
|
|
||||||
echo ============================================================
|
|
||||||
pause
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
echo ============================================================
|
|
||||||
echo Deploy to STABLE (main branch + /srv/stock-tool on VPS)
|
|
||||||
echo ============================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
set /p COMMIT_MSG=Commit message:
|
|
||||||
if "%COMMIT_MSG%"=="" (
|
|
||||||
echo Error: Commit message cannot be empty.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [1/4] Staging all changes...
|
|
||||||
git add -A
|
|
||||||
|
|
||||||
echo [2/4] Committing...
|
|
||||||
git commit -m "%COMMIT_MSG%"
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Nothing to commit.
|
|
||||||
pause
|
|
||||||
exit /b 0
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [3/4] Pushing to Gitea main branch...
|
|
||||||
git push gitea main
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Push failed. Check Gitea connection.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [4/4] Deploying to VPS...
|
|
||||||
ssh root@87.99.133.95 "cd /srv/stock-tool && git pull && cp api/main.py /srv/api/main.py && cp health_monitor.py /srv/health_monitor.py && pm2 restart api && echo VPS deploy OK"
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo VPS deploy failed. SSH in and check manually.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ============================================================
|
|
||||||
echo Stable deploy complete.
|
|
||||||
echo ============================================================
|
|
||||||
pause
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# ─── Discord ────────────────────────────────────────────────────────────────
|
|
||||||
# Bot token from https://discord.com/developers/applications
|
|
||||||
DISCORD_TOKEN=
|
|
||||||
|
|
||||||
# Your server's ID (right-click server icon → Copy Server ID)
|
|
||||||
GUILD_ID=
|
|
||||||
|
|
||||||
# ─── Role IDs ───────────────────────────────────────────────────────────────
|
|
||||||
# Right-click each role in Server Settings → Roles → Copy Role ID
|
|
||||||
# User role is assigned to all subscribers; Lifetime role is also assigned for lifetime purchases
|
|
||||||
ROLE_USER_ID=
|
|
||||||
ROLE_LIFETIME_ID=
|
|
||||||
|
|
||||||
# ─── Ticket channel IDs ─────────────────────────────────────────────────────
|
|
||||||
# Category where ticket channels are created (right-click category → Copy ID)
|
|
||||||
TICKET_CATEGORY_ID=
|
|
||||||
|
|
||||||
# Channel where ticket open/close events are logged (right-click channel → Copy ID)
|
|
||||||
TICKET_LOG_CHANNEL_ID=
|
|
||||||
|
|
||||||
# ─── Stripe ─────────────────────────────────────────────────────────────────
|
|
||||||
# From https://dashboard.stripe.com/apikeys
|
|
||||||
STRIPE_SECRET_KEY=sk_live_...
|
|
||||||
|
|
||||||
# From https://dashboard.stripe.com/webhooks (signing secret for this endpoint)
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
||||||
|
|
||||||
# Where Stripe redirects after payment (can be a thank-you page or Discord DM link)
|
|
||||||
STRIPE_SUCCESS_URL=https://discord.com/channels/@me
|
|
||||||
STRIPE_CANCEL_URL=https://discord.com/channels/@me
|
|
||||||
|
|
||||||
# ─── Supabase ───────────────────────────────────────────────────────────────
|
|
||||||
SUPABASE_URL=https://yeispcpmepjelfbhfkwr.supabase.co
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
|
||||||
|
|
||||||
# ─── Webhook server ─────────────────────────────────────────────────────────
|
|
||||||
# Port the bot listens on for incoming Stripe webhook POSTs
|
|
||||||
# Forward this via ngrok (dev) or expose via your server's firewall (prod)
|
|
||||||
WEBHOOK_PORT=8080
|
|
||||||
|
|
@ -1,533 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Discord Sales & Support Bot — Ultimate Investment Tool
|
|
||||||
=======================================================
|
|
||||||
Features:
|
|
||||||
/setup → (Admin only) Posts the persistent sales embed with Purchase &
|
|
||||||
Support buttons into the current channel. Run this once in your
|
|
||||||
read-only sales channel.
|
|
||||||
Purchase button → Stripe Checkout (weekly $10 / monthly $25 / lifetime $150)
|
|
||||||
License key auto-generated + DM'd on successful payment
|
|
||||||
Role assigned automatically in the server
|
|
||||||
Support button → Modal prompts for a subject, then opens a private ticket
|
|
||||||
channel with a Close button and audit log
|
|
||||||
|
|
||||||
Setup: copy .env.example → .env and fill in all values before running.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from aiohttp import web
|
|
||||||
import discord
|
|
||||||
from discord import app_commands
|
|
||||||
from discord.ext import commands
|
|
||||||
import requests
|
|
||||||
import stripe
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Configuration (all values from .env)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
|
|
||||||
GUILD_ID = int(os.getenv("GUILD_ID", "0"))
|
|
||||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
|
|
||||||
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
|
||||||
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
|
||||||
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
|
||||||
|
|
||||||
# Discord IDs — right-click channel/role → Copy ID (needs Developer Mode enabled)
|
|
||||||
TICKET_CATEGORY_ID = int(os.getenv("TICKET_CATEGORY_ID", "0"))
|
|
||||||
TICKET_LOG_CHANNEL_ID = int(os.getenv("TICKET_LOG_CHANNEL_ID", "0"))
|
|
||||||
ROLE_USER_ID = int(os.getenv("ROLE_USER_ID", "0"))
|
|
||||||
ROLE_LIFETIME_ID = int(os.getenv("ROLE_LIFETIME_ID", "0"))
|
|
||||||
|
|
||||||
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "8080"))
|
|
||||||
|
|
||||||
# Pricing tiers
|
|
||||||
PRICES = {
|
|
||||||
"weekly": {"amount": 1000, "label": "$10 / week", "days": 7},
|
|
||||||
"monthly": {"amount": 2500, "label": "$25 / month", "days": 30},
|
|
||||||
"lifetime": {"amount": 15000, "label": "$150 lifetime", "days": None},
|
|
||||||
}
|
|
||||||
|
|
||||||
stripe.api_key = STRIPE_SECRET_KEY
|
|
||||||
|
|
||||||
_SUPABASE_HEADERS = {
|
|
||||||
"apikey": SUPABASE_SERVICE_KEY,
|
|
||||||
"Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Prefer": "return=representation",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# License helpers
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _generate_key() -> str:
|
|
||||||
parts = [uuid.uuid4().hex[:8].upper() for _ in range(3)]
|
|
||||||
return "UIT-" + "-".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def issue_license(tier: str, notes: str = "") -> str:
|
|
||||||
"""Create a new license in Supabase and return the key."""
|
|
||||||
days = PRICES[tier]["days"]
|
|
||||||
expiry = None
|
|
||||||
if days is not None:
|
|
||||||
expiry = (datetime.datetime.utcnow() + datetime.timedelta(days=days)).isoformat() + "Z"
|
|
||||||
|
|
||||||
key = _generate_key()
|
|
||||||
payload = {
|
|
||||||
"license_key": key,
|
|
||||||
"tier": tier,
|
|
||||||
"expiry_date": expiry,
|
|
||||||
"active": True,
|
|
||||||
"machines_allowed": 1,
|
|
||||||
"notes": notes,
|
|
||||||
}
|
|
||||||
resp = requests.post(
|
|
||||||
f"{SUPABASE_URL}/rest/v1/licenses",
|
|
||||||
headers=_SUPABASE_HEADERS,
|
|
||||||
json=payload,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Stripe helpers
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def create_checkout_session(tier: str, discord_user_id: str, discord_username: str) -> str:
|
|
||||||
"""Create a Stripe Checkout Session and return its URL."""
|
|
||||||
price_info = PRICES[tier]
|
|
||||||
session = stripe.checkout.Session.create(
|
|
||||||
payment_method_types=["card"],
|
|
||||||
line_items=[{
|
|
||||||
"price_data": {
|
|
||||||
"currency": "usd",
|
|
||||||
"unit_amount": price_info["amount"],
|
|
||||||
"product_data": {
|
|
||||||
"name": f"Ultimate Investment Tool — {price_info['label']}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"quantity": 1,
|
|
||||||
}],
|
|
||||||
mode="payment",
|
|
||||||
success_url=os.getenv("STRIPE_SUCCESS_URL", "https://discord.com/channels/@me"),
|
|
||||||
cancel_url=os.getenv("STRIPE_CANCEL_URL", "https://discord.com/channels/@me"),
|
|
||||||
metadata={
|
|
||||||
"discord_user_id": discord_user_id,
|
|
||||||
"discord_username": discord_username,
|
|
||||||
"tier": tier,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return session.url
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Bot setup
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
|
||||||
intents.members = True
|
|
||||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
|
||||||
tree = bot.tree
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Ticket creation helper (shared by button and any future commands)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def create_ticket(interaction: discord.Interaction, subject: str):
|
|
||||||
guild = interaction.guild
|
|
||||||
category = guild.get_channel(TICKET_CATEGORY_ID)
|
|
||||||
|
|
||||||
overwrites = {
|
|
||||||
guild.default_role: discord.PermissionOverwrite(view_channel=False),
|
|
||||||
interaction.user: discord.PermissionOverwrite(
|
|
||||||
view_channel=True,
|
|
||||||
send_messages=True,
|
|
||||||
read_message_history=True,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
for role in guild.roles:
|
|
||||||
if role.permissions.administrator or role.permissions.manage_channels:
|
|
||||||
overwrites[role] = discord.PermissionOverwrite(
|
|
||||||
view_channel=True,
|
|
||||||
send_messages=True,
|
|
||||||
read_message_history=True,
|
|
||||||
manage_channels=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
safe_name = "".join(c for c in interaction.user.name if c.isalnum() or c in "-_")[:20]
|
|
||||||
channel_name = f"ticket-{safe_name}"
|
|
||||||
|
|
||||||
if category:
|
|
||||||
existing = discord.utils.get(category.text_channels, name=channel_name)
|
|
||||||
if existing:
|
|
||||||
await interaction.followup.send(
|
|
||||||
f"You already have an open ticket: {existing.mention}\n"
|
|
||||||
"Please use that channel or close it before opening a new one.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
channel = await guild.create_text_channel(
|
|
||||||
name=channel_name,
|
|
||||||
category=category,
|
|
||||||
overwrites=overwrites,
|
|
||||||
topic=f"Ticket by {interaction.user} ({interaction.user.id}) | {subject}",
|
|
||||||
)
|
|
||||||
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"Support Ticket — {subject}",
|
|
||||||
description=(
|
|
||||||
f"Welcome {interaction.user.mention}!\n\n"
|
|
||||||
"Please describe your issue in as much detail as possible.\n"
|
|
||||||
"A staff member will be with you shortly.\n\n"
|
|
||||||
"Press **🔒 Close Ticket** when your issue is resolved."
|
|
||||||
),
|
|
||||||
color=discord.Color.blurple(),
|
|
||||||
timestamp=discord.utils.utcnow(),
|
|
||||||
)
|
|
||||||
embed.set_footer(text=str(interaction.user), icon_url=interaction.user.display_avatar.url)
|
|
||||||
await channel.send(embed=embed, view=TicketCloseView())
|
|
||||||
|
|
||||||
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
|
|
||||||
if log_channel:
|
|
||||||
log_embed = discord.Embed(
|
|
||||||
title="Ticket Opened",
|
|
||||||
description=(
|
|
||||||
f"**User:** {interaction.user.mention} (`{interaction.user.id}`)\n"
|
|
||||||
f"**Channel:** {channel.mention}\n"
|
|
||||||
f"**Subject:** {subject}"
|
|
||||||
),
|
|
||||||
color=discord.Color.green(),
|
|
||||||
timestamp=discord.utils.utcnow(),
|
|
||||||
)
|
|
||||||
await log_channel.send(embed=log_embed)
|
|
||||||
|
|
||||||
await interaction.followup.send(
|
|
||||||
f"Your ticket has been created: {channel.mention}", ephemeral=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Persistent Views (survive bot restarts)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TicketCloseView(discord.ui.View):
|
|
||||||
"""Close button that lives permanently in ticket channels."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(timeout=None)
|
|
||||||
|
|
||||||
@discord.ui.button(
|
|
||||||
label="🔒 Close Ticket",
|
|
||||||
style=discord.ButtonStyle.danger,
|
|
||||||
custom_id="ticket_close",
|
|
||||||
)
|
|
||||||
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
||||||
channel = interaction.channel
|
|
||||||
guild = interaction.guild
|
|
||||||
|
|
||||||
await interaction.response.send_message(
|
|
||||||
f"Ticket closed by {interaction.user.mention}. Channel will be deleted in 5 seconds.",
|
|
||||||
ephemeral=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
log_channel = guild.get_channel(TICKET_LOG_CHANNEL_ID)
|
|
||||||
if log_channel:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Ticket Closed",
|
|
||||||
description=(
|
|
||||||
f"**Channel:** {channel.name}\n"
|
|
||||||
f"**Closed by:** {interaction.user.mention}\n"
|
|
||||||
f"**Topic:** {channel.topic or 'N/A'}"
|
|
||||||
),
|
|
||||||
color=discord.Color.red(),
|
|
||||||
timestamp=discord.utils.utcnow(),
|
|
||||||
)
|
|
||||||
await log_channel.send(embed=embed)
|
|
||||||
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
await channel.delete(reason=f"Ticket closed by {interaction.user}")
|
|
||||||
|
|
||||||
|
|
||||||
class TierSelectView(discord.ui.View):
|
|
||||||
"""Three buttons for selecting a pricing tier — shown ephemerally after clicking Purchase."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(timeout=120)
|
|
||||||
|
|
||||||
async def _handle(self, interaction: discord.Interaction, tier: str):
|
|
||||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
|
||||||
try:
|
|
||||||
url = create_checkout_session(
|
|
||||||
tier,
|
|
||||||
str(interaction.user.id),
|
|
||||||
str(interaction.user),
|
|
||||||
)
|
|
||||||
price_label = PRICES[tier]["label"]
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Complete Your Purchase",
|
|
||||||
description=(
|
|
||||||
f"You selected the **{price_label}** plan.\n\n"
|
|
||||||
f"[**→ Pay securely via Stripe**]({url})\n\n"
|
|
||||||
"Your license key will be **DM'd to you instantly** after payment.\n"
|
|
||||||
"The checkout link is valid for **24 hours**."
|
|
||||||
),
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
embed.set_footer(text="Powered by Stripe — we never store your card details.")
|
|
||||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
||||||
except Exception as exc:
|
|
||||||
await interaction.followup.send(
|
|
||||||
f"❌ Could not create a checkout session. Please try again later.\n`{exc}`",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@discord.ui.button(label="$10 / Week", style=discord.ButtonStyle.primary, custom_id="buy_weekly")
|
|
||||||
async def weekly(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
||||||
await self._handle(interaction, "weekly")
|
|
||||||
|
|
||||||
@discord.ui.button(label="$25 / Month", style=discord.ButtonStyle.primary, custom_id="buy_monthly")
|
|
||||||
async def monthly(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
||||||
await self._handle(interaction, "monthly")
|
|
||||||
|
|
||||||
@discord.ui.button(label="$150 Lifetime", style=discord.ButtonStyle.success, custom_id="buy_lifetime")
|
|
||||||
async def lifetime(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
||||||
await self._handle(interaction, "lifetime")
|
|
||||||
|
|
||||||
|
|
||||||
class TicketModal(discord.ui.Modal, title="Open a Support Ticket"):
|
|
||||||
"""Modal that collects a subject before creating the ticket channel."""
|
|
||||||
|
|
||||||
subject = discord.ui.TextInput(
|
|
||||||
label="Subject",
|
|
||||||
placeholder="Brief description of your issue",
|
|
||||||
max_length=100,
|
|
||||||
required=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_submit(self, interaction: discord.Interaction):
|
|
||||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
|
||||||
await create_ticket(interaction, self.subject.value)
|
|
||||||
|
|
||||||
|
|
||||||
class SalesView(discord.ui.View):
|
|
||||||
"""
|
|
||||||
Persistent view with Purchase and Support buttons.
|
|
||||||
Posted once by /setup into the read-only sales channel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(timeout=None)
|
|
||||||
|
|
||||||
@discord.ui.button(
|
|
||||||
label="Purchase",
|
|
||||||
style=discord.ButtonStyle.success,
|
|
||||||
custom_id="sales_purchase",
|
|
||||||
emoji="🛒",
|
|
||||||
)
|
|
||||||
async def purchase(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Ultimate Investment Tool — Pricing",
|
|
||||||
description=(
|
|
||||||
"Select a tier below. Payment is handled securely via **Stripe**.\n"
|
|
||||||
"Your license key will be delivered to your **DMs** the moment payment clears."
|
|
||||||
),
|
|
||||||
color=discord.Color.gold(),
|
|
||||||
)
|
|
||||||
embed.add_field(name="Weekly", value="**$10** / 7 days", inline=True)
|
|
||||||
embed.add_field(name="Monthly", value="**$25** / 30 days", inline=True)
|
|
||||||
embed.add_field(name="Lifetime", value="**$150** one-time", inline=True)
|
|
||||||
embed.set_footer(text="Having trouble? Click the Support button to open a ticket.")
|
|
||||||
await interaction.response.send_message(embed=embed, view=TierSelectView(), ephemeral=True)
|
|
||||||
|
|
||||||
@discord.ui.button(
|
|
||||||
label="Support",
|
|
||||||
style=discord.ButtonStyle.secondary,
|
|
||||||
custom_id="sales_support",
|
|
||||||
emoji="🎫",
|
|
||||||
)
|
|
||||||
async def support(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
||||||
await interaction.response.send_modal(TicketModal())
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Admin slash command — post the sales embed
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@tree.command(name="setup", description="Post the sales & support embed (admin only)")
|
|
||||||
@app_commands.checks.has_permissions(administrator=True)
|
|
||||||
async def setup(interaction: discord.Interaction):
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Ultimate Investment Tool",
|
|
||||||
description=(
|
|
||||||
"Click on the **Purchase** button to purchase a license. "
|
|
||||||
"Click on the **Support** button to create a support ticket. "
|
|
||||||
"Support tickets are checked by our staff, and can take time to get a response. "
|
|
||||||
"Please be patient when waiting for staff to respond."
|
|
||||||
),
|
|
||||||
color=discord.Color.gold(),
|
|
||||||
)
|
|
||||||
await interaction.channel.send(embed=embed, view=SalesView())
|
|
||||||
await interaction.response.send_message("Sales embed posted.", ephemeral=True)
|
|
||||||
|
|
||||||
|
|
||||||
@setup.error
|
|
||||||
async def setup_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
|
|
||||||
if isinstance(error, app_commands.MissingPermissions):
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"You need Administrator permission to use this command.", ephemeral=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# On-ready
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@bot.event
|
|
||||||
async def on_ready():
|
|
||||||
# Re-register all persistent views so buttons work after restarts
|
|
||||||
bot.add_view(SalesView())
|
|
||||||
bot.add_view(TicketCloseView())
|
|
||||||
|
|
||||||
# Sync slash commands to the guild instantly (no 1-hour global wait)
|
|
||||||
guild_obj = discord.Object(id=GUILD_ID)
|
|
||||||
tree.copy_global_to(guild=guild_obj)
|
|
||||||
await tree.sync(guild=guild_obj)
|
|
||||||
|
|
||||||
print(f"[Bot] Online as {bot.user} | Guild {GUILD_ID} | Slash commands synced")
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Stripe webhook (aiohttp server on WEBHOOK_PORT)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _handle_stripe_webhook(request: web.Request) -> web.Response:
|
|
||||||
payload = await request.read()
|
|
||||||
sig_header = request.headers.get("stripe-signature", "")
|
|
||||||
|
|
||||||
try:
|
|
||||||
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
|
|
||||||
except stripe.error.SignatureVerificationError:
|
|
||||||
print("[Webhook] Invalid Stripe signature — request rejected")
|
|
||||||
return web.Response(status=400, text="Invalid signature")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[Webhook] Error parsing event: {exc}")
|
|
||||||
return web.Response(status=400, text=str(exc))
|
|
||||||
|
|
||||||
if event["type"] == "checkout.session.completed":
|
|
||||||
session = event["data"]["object"]
|
|
||||||
metadata = session.get("metadata", {})
|
|
||||||
|
|
||||||
discord_user_id = metadata.get("discord_user_id")
|
|
||||||
discord_username = metadata.get("discord_username", "Unknown")
|
|
||||||
tier = metadata.get("tier")
|
|
||||||
|
|
||||||
if not discord_user_id or tier not in PRICES:
|
|
||||||
print(f"[Webhook] Missing/invalid metadata: {metadata}")
|
|
||||||
return web.Response(status=200, text="OK")
|
|
||||||
|
|
||||||
# 1. Issue license in Supabase
|
|
||||||
try:
|
|
||||||
key = issue_license(
|
|
||||||
tier,
|
|
||||||
notes=f"Discord: {discord_username} ({discord_user_id})",
|
|
||||||
)
|
|
||||||
print(f"[License] Issued {key} ({tier}) for Discord user {discord_user_id}")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[License] Failed to issue for {discord_user_id}: {exc}")
|
|
||||||
return web.Response(status=500, text="License issuance failed")
|
|
||||||
|
|
||||||
# 2. DM the license key to the buyer
|
|
||||||
price_info = PRICES[tier]
|
|
||||||
expiry_str = (
|
|
||||||
"Never (lifetime)"
|
|
||||||
if price_info["days"] is None
|
|
||||||
else f"{price_info['days']} days from today"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
user = await bot.fetch_user(int(discord_user_id))
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="🎉 Purchase Confirmed — Ultimate Investment Tool",
|
|
||||||
description=(
|
|
||||||
f"Thank you for your purchase!\n\n"
|
|
||||||
f"**Your License Key:**\n```\n{key}\n```\n"
|
|
||||||
f"**Plan:** {price_info['label']}\n"
|
|
||||||
f"**Expires:** {expiry_str}\n\n"
|
|
||||||
"Paste this key into the application when prompted.\n"
|
|
||||||
"⚠️ This key is locked to one machine — keep it private."
|
|
||||||
),
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
embed.set_footer(text="Need help? Click the Support button in the server.")
|
|
||||||
await user.send(embed=embed)
|
|
||||||
print(f"[Bot] License DM'd to {discord_username} ({discord_user_id})")
|
|
||||||
except discord.Forbidden:
|
|
||||||
print(f"[Bot] Cannot DM {discord_user_id} — DMs may be disabled.")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[Bot] DM error for {discord_user_id}: {exc}")
|
|
||||||
|
|
||||||
# 3. Assign roles in the guild
|
|
||||||
try:
|
|
||||||
guild = bot.get_guild(GUILD_ID)
|
|
||||||
if guild:
|
|
||||||
member = await guild.fetch_member(int(discord_user_id))
|
|
||||||
roles_to_add = []
|
|
||||||
|
|
||||||
user_role = guild.get_role(ROLE_USER_ID)
|
|
||||||
if user_role:
|
|
||||||
roles_to_add.append(user_role)
|
|
||||||
|
|
||||||
if tier == "lifetime":
|
|
||||||
lifetime_role = guild.get_role(ROLE_LIFETIME_ID)
|
|
||||||
if lifetime_role:
|
|
||||||
roles_to_add.append(lifetime_role)
|
|
||||||
|
|
||||||
if roles_to_add:
|
|
||||||
await member.add_roles(*roles_to_add, reason=f"Purchased {tier} via Stripe")
|
|
||||||
names = ", ".join(r.name for r in roles_to_add)
|
|
||||||
print(f"[Bot] Assigned roles '{names}' to {discord_username}")
|
|
||||||
else:
|
|
||||||
print(f"[Bot] No valid roles found to assign")
|
|
||||||
except discord.NotFound:
|
|
||||||
print(f"[Bot] Member {discord_user_id} not found in guild (may have left)")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[Bot] Role assignment error for {discord_user_id}: {exc}")
|
|
||||||
|
|
||||||
return web.Response(status=200, text="OK")
|
|
||||||
|
|
||||||
|
|
||||||
async def _start_webhook_server():
|
|
||||||
app = web.Application()
|
|
||||||
app.router.add_post("/stripe/webhook", _handle_stripe_webhook)
|
|
||||||
runner = web.AppRunner(app)
|
|
||||||
await runner.setup()
|
|
||||||
site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT)
|
|
||||||
await site.start()
|
|
||||||
print(f"[Webhook] Stripe webhook server listening on :{WEBHOOK_PORT}/stripe/webhook")
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Entry point
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
async with bot:
|
|
||||||
await _start_webhook_server()
|
|
||||||
await bot.start(DISCORD_TOKEN)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
discord.py>=2.3.0
|
|
||||||
stripe>=7.0.0
|
|
||||||
aiohttp>=3.9.0
|
|
||||||
requests>=2.31.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -27,6 +27,7 @@ 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
|
||||||
|
|
@ -791,7 +792,8 @@ def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
|
||||||
time.sleep(0.15) # stay comfortably under SEC's 10 req/sec limit
|
time.sleep(0.15) # stay comfortably under SEC's 10 req/sec limit
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
_FRAME_CACHE[key] = {}
|
# Do not cache transient failures (network errors, timeouts, etc.)
|
||||||
|
# Only 404s (already handled above) should produce a permanent empty cache entry.
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -808,7 +810,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()}
|
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
|
||||||
|
|
||||||
|
|
||||||
def _best_frame(concepts: list[str], unit: str,
|
def _best_frame(concepts: list[str], unit: str,
|
||||||
|
|
@ -995,11 +997,21 @@ def _fetch_edgar_bulk(close_map: dict[str, pd.Series]) -> dict[str, dict]:
|
||||||
# Analyst + Fundamentals Cache (Verimund API)
|
# Analyst + Fundamentals Cache (Verimund API)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_API_URL = "https://api.verimundsolutions.com"
|
||||||
|
|
||||||
|
def _get_api_headers() -> dict:
|
||||||
|
"""Read JWT from the auth cache file directly — no dependency on obfuscated license_check."""
|
||||||
try:
|
try:
|
||||||
from license_check import _API_URL as _API_URL, get_api_headers as _get_api_headers
|
import json as _json
|
||||||
except ImportError:
|
_cache = os.path.join(
|
||||||
_API_URL = None
|
os.environ.get("APPDATA", os.path.expanduser("~")),
|
||||||
_get_api_headers = lambda: {}
|
"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]:
|
||||||
|
|
@ -1298,6 +1310,8 @@ 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
|
||||||
|
|
@ -1350,7 +1364,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.get("debtToEquity"),
|
"debt_to_equity": (info["debtToEquity"] / 100.0) if info.get("debtToEquity") is not None else None,
|
||||||
"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"),
|
||||||
|
|
@ -1366,6 +1380,7 @@ 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"),
|
||||||
|
|
@ -1500,6 +1515,8 @@ 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
|
||||||
|
|
@ -1544,7 +1561,7 @@ def fetch_all(
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"name": edgar.get("name", ticker),
|
"name": analyst.get("name") or edgar.get("name") or ticker,
|
||||||
"sector": analyst.get("sector") or edgar.get("sector", "N/A"),
|
"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,
|
||||||
|
|
@ -1586,8 +1603,15 @@ 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"),
|
||||||
# Risk — FINRA cache first, then yfinance shortPercentOfFloat as display fallback
|
# Bug 10 fix: use only shortPercentOfFloat (0.0–1.0 float fraction) from yfinance.
|
||||||
"short_percent": finra_short.get(ticker) or analyst.get("short_percent"),
|
# FINRA short-volume ratio (sv/total_vol) is on a different scale (typically 0.3–0.6)
|
||||||
|
# and is incompatible with the short_pts breakpoints which expect % of float.
|
||||||
|
# Do NOT fall back to finra_short here; leave as None when yfinance data is absent.
|
||||||
|
"short_percent": analyst.get("short_percent"),
|
||||||
|
"short_ratio": analyst.get("short_ratio"),
|
||||||
|
"piotroski_score": edgar.get("piotroski_score"),
|
||||||
|
"accruals_ratio": edgar.get("accruals_ratio"),
|
||||||
|
"news_headline_count": analyst.get("news_headline_count"),
|
||||||
"beta": beta_map.get(ticker),
|
"beta": beta_map.get(ticker),
|
||||||
"volatility_30d": volatility,
|
"volatility_30d": volatility,
|
||||||
# Market data
|
# Market data
|
||||||
|
|
@ -1803,39 +1827,40 @@ _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},
|
"quality": {"roa": 0.40, "roe": 0.20, "debt_to_equity": 0.30, "current_ratio": 0.10, "piotroski": 0.20},
|
||||||
"profitability": {"fcf_yield": 0.40, "operating_margin": 0.38, "profit_margin": 0.22},
|
"profitability": {"fcf_yield": 0.40, "operating_margin": 0.38, "profit_margin": 0.22, "accruals": 0.15},
|
||||||
},
|
},
|
||||||
"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},
|
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.15, "current_ratio": 0.35, "piotroski": 0.20},
|
||||||
"profitability": {"fcf_yield": 0.15, "operating_margin": 0.50, "profit_margin": 0.35},
|
"profitability": {"fcf_yield": 0.15, "operating_margin": 0.50, "profit_margin": 0.35, "accruals": 0.15},
|
||||||
},
|
},
|
||||||
"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},
|
"quality": {"roa": 0.50, "roe": 0.30, "debt_to_equity": 0.00, "current_ratio": 0.20, "piotroski": 0.15},
|
||||||
"profitability": {"fcf_yield": 0.30, "operating_margin": 0.00, "profit_margin": 0.70},
|
"profitability": {"fcf_yield": 0.30, "operating_margin": 0.00, "profit_margin": 0.70, "accruals": 0.15},
|
||||||
},
|
},
|
||||||
"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},
|
"quality": {"roa": 0.30, "roe": 0.15, "debt_to_equity": 0.40, "current_ratio": 0.15, "piotroski": 0.15},
|
||||||
"profitability": {"fcf_yield": 0.35, "operating_margin": 0.45, "profit_margin": 0.20},
|
"profitability": {"fcf_yield": 0.35, "operating_margin": 0.45, "profit_margin": 0.20, "accruals": 0.15},
|
||||||
},
|
},
|
||||||
"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},
|
"quality": {"roa": 0.20, "roe": 0.00, "debt_to_equity": 0.00, "current_ratio": 0.80, "piotroski": 0.10},
|
||||||
"profitability": {"fcf_yield": 0.00, "operating_margin": 0.40, "profit_margin": 0.00,
|
# revenue_growth_proxy removed — revenue growth already carries 70% of the growth score
|
||||||
"revenue_growth_proxy": 0.60},
|
"profitability": {"fcf_yield": 0.00, "operating_margin": 1.00, "profit_margin": 0.00,
|
||||||
|
"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},
|
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.35, "current_ratio": 0.15, "piotroski": 0.20},
|
||||||
"profitability": {"fcf_yield": 0.25, "operating_margin": 0.45, "profit_margin": 0.30},
|
"profitability": {"fcf_yield": 0.25, "operating_margin": 0.45, "profit_margin": 0.30, "accruals": 0.15},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1871,23 +1896,24 @@ def compute_value_score(df: pd.DataFrame,
|
||||||
"""
|
"""
|
||||||
ss = sector_stats or {}
|
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))
|
||||||
pe = df["pe_forward"].combine_first(df["pe_trailing"]).where(lambda x: x > 0)
|
# Z-score forward and trailing PE against their own sector distributions, then combine
|
||||||
|
# Lower valuation ratios are better — invert z-scores so cheap stocks rank high
|
||||||
# Pre-compute z-scores for each metric
|
fwd_s = 100.0 - _z_series(df["pe_forward"].where(df["pe_forward"] > 0), "pe_forward", sectors, ss)
|
||||||
pe_s = _z_series(pe, "pe_trailing", sectors, ss)
|
trl_s = 100.0 - _z_series(df["pe_trailing"].where(df["pe_trailing"] > 0), "pe_trailing", sectors, ss)
|
||||||
pb_s = _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
|
pe_s = fwd_s.combine_first(trl_s)
|
||||||
ev_s = _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
|
pb_s = 100.0 - _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
|
||||||
evr_s = _z_series(df.get("ev_revenue", pd.Series(
|
ev_s = 100.0 - _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
|
||||||
|
evr_s = 100.0 - _z_series(df.get("ev_revenue", pd.Series(
|
||||||
[None]*len(df), index=df.index)),
|
[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.iloc[i]),
|
"pe": float(pe_s.loc[i]),
|
||||||
"pb": float(pb_s.iloc[i]),
|
"pb": float(pb_s.loc[i]),
|
||||||
"ev_ebitda": float(ev_s.iloc[i]),
|
"ev_ebitda": float(ev_s.loc[i]),
|
||||||
"ev_revenue": float(evr_s.iloc[i]),
|
"ev_revenue": float(evr_s.loc[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)
|
||||||
|
|
@ -1914,9 +1940,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.iloc[i]),
|
"revenue_growth": float(rev_s.loc[i]),
|
||||||
"earnings_growth": float(ear_s.iloc[i]),
|
"earnings_growth": float(ear_s.loc[i]),
|
||||||
"eps_growth": float(eps_s.iloc[i]),
|
"eps_growth": float(eps_s.loc[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)
|
||||||
|
|
@ -1967,19 +1993,27 @@ 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 - de_s
|
de_s = 100.0 - _z_series(de_raw, "debt_to_equity", sectors, ss)
|
||||||
|
|
||||||
|
# Piotroski partial F-Score (0-4) → 0-100 via absolute breakpoints
|
||||||
|
p_raw = pd.to_numeric(df.get("piotroski_score",
|
||||||
|
pd.Series([None] * len(df), index=df.index)), errors="coerce")
|
||||||
|
p_pts = [(0, 5), (1, 30), (2, 50), (3, 72), (4, 95)]
|
||||||
|
piotroski_s = _score_series(p_raw, p_pts)
|
||||||
|
|
||||||
scores = []
|
scores = []
|
||||||
for i, row in df.iterrows():
|
for i, row in df.iterrows():
|
||||||
ms = {
|
ms = {
|
||||||
"roa": float(roa_s.iloc[i]),
|
"roa": float(roa_s.loc[i]),
|
||||||
"roe": float(roe_s.iloc[i]),
|
"roe": float(roe_s.loc[i]),
|
||||||
"debt_to_equity": float(de_s.iloc[i]),
|
"debt_to_equity": float(de_s.loc[i]),
|
||||||
"current_ratio": float(cr_s.iloc[i]),
|
"current_ratio": float(cr_s.loc[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)
|
||||||
|
|
@ -1999,13 +2033,21 @@ 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.iloc[i]),
|
"fcf_yield": float(fcf_s.loc[i]),
|
||||||
"operating_margin": float(om_s.iloc[i]),
|
"operating_margin": float(om_s.loc[i]),
|
||||||
"profit_margin": float(pm_s.iloc[i]),
|
"profit_margin": float(pm_s.loc[i]),
|
||||||
"revenue_growth_proxy": float(rev_s.iloc[i]), # used by early_stage
|
"revenue_growth_proxy": float(rev_s.loc[i]), # used by early_stage
|
||||||
|
"accruals": float(accruals_s.loc[i]),
|
||||||
}
|
}
|
||||||
scores.append(_profile_score_row(row, "profitability", ms))
|
scores.append(_profile_score_row(row, "profitability", ms))
|
||||||
return pd.Series(scores, index=df.index)
|
return pd.Series(scores, index=df.index)
|
||||||
|
|
@ -2013,12 +2055,27 @@ def compute_profitability_score(df: pd.DataFrame,
|
||||||
|
|
||||||
def compute_sentiment_score(df: pd.DataFrame) -> pd.Series:
|
def compute_sentiment_score(df: pd.DataFrame) -> pd.Series:
|
||||||
"""
|
"""
|
||||||
News Sentiment Score — VADER compound score mapped to 0–100 (absolute scale).
|
News Sentiment Score — VADER compound score mapped to 0–100, confidence-weighted
|
||||||
Sentiment is inherently comparable across all stocks; no z-scoring applied.
|
by headline count. Fewer headlines blend toward neutral (50) to avoid overreacting
|
||||||
|
to a single article.
|
||||||
"""
|
"""
|
||||||
sent_pts = [(-1.0, 0), (-0.40, 20), (-0.15, 36), (0.0, 50),
|
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)]
|
||||||
return _score_series(df["news_sentiment"], sent_pts)
|
base = _score_series(df["news_sentiment"], sent_pts)
|
||||||
|
|
||||||
|
# Derive headline count from stored field or from news_headlines list
|
||||||
|
if "news_headline_count" in df.columns:
|
||||||
|
count = pd.to_numeric(df["news_headline_count"], errors="coerce").fillna(0)
|
||||||
|
elif "news_headlines" in df.columns:
|
||||||
|
count = df["news_headlines"].apply(
|
||||||
|
lambda h: len(h) if isinstance(h, list) else 0
|
||||||
|
).astype(float)
|
||||||
|
else:
|
||||||
|
return base
|
||||||
|
|
||||||
|
# 5+ headlines = full confidence; 0 headlines = 5% confidence (nearly neutral)
|
||||||
|
confidence = (count.clip(upper=5) / 5.0).clip(lower=0.05)
|
||||||
|
return base * confidence + 50.0 * (1.0 - confidence)
|
||||||
|
|
||||||
|
|
||||||
def compute_analyst_score(df: pd.DataFrame,
|
def compute_analyst_score(df: pd.DataFrame,
|
||||||
|
|
@ -2092,8 +2149,9 @@ def score_stocks(df: pd.DataFrame, weights: dict | None = None,
|
||||||
"ret_1m", "ret_3m", "ret_6m", "ret_12m",
|
"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", "analyst_norm", "analyst_upside", "analyst_count",
|
"news_sentiment", "news_headline_count", "analyst_norm", "analyst_upside", "analyst_count",
|
||||||
"short_percent", "volatility_30d", "beta", "price", "market_cap",
|
"short_percent", "short_ratio", "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:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[2026-03-16 14:10:05] No rows returned for channel=beta
|
|
||||||
[2026-03-16 14:18:03] RPC response for channel=beta: version=1.1.4, exe_url=present
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
"""
|
|
||||||
Health Monitor
|
|
||||||
==============
|
|
||||||
Runs every 5 minutes via cron. Checks processes, resources, SSL, cache logs,
|
|
||||||
and runs nightly database backups. Alerts via Discord webhook on any failure.
|
|
||||||
Auto-fixes safe/known issues (Tier 1). Alerts and waits for human decision on
|
|
||||||
everything else (Tier 2/3).
|
|
||||||
|
|
||||||
Deploy to: /srv/health_monitor.py
|
|
||||||
Cron entry (/etc/cron.d/health_monitor):
|
|
||||||
*/5 * * * * root python3 /srv/health_monitor.py >> /var/log/health_monitor.log 2>&1
|
|
||||||
|
|
||||||
Secrets loaded from /srv/api/.env:
|
|
||||||
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
|
|
||||||
DB_PASS=...
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv("/srv/api/.env")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Config
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DISCORD_WEBHOOK = os.getenv("DISCORD_WEBHOOK", "")
|
|
||||||
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
|
|
||||||
DB_USER = "verimund_user"
|
|
||||||
DB_NAME = "verimund"
|
|
||||||
BACKUP_DIR = Path("/srv/backups/db")
|
|
||||||
BACKUP_KEEP_DAYS = 7
|
|
||||||
LOG_DIR = Path("/var/log")
|
|
||||||
STATE_FILE = Path("/srv/health_monitor_state.json")
|
|
||||||
|
|
||||||
DISK_WARN_PCT = 80
|
|
||||||
RAM_WARN_PCT = 90
|
|
||||||
CPU_WARN_PCT = 90
|
|
||||||
SSL_WARN_DAYS = 14
|
|
||||||
DOMAINS = [
|
|
||||||
"verimundsolutions.com",
|
|
||||||
"api.verimundsolutions.com",
|
|
||||||
"git.verimundsolutions.com",
|
|
||||||
"admin.verimundsolutions.com",
|
|
||||||
]
|
|
||||||
CACHE_LOGS = {
|
|
||||||
"stable": LOG_DIR / "cache_builder_stable.log",
|
|
||||||
"beta": LOG_DIR / "cache_builder_beta.log",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# State (tracks last daily/weekly run so we don't repeat within the window)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _load_state() -> dict:
|
|
||||||
if STATE_FILE.exists():
|
|
||||||
try:
|
|
||||||
return json.loads(STATE_FILE.read_text())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_state(state: dict) -> None:
|
|
||||||
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def _should_run(state: dict, key: str, interval_seconds: int) -> bool:
|
|
||||||
last = state.get(key, 0)
|
|
||||||
return (time.time() - last) >= interval_seconds
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_run(state: dict, key: str) -> None:
|
|
||||||
state[key] = time.time()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Discord alerts
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def alert(message: str, tier: int = 2) -> None:
|
|
||||||
"""Send a Discord webhook message. Never raises — alerting must not crash the monitor."""
|
|
||||||
prefix = {1: "✅ [AUTO-FIXED]", 2: "⚠ [TIER 2]", 3: "🚨 [TIER 3 — ACTION REQUIRED]"}.get(tier, "ℹ")
|
|
||||||
full = f"{prefix} {message}"
|
|
||||||
print(full, flush=True)
|
|
||||||
if not DISCORD_WEBHOOK:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
import urllib.request
|
|
||||||
payload = json.dumps({"content": full}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
DISCORD_WEBHOOK,
|
|
||||||
data=payload,
|
|
||||||
headers={"Content-Type": "application/json", "User-Agent": "DiscordBot (https://verimundsolutions.com, 1.0)"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
urllib.request.urlopen(req, timeout=10)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [alert] Discord send failed: {e}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def log(message: str) -> None:
|
|
||||||
print(f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC] {message}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Process checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _pm2_list() -> list[str]:
|
|
||||||
"""Return list of PM2 process names that are 'online'."""
|
|
||||||
try:
|
|
||||||
out = subprocess.check_output(
|
|
||||||
["pm2", "jlist"], stderr=subprocess.DEVNULL, text=True
|
|
||||||
)
|
|
||||||
procs = json.loads(out)
|
|
||||||
return [p["name"] for p in procs if p.get("pm2_env", {}).get("status") == "online"]
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def check_processes() -> None:
|
|
||||||
log("Checking processes...")
|
|
||||||
|
|
||||||
# Nginx
|
|
||||||
r = subprocess.run(["systemctl", "is-active", "nginx"], capture_output=True, text=True)
|
|
||||||
if r.stdout.strip() != "active":
|
|
||||||
subprocess.run(["systemctl", "start", "nginx"], capture_output=True)
|
|
||||||
alert("Nginx was down — restarted.", tier=1)
|
|
||||||
|
|
||||||
# PostgreSQL
|
|
||||||
r = subprocess.run(["systemctl", "is-active", "postgresql"], capture_output=True, text=True)
|
|
||||||
if r.stdout.strip() != "active":
|
|
||||||
subprocess.run(["systemctl", "start", "postgresql"], capture_output=True)
|
|
||||||
alert("PostgreSQL was down — restarted.", tier=1)
|
|
||||||
|
|
||||||
# PM2 itself
|
|
||||||
r = subprocess.run(["systemctl", "is-active", "pm2-root"], capture_output=True, text=True)
|
|
||||||
if r.stdout.strip() != "active":
|
|
||||||
subprocess.run(["systemctl", "start", "pm2-root"], capture_output=True)
|
|
||||||
alert("PM2 service was down — restarted.", tier=1)
|
|
||||||
|
|
||||||
# PM2-managed processes
|
|
||||||
online = _pm2_list()
|
|
||||||
for name in ("api",):
|
|
||||||
if name not in online:
|
|
||||||
subprocess.run(["pm2", "restart", name], capture_output=True)
|
|
||||||
alert(f"PM2 process '{name}' was down — restarted.", tier=1)
|
|
||||||
|
|
||||||
# API health check (HTTP)
|
|
||||||
try:
|
|
||||||
import urllib.request
|
|
||||||
urllib.request.urlopen("http://127.0.0.1:8000/docs", timeout=5)
|
|
||||||
except Exception:
|
|
||||||
subprocess.run(["pm2", "restart", "api"], capture_output=True)
|
|
||||||
alert("API server not responding on port 8000 — restarted via PM2.", tier=1)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Resource checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def check_resources() -> None:
|
|
||||||
log("Checking resources...")
|
|
||||||
|
|
||||||
# Disk
|
|
||||||
usage = shutil.disk_usage("/")
|
|
||||||
pct = usage.used / usage.total * 100
|
|
||||||
if pct >= DISK_WARN_PCT:
|
|
||||||
_clean_old_logs()
|
|
||||||
# Re-check after cleanup
|
|
||||||
usage = shutil.disk_usage("/")
|
|
||||||
pct = usage.used / usage.total * 100
|
|
||||||
if pct >= DISK_WARN_PCT:
|
|
||||||
alert(
|
|
||||||
f"Disk usage is {pct:.1f}% after auto-cleanup. Manual intervention may be needed.",
|
|
||||||
tier=3 if pct >= 90 else 2,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
alert(f"Disk was {pct:.1f}% — old logs cleaned, now {pct:.1f}%.", tier=1)
|
|
||||||
|
|
||||||
# RAM
|
|
||||||
with open("/proc/meminfo") as f:
|
|
||||||
lines = {line.split(":")[0]: int(line.split()[1]) for line in f if ":" in line}
|
|
||||||
total = lines.get("MemTotal", 1)
|
|
||||||
avail = lines.get("MemAvailable", 1)
|
|
||||||
ram_pct = (total - avail) / total * 100
|
|
||||||
if ram_pct >= RAM_WARN_PCT:
|
|
||||||
alert(f"RAM usage is {ram_pct:.1f}% ({(total - avail) // 1024} MB used of {total // 1024} MB).", tier=2)
|
|
||||||
|
|
||||||
# CPU (1-minute load average vs core count)
|
|
||||||
load1 = os.getloadavg()[0]
|
|
||||||
cores = os.cpu_count() or 1
|
|
||||||
cpu_pct = load1 / cores * 100
|
|
||||||
if cpu_pct >= CPU_WARN_PCT:
|
|
||||||
alert(f"CPU load is {cpu_pct:.1f}% (load avg {load1:.2f} on {cores} cores).", tier=2)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_old_logs() -> None:
|
|
||||||
"""Delete log files older than 30 days to free disk space."""
|
|
||||||
now = time.time()
|
|
||||||
cutoff = now - 30 * 86400
|
|
||||||
cleaned = 0
|
|
||||||
for path in LOG_DIR.glob("*.log*"):
|
|
||||||
if path.stat().st_mtime < cutoff:
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
cleaned += 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if cleaned:
|
|
||||||
log(f" Cleaned {cleaned} old log file(s).")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SSL certificate checks (daily)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def check_ssl(state: dict) -> None:
|
|
||||||
if not _should_run(state, "ssl_check", 86400):
|
|
||||||
return
|
|
||||||
log("Checking SSL certificates...")
|
|
||||||
_mark_run(state, "ssl_check")
|
|
||||||
|
|
||||||
for domain in DOMAINS:
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["certbot", "certificates", "--domain", domain],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
for line in r.stdout.splitlines():
|
|
||||||
if "VALID:" in line:
|
|
||||||
days = int(line.split("VALID:")[1].split("day")[0].strip())
|
|
||||||
if days <= SSL_WARN_DAYS:
|
|
||||||
log(f" SSL for {domain} expires in {days} days — renewing...")
|
|
||||||
result = subprocess.run(
|
|
||||||
["certbot", "renew", "--cert-name", domain, "--non-interactive"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
alert(f"SSL cert for {domain} renewed ({days} days remaining).", tier=1)
|
|
||||||
else:
|
|
||||||
alert(
|
|
||||||
f"SSL cert for {domain} expires in {days} days but renewal FAILED.\n"
|
|
||||||
f"```{result.stderr[-500:]}```",
|
|
||||||
tier=3,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log(f" SSL check failed for {domain}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cache log checks (daily)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def check_cache_logs(state: dict) -> None:
|
|
||||||
if not _should_run(state, "cache_log_check", 86400):
|
|
||||||
return
|
|
||||||
log("Checking cache builder logs...")
|
|
||||||
_mark_run(state, "cache_log_check")
|
|
||||||
|
|
||||||
for channel, log_path in CACHE_LOGS.items():
|
|
||||||
if not log_path.exists():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
content = log_path.read_text(errors="replace")
|
|
||||||
lines = content.splitlines()
|
|
||||||
recent = "\n".join(lines[-50:])
|
|
||||||
|
|
||||||
if "Traceback" in recent or "Error" in recent:
|
|
||||||
alert(
|
|
||||||
f"Cache builder ({channel}) log contains errors:\n```{recent[-800:]}```",
|
|
||||||
tier=2,
|
|
||||||
)
|
|
||||||
elif "0 rows" in recent or "0 tickers" in recent.lower():
|
|
||||||
alert(
|
|
||||||
f"Cache builder ({channel}) may have produced 0 rows:\n```{recent[-800:]}```",
|
|
||||||
tier=2,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log(f" Could not read cache log for {channel}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Database backup (daily)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def run_backup(state: dict) -> None:
|
|
||||||
if not _should_run(state, "db_backup", 86400):
|
|
||||||
return
|
|
||||||
log("Running database backup...")
|
|
||||||
_mark_run(state, "db_backup")
|
|
||||||
|
|
||||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
out_path = BACKUP_DIR / f"verimund_{date_str}.sql.gz"
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["PGPASSWORD"] = DB_PASS
|
|
||||||
|
|
||||||
dump = subprocess.run(
|
|
||||||
["pg_dump", "-U", DB_USER, "-h", "127.0.0.1", DB_NAME],
|
|
||||||
capture_output=True, env=env,
|
|
||||||
)
|
|
||||||
if dump.returncode != 0:
|
|
||||||
alert(
|
|
||||||
f"pg_dump failed:\n```{dump.stderr.decode()[-500:]}```",
|
|
||||||
tier=3,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
import gzip
|
|
||||||
with gzip.open(out_path, "wb") as f:
|
|
||||||
f.write(dump.stdout)
|
|
||||||
|
|
||||||
size_mb = out_path.stat().st_size / 1024 / 1024
|
|
||||||
log(f" Backup written: {out_path} ({size_mb:.1f} MB)")
|
|
||||||
|
|
||||||
# Sync to R2 if rclone is configured
|
|
||||||
r = subprocess.run(
|
|
||||||
["rclone", "sync", str(BACKUP_DIR), "r2:verimund-backups/db/"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
alert(f"rclone sync to R2 failed:\n```{r.stderr[-400:]}```", tier=2)
|
|
||||||
else:
|
|
||||||
log(" R2 sync complete.")
|
|
||||||
|
|
||||||
# Remove backups older than BACKUP_KEEP_DAYS
|
|
||||||
cutoff = time.time() - BACKUP_KEEP_DAYS * 86400
|
|
||||||
for f in BACKUP_DIR.glob("verimund_*.sql.gz"):
|
|
||||||
if f.stat().st_mtime < cutoff:
|
|
||||||
f.unlink()
|
|
||||||
log(f" Deleted old backup: {f.name}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Log compression (weekly)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def compress_old_logs(state: dict) -> None:
|
|
||||||
if not _should_run(state, "log_compress", 7 * 86400):
|
|
||||||
return
|
|
||||||
log("Compressing old logs...")
|
|
||||||
_mark_run(state, "log_compress")
|
|
||||||
|
|
||||||
import gzip
|
|
||||||
cutoff = time.time() - 7 * 86400
|
|
||||||
compressed = 0
|
|
||||||
for path in LOG_DIR.glob("*.log"):
|
|
||||||
if path.stat().st_mtime < cutoff:
|
|
||||||
gz_path = path.with_suffix(".log.gz")
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as f_in, gzip.open(gz_path, "wb") as f_out:
|
|
||||||
f_out.write(f_in.read())
|
|
||||||
path.unlink()
|
|
||||||
compressed += 1
|
|
||||||
except Exception as e:
|
|
||||||
log(f" Could not compress {path.name}: {e}")
|
|
||||||
if compressed:
|
|
||||||
log(f" Compressed {compressed} log file(s).")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Main
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
log("=== Health monitor start ===")
|
|
||||||
state = _load_state()
|
|
||||||
|
|
||||||
check_processes()
|
|
||||||
check_resources()
|
|
||||||
check_ssl(state)
|
|
||||||
check_cache_logs(state)
|
|
||||||
run_backup(state)
|
|
||||||
compress_old_logs(state)
|
|
||||||
|
|
||||||
_save_state(state)
|
|
||||||
log("=== Health monitor done ===")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -14,6 +14,9 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
# verimundsolutions.com - website (Next.js, coming later)
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
listen [::]:443 ssl;
|
|
||||||
server_name verimundsolutions.com;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/verimundsolutions.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/verimundsolutions.com/privkey.pem;
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 200 'Coming soon';
|
|
||||||
add_header Content-Type text/plain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# api.verimundsolutions.com - API server
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
listen [::]:443 ssl;
|
|
||||||
server_name api.verimundsolutions.com;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/verimundsolutions.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/verimundsolutions.com/privkey.pem;
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# git.verimundsolutions.com - Gitea
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
listen [::]:443 ssl;
|
|
||||||
server_name git.verimundsolutions.com;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/git.verimundsolutions.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/git.verimundsolutions.com/privkey.pem;
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# admin.verimundsolutions.com - Admin panel (coming later)
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
listen [::]:443 ssl;
|
|
||||||
server_name admin.verimundsolutions.com;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/git.verimundsolutions.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/git.verimundsolutions.com/privkey.pem;
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 200 'Admin coming soon';
|
|
||||||
add_header Content-Type text/plain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Redirect all HTTP to HTTPS
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name verimundsolutions.com api.verimundsolutions.com git.verimundsolutions.com admin.verimundsolutions.com;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
[BUG]: out of license
|
|
||||||
|
|
||||||
## Command Line
|
|
||||||
C:\Users\noach\AppData\Local\Programs\Python\Python314\Scripts\pyarmor gen --platform windows.x86_64 --output C:\Users\noach\Desktop\Stock Tool\Stock Tool\_pyarmor_build C:\Users\noach\Desktop\Stock Tool\Stock Tool\launcher.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\license_check.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\updater.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\activation_dialog.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\hwid.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\screener_gui.py C:\Users\noach\Desktop\Stock Tool\Stock Tool\stock_screener.py
|
|
||||||
|
|
||||||
## Environments
|
|
||||||
Python 3.14.3
|
|
||||||
Pyarmor 9.2.4 (trial), 000000, non-profits
|
|
||||||
Platform windows.x86_64
|
|
||||||
Native windows.amd64
|
|
||||||
Home C:\Users\noach\.pyarmor
|
|
||||||
|
|
||||||
## Traceback
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 804, in main
|
|
||||||
main_entry(sys.argv[1:])
|
|
||||||
~~~~~~~~~~^^^^^^^^^^^^^^
|
|
||||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 789, in main_entry
|
|
||||||
return args.func(ctx, args)
|
|
||||||
~~~~~~~~~^^^^^^^^^^^
|
|
||||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__main__.py", line 248, in cmd_gen
|
|
||||||
builder.process(options)
|
|
||||||
~~~~~~~~~~~~~~~^^^^^^^^^
|
|
||||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 190, in process
|
|
||||||
async_obfuscate_scripts(self, n) if n else self._obfuscate_scripts()
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~^^
|
|
||||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\generate.py", line 145, in _obfuscate_scripts
|
|
||||||
code = Pytransform3.generate_obfuscated_script(self.ctx, r)
|
|
||||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\core\__init__.py", line 95, in generate_obfuscated_script
|
|
||||||
return m.generate_obfuscated_script(ctx, res)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
|
|
||||||
File "<maker>", line 728, in generate_obfuscated_script
|
|
||||||
File "C:\Users\noach\AppData\Local\Programs\Python\Python314\Lib\site-packages\pyarmor\cli\__init__.py", line 16, in process
|
|
||||||
return meth(self, res, *args, **kwargs)
|
|
||||||
File "<maker>", line 553, in process
|
|
||||||
File "<maker>", line 559, in coserialize
|
|
||||||
File "<maker>", line 609, in _build_ast_body
|
|
||||||
RuntimeError: out of license
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
DATE=$(date +%Y-%m-%d)
|
|
||||||
BACKUP_DIR=/srv/backups/db
|
|
||||||
R2_BUCKET=r2:verimund-backups
|
|
||||||
|
|
||||||
# Database backup
|
|
||||||
pg_dump -h 127.0.0.1 -U verimund_user verimund | gzip > $BACKUP_DIR/verimund_$DATE.sql.gz
|
|
||||||
|
|
||||||
# Upload to R2
|
|
||||||
rclone sync $BACKUP_DIR $R2_BUCKET/db
|
|
||||||
|
|
||||||
# Keep only last 7 local backups
|
|
||||||
cd $BACKUP_DIR && ls -t | tail -n +8 | xargs -r rm
|
|
||||||
|
|
||||||
echo "Backup complete: $DATE"
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
-- =============================================================================
|
|
||||||
-- Ultimate Investment Tool — Supabase Schema
|
|
||||||
-- Paste this entire file into Supabase SQL Editor and click Run.
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 1. TABLES
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS licenses (
|
|
||||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
license_key TEXT UNIQUE NOT NULL,
|
|
||||||
hwid TEXT, -- NULL until first activation
|
|
||||||
tier TEXT NOT NULL DEFAULT 'monthly', -- 'weekly' | 'monthly' | 'lifetime'
|
|
||||||
channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta'
|
|
||||||
expiry_date TIMESTAMPTZ, -- NULL = lifetime
|
|
||||||
active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
machines_allowed INTEGER NOT NULL DEFAULT 1,
|
|
||||||
notes TEXT, -- customer name / order ref
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
activated_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add channel column to existing tables (safe to run on already-created tables)
|
|
||||||
ALTER TABLE licenses ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS app_versions (
|
|
||||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
version TEXT UNIQUE NOT NULL, -- e.g. '1.0.1'
|
|
||||||
channel TEXT NOT NULL DEFAULT 'stable', -- 'stable' | 'beta'
|
|
||||||
gui_py_url TEXT, -- Supabase Storage URL for screener_gui.py
|
|
||||||
screener_py_url TEXT, -- Supabase Storage URL for stock_screener.py
|
|
||||||
release_notes TEXT,
|
|
||||||
is_latest BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
released_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add channel column to existing app_versions tables (safe to run on already-created tables)
|
|
||||||
ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'stable';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_log (
|
|
||||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
license_key TEXT,
|
|
||||||
hwid TEXT,
|
|
||||||
result TEXT, -- 'valid' | 'invalid'
|
|
||||||
reason TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS analyst_cache (
|
|
||||||
ticker TEXT PRIMARY KEY,
|
|
||||||
pe_forward DOUBLE PRECISION,
|
|
||||||
eps_forward DOUBLE PRECISION,
|
|
||||||
analyst_norm DOUBLE PRECISION,
|
|
||||||
analyst_upside DOUBLE PRECISION,
|
|
||||||
analyst_count INTEGER,
|
|
||||||
analyst_target DOUBLE PRECISION,
|
|
||||||
recommendation TEXT,
|
|
||||||
news_sentiment DOUBLE PRECISION,
|
|
||||||
sector TEXT,
|
|
||||||
industry TEXT,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fundamentals_cache (
|
|
||||||
ticker TEXT PRIMARY KEY,
|
|
||||||
-- Valuation
|
|
||||||
pe_trailing DOUBLE PRECISION,
|
|
||||||
pb_ratio DOUBLE PRECISION,
|
|
||||||
ev_ebitda DOUBLE PRECISION,
|
|
||||||
eps_trailing DOUBLE PRECISION,
|
|
||||||
-- Growth
|
|
||||||
revenue DOUBLE PRECISION,
|
|
||||||
revenue_growth DOUBLE PRECISION,
|
|
||||||
earnings_growth DOUBLE PRECISION,
|
|
||||||
-- Quality
|
|
||||||
roe DOUBLE PRECISION,
|
|
||||||
roa DOUBLE PRECISION,
|
|
||||||
debt_to_equity DOUBLE PRECISION,
|
|
||||||
total_debt DOUBLE PRECISION,
|
|
||||||
total_cash DOUBLE PRECISION,
|
|
||||||
book_value DOUBLE PRECISION,
|
|
||||||
current_ratio DOUBLE PRECISION,
|
|
||||||
-- Profitability
|
|
||||||
profit_margin DOUBLE PRECISION,
|
|
||||||
operating_margin DOUBLE PRECISION,
|
|
||||||
fcf_yield DOUBLE PRECISION,
|
|
||||||
dividend_yield DOUBLE PRECISION,
|
|
||||||
-- Market
|
|
||||||
market_cap DOUBLE PRECISION,
|
|
||||||
shares_outstanding DOUBLE PRECISION,
|
|
||||||
ev_revenue DOUBLE PRECISION,
|
|
||||||
-- Risk
|
|
||||||
short_percent DOUBLE PRECISION,
|
|
||||||
-- Meta
|
|
||||||
filer_type TEXT, -- 'us-gaap-quarterly' | 'us-gaap-annual' | 'ifrs-annual' | 'none'
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sector_stats (
|
|
||||||
sector TEXT PRIMARY KEY,
|
|
||||||
-- Valuation
|
|
||||||
pe_trailing_med DOUBLE PRECISION, pe_trailing_mad DOUBLE PRECISION,
|
|
||||||
pb_ratio_med DOUBLE PRECISION, pb_ratio_mad DOUBLE PRECISION,
|
|
||||||
ev_ebitda_med DOUBLE PRECISION, ev_ebitda_mad DOUBLE PRECISION,
|
|
||||||
ev_revenue_med DOUBLE PRECISION, ev_revenue_mad DOUBLE PRECISION,
|
|
||||||
-- Growth
|
|
||||||
revenue_growth_med DOUBLE PRECISION, revenue_growth_mad DOUBLE PRECISION,
|
|
||||||
earnings_growth_med DOUBLE PRECISION, earnings_growth_mad DOUBLE PRECISION,
|
|
||||||
eps_growth_med DOUBLE PRECISION, eps_growth_mad DOUBLE PRECISION,
|
|
||||||
-- Quality
|
|
||||||
roe_med DOUBLE PRECISION, roe_mad DOUBLE PRECISION,
|
|
||||||
roa_med DOUBLE PRECISION, roa_mad DOUBLE PRECISION,
|
|
||||||
debt_to_equity_med DOUBLE PRECISION, debt_to_equity_mad DOUBLE PRECISION,
|
|
||||||
current_ratio_med DOUBLE PRECISION, current_ratio_mad DOUBLE PRECISION,
|
|
||||||
-- Profitability
|
|
||||||
profit_margin_med DOUBLE PRECISION, profit_margin_mad DOUBLE PRECISION,
|
|
||||||
operating_margin_med DOUBLE PRECISION, operating_margin_mad DOUBLE PRECISION,
|
|
||||||
fcf_yield_med DOUBLE PRECISION, fcf_yield_mad DOUBLE PRECISION,
|
|
||||||
-- Analyst
|
|
||||||
analyst_upside_med DOUBLE PRECISION, analyst_upside_mad DOUBLE PRECISION,
|
|
||||||
-- Meta
|
|
||||||
ticker_count INTEGER,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add columns that may be missing if sector_stats was created before the current schema
|
|
||||||
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_med DOUBLE PRECISION;
|
|
||||||
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS eps_growth_mad DOUBLE PRECISION;
|
|
||||||
ALTER TABLE sector_stats ADD COLUMN IF NOT EXISTS ticker_count INTEGER;
|
|
||||||
|
|
||||||
ALTER TABLE sector_stats ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_policies
|
|
||||||
WHERE tablename = 'sector_stats' AND policyname = 'anon_select'
|
|
||||||
) THEN
|
|
||||||
CREATE POLICY anon_select ON sector_stats FOR SELECT TO anon USING (true);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE fundamentals_cache ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_policies
|
|
||||||
WHERE tablename = 'fundamentals_cache' AND policyname = 'anon_select'
|
|
||||||
) THEN
|
|
||||||
CREATE POLICY anon_select ON fundamentals_cache FOR SELECT TO anon USING (true);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- Seed the initial version row so the updater has something to compare against.
|
|
||||||
INSERT INTO app_versions (version, is_latest, release_notes)
|
|
||||||
SELECT '1.0.0', true, 'Initial release'
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM app_versions WHERE version = '1.0.0');
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 2. ROW LEVEL SECURITY — no direct table access for anon
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
ALTER TABLE licenses ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE analyst_cache ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- No RLS policies on licenses/app_versions/auth_log = anon cannot access directly.
|
|
||||||
-- All sensitive access goes through SECURITY DEFINER functions below.
|
|
||||||
|
|
||||||
-- analyst_cache is public market data — allow the anon key to read it.
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_policies
|
|
||||||
WHERE tablename = 'analyst_cache' AND policyname = 'anon_select'
|
|
||||||
) THEN
|
|
||||||
CREATE POLICY anon_select ON analyst_cache FOR SELECT TO anon USING (true);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 3. RPC: verify_license(p_key, p_hwid) → JSON
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- Called by the app on every launch with the stored license key + machine HWID.
|
|
||||||
-- Handles: first-time HWID binding, expiry, disabled keys, HWID mismatch.
|
|
||||||
-- Returns: { valid, reason, tier, expiry_date }
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION verify_license(p_key TEXT, p_hwid TEXT)
|
|
||||||
RETURNS JSON
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
lic RECORD;
|
|
||||||
BEGIN
|
|
||||||
-- Look up the key
|
|
||||||
SELECT * INTO lic FROM licenses WHERE license_key = p_key;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
INSERT INTO auth_log (license_key, hwid, result, reason)
|
|
||||||
VALUES (p_key, p_hwid, 'invalid', 'key_not_found');
|
|
||||||
RETURN json_build_object(
|
|
||||||
'valid', false,
|
|
||||||
'reason', 'License key not found.'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Check disabled
|
|
||||||
IF NOT lic.active THEN
|
|
||||||
INSERT INTO auth_log (license_key, hwid, result, reason)
|
|
||||||
VALUES (p_key, p_hwid, 'invalid', 'key_disabled');
|
|
||||||
RETURN json_build_object(
|
|
||||||
'valid', false,
|
|
||||||
'reason', 'This license has been disabled. Please contact support.'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Check expiry (NULL = lifetime, never expires)
|
|
||||||
IF lic.expiry_date IS NOT NULL AND lic.expiry_date < NOW() THEN
|
|
||||||
INSERT INTO auth_log (license_key, hwid, result, reason)
|
|
||||||
VALUES (p_key, p_hwid, 'invalid', 'expired');
|
|
||||||
RETURN json_build_object(
|
|
||||||
'valid', false,
|
|
||||||
'reason', 'Your subscription has expired. Please renew to continue.'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- HWID check
|
|
||||||
IF lic.hwid IS NULL THEN
|
|
||||||
-- First activation — bind this machine
|
|
||||||
UPDATE licenses
|
|
||||||
SET hwid = p_hwid, activated_at = NOW()
|
|
||||||
WHERE license_key = p_key;
|
|
||||||
ELSIF lic.hwid <> p_hwid THEN
|
|
||||||
INSERT INTO auth_log (license_key, hwid, result, reason)
|
|
||||||
VALUES (p_key, p_hwid, 'invalid', 'hwid_mismatch');
|
|
||||||
RETURN json_build_object(
|
|
||||||
'valid', false,
|
|
||||||
'reason', 'This license is registered to a different machine. Contact support to transfer.'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- All checks passed
|
|
||||||
INSERT INTO auth_log (license_key, hwid, result, reason)
|
|
||||||
VALUES (p_key, p_hwid, 'valid', 'ok');
|
|
||||||
|
|
||||||
RETURN json_build_object(
|
|
||||||
'valid', true,
|
|
||||||
'reason', 'ok',
|
|
||||||
'tier', lic.tier,
|
|
||||||
'channel', lic.channel,
|
|
||||||
'expiry_date', lic.expiry_date
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 4. RPC: get_latest_version() → JSON
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- Called by the updater after a successful license check.
|
|
||||||
-- Returns: { version, gui_py_url, screener_py_url, release_notes }
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION get_latest_version(p_channel TEXT DEFAULT 'stable')
|
|
||||||
RETURNS JSON
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
ver RECORD;
|
|
||||||
BEGIN
|
|
||||||
SELECT * INTO ver
|
|
||||||
FROM app_versions
|
|
||||||
WHERE is_latest = true
|
|
||||||
AND channel = p_channel
|
|
||||||
ORDER BY released_at DESC
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RETURN json_build_object('version', '1.0.0');
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN json_build_object(
|
|
||||||
'version', ver.version,
|
|
||||||
'gui_py_url', ver.gui_py_url,
|
|
||||||
'screener_py_url', ver.screener_py_url,
|
|
||||||
'release_notes', ver.release_notes
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 5. GRANTS — allow anon key to call the two RPC functions only
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION verify_license(TEXT, TEXT) TO anon;
|
|
||||||
GRANT EXECUTE ON FUNCTION get_latest_version(TEXT) TO anon;
|
|
||||||
|
|
@ -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.3.9"
|
APP_VERSION = "1.4.9"
|
||||||
|
|
||||||
|
|
||||||
def _exe_dir() -> str:
|
def _exe_dir() -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue