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 = []
|
||||
binaries = []
|
||||
hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment', 'pyarmor_runtime_000000']
|
||||
tmp_ret = collect_all('pandas')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('numpy')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('yfinance')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('matplotlib')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('requests')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('certifi')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('charset_normalizer')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('vaderSentiment')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('tzdata')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
hiddenimports = [
|
||||
'pandas', 'numpy', 'yfinance', 'requests',
|
||||
'lxml', 'lxml.etree', 'html5lib', 'bs4',
|
||||
'appdirs', 'platformdirs',
|
||||
'tkinter', 'tkinter.ttk', 'tkinter.messagebox',
|
||||
'screener_gui', 'stock_screener',
|
||||
'matplotlib', 'matplotlib.backends.backend_qtagg',
|
||||
'matplotlib.backends.backend_tkagg',
|
||||
'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker',
|
||||
'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer',
|
||||
'hwid', 'license_check', 'activation_dialog', 'updater',
|
||||
'vaderSentiment', 'vaderSentiment.vaderSentiment',
|
||||
'pyarmor_runtime_000000',
|
||||
'PySide6', 'PySide6.QtCore', 'PySide6.QtWidgets', 'PySide6.QtGui',
|
||||
'PySide6.QtCharts', 'PySide6.QtNetwork',
|
||||
'shiboken6',
|
||||
]
|
||||
|
||||
for pkg in ('pandas', 'numpy', 'yfinance', 'matplotlib', 'requests',
|
||||
'certifi', 'charset_normalizer', 'vaderSentiment', 'tzdata',
|
||||
'PySide6'):
|
||||
tmp_ret = collect_all(pkg)
|
||||
datas += tmp_ret[0]
|
||||
binaries += tmp_ret[1]
|
||||
hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
|
|
@ -58,5 +64,5 @@ exe = EXE(
|
|||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
uac_admin=True, # embed UAC manifest: always run as Administrator
|
||||
uac_admin=True,
|
||||
)
|
||||
|
|
|
|||
13
api/main.py
13
api/main.py
|
|
@ -20,7 +20,9 @@ app = FastAPI()
|
|||
DB_HOST = "127.0.0.1"
|
||||
DB_NAME = "verimund"
|
||||
DB_USER = "verimund_user"
|
||||
DB_PASS = os.getenv("DB_PASS", "Shlevison2k17")
|
||||
DB_PASS = os.getenv("DB_PASS")
|
||||
if not DB_PASS:
|
||||
raise RuntimeError("DB_PASS environment variable is not set")
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "")
|
||||
HMAC_SECRET = os.getenv("HMAC_SECRET", "")
|
||||
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
|
||||
|
|
@ -157,11 +159,14 @@ def check_update(payload=Depends(verify_jwt), db=Depends(get_db)):
|
|||
|
||||
|
||||
@app.get("/admin/versions")
|
||||
def get_versions(_=Depends(verify_admin), db=Depends(get_db)):
|
||||
def get_versions(channel: str = "stable", _=Depends(verify_admin), db=Depends(get_db)):
|
||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT * FROM app_versions ORDER BY released_at DESC LIMIT 1")
|
||||
cur.execute(
|
||||
"SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1",
|
||||
(channel,)
|
||||
)
|
||||
version = cur.fetchone()
|
||||
return {"version": version["version"] if version else "1.0.0"}
|
||||
return {"version": version["version"] if version else "1.0.0", "channel": channel}
|
||||
|
||||
|
||||
@app.post("/admin/register-version")
|
||||
|
|
|
|||
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_NAME = "verimund"
|
||||
_DB_USER = "verimund_user"
|
||||
_DB_PASS = os.environ.get("DB_PASS", "Shlevison2k17")
|
||||
_DB_PASS = os.environ.get("DB_PASS")
|
||||
if not _DB_PASS:
|
||||
raise RuntimeError("DB_PASS environment variable is not set")
|
||||
|
||||
# TABLE_PREFIX is set by the cron job: "" for stable, "beta_" for beta channel.
|
||||
_TABLE_PREFIX = os.environ.get("TABLE_PREFIX", "")
|
||||
|
|
@ -284,6 +286,7 @@ def _fetch_analyst(ticker: str) -> dict | None:
|
|||
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"name": info.get("longName") or None,
|
||||
"pe_forward": info.get("forwardPE"),
|
||||
"eps_forward": info.get("forwardEps"),
|
||||
"analyst_norm": analyst_norm,
|
||||
|
|
@ -293,6 +296,8 @@ def _fetch_analyst(ticker: str) -> dict | None:
|
|||
"recommendation": info.get("recommendationKey") or "N/A",
|
||||
"sector": info.get("sector") or None,
|
||||
"industry": info.get("industry") or None,
|
||||
"short_percent": info.get("shortPercentOfFloat"),
|
||||
"short_ratio": info.get("shortRatio"),
|
||||
"dividend_yield": info.get("dividendYield"),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
|
@ -414,6 +419,7 @@ def _fetch_news_sentiment(ticker: str) -> dict | None:
|
|||
return {
|
||||
"ticker": ticker,
|
||||
"news_sentiment": sentiment,
|
||||
"news_headline_count": len(scores),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
except Exception:
|
||||
|
|
@ -463,16 +469,17 @@ def run_news_phase(tickers: list[str]) -> None:
|
|||
# Explicit column allow-lists keep extra fields (e.g. dividend_yield from
|
||||
# yfinance) from causing "column does not exist" errors in PostgreSQL.
|
||||
_ANALYST_COLS = (
|
||||
"ticker", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
||||
"ticker", "name", "pe_forward", "eps_forward", "analyst_norm", "analyst_upside",
|
||||
"analyst_count", "analyst_target", "recommendation", "news_sentiment",
|
||||
"sector", "industry", "updated_at",
|
||||
"news_headline_count", "sector", "industry", "short_percent", "short_ratio", "updated_at",
|
||||
)
|
||||
_FUND_COLS = (
|
||||
"ticker", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
|
||||
"ticker", "name", "pe_trailing", "pb_ratio", "ev_ebitda", "eps_trailing",
|
||||
"revenue", "revenue_growth", "earnings_growth", "roe", "roa",
|
||||
"debt_to_equity", "total_debt", "total_cash", "book_value", "current_ratio",
|
||||
"profit_margin", "operating_margin", "fcf_yield", "dividend_yield",
|
||||
"market_cap", "shares_outstanding", "ev_revenue", "short_percent",
|
||||
"piotroski_score", "accruals_ratio",
|
||||
"filer_type", "updated_at",
|
||||
)
|
||||
|
||||
|
|
@ -534,8 +541,11 @@ def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
|
|||
_FRAME_CACHE[key] = result
|
||||
time.sleep(0.15)
|
||||
return result
|
||||
except Exception:
|
||||
_FRAME_CACHE[key] = {}
|
||||
except requests.HTTPError as e:
|
||||
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: HTTP {e.response.status_code} — not cached")
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f" [WARN] EDGAR frame {concept}/{unit}/{period}: {e} — not cached")
|
||||
return {}
|
||||
|
||||
|
||||
|
|
@ -547,7 +557,7 @@ def _sum_frames(concepts: list[str], unit: str, periods: list[str]) -> dict[int,
|
|||
ttm.setdefault(cik, {})
|
||||
if period not in ttm[cik]:
|
||||
ttm[cik][period] = val
|
||||
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
|
||||
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
|
||||
|
||||
|
||||
def _best_frame(concepts: list[str], unit: str, periods: list[str]) -> dict[int, float]:
|
||||
|
|
@ -613,6 +623,20 @@ def _compute_fundamentals(
|
|||
if rev is not None and rev_prev and rev_prev != 0 else None)
|
||||
ni_g = ((ni - ni_prev) / abs(ni_prev)
|
||||
if ni is not None and ni_prev and ni_prev != 0 else None)
|
||||
|
||||
# Piotroski partial F-Score (4 signals available at compute time)
|
||||
p = 0
|
||||
if ni is not None and tot_a and tot_a > 0 and ni / tot_a > 0: p += 1 # ROA > 0
|
||||
if ocf is not None and ocf > 0: p += 1 # OCF > 0
|
||||
if ocf is not None and ni is not None and ocf > ni: p += 1 # OCF > NI (cash-backed)
|
||||
if rev is not None and rev_prev is not None and rev_prev != 0 and rev > rev_prev: p += 1 # Revenue improving
|
||||
piotroski = p
|
||||
|
||||
# Sloan accruals ratio: (NI - OCF) / Total Assets — negative = higher earnings quality
|
||||
accruals = None
|
||||
if ni is not None and ocf is not None and tot_a and tot_a > 0:
|
||||
accruals = max(-0.3, min(0.3, (ni - ocf) / tot_a))
|
||||
|
||||
return {
|
||||
"market_cap": mkt_cap,
|
||||
"shares_outstanding": sh,
|
||||
|
|
@ -634,6 +658,8 @@ def _compute_fundamentals(
|
|||
"profit_margin": pm,
|
||||
"operating_margin": om,
|
||||
"fcf_yield": fcf_yield,
|
||||
"piotroski_score": piotroski,
|
||||
"accruals_ratio": accruals,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -721,7 +747,13 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]:
|
|||
cash.get(cik), lt_debt.get(cik),
|
||||
shares.get(cik),
|
||||
)
|
||||
try:
|
||||
_info = yf.Ticker(ticker).info or {}
|
||||
_name = _info.get("longName") or _info.get("shortName") or None
|
||||
except Exception:
|
||||
_name = None
|
||||
metrics["ticker"] = ticker
|
||||
metrics["name"] = _name
|
||||
metrics["filer_type"] = "us-gaap-quarterly"
|
||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result[ticker] = metrics
|
||||
|
|
@ -752,7 +784,13 @@ def run_fundamentals_phase(all_tickers: list[str]) -> dict[str, dict]:
|
|||
cash.get(cik), lt_debt.get(cik),
|
||||
shares.get(cik),
|
||||
)
|
||||
try:
|
||||
_info = yf.Ticker(ticker).info or {}
|
||||
_name = _info.get("longName") or _info.get("shortName") or None
|
||||
except Exception:
|
||||
_name = None
|
||||
metrics["ticker"] = ticker
|
||||
metrics["name"] = _name
|
||||
metrics["filer_type"] = "us-gaap-quarterly"
|
||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result[ticker] = metrics
|
||||
|
|
@ -889,7 +927,13 @@ def run_foreign_filers_phase() -> dict[str, dict]:
|
|||
raw.get("cash"), raw.get("lt_debt"),
|
||||
raw.get("shares"),
|
||||
)
|
||||
try:
|
||||
_info = yf.Ticker(ticker).info or {}
|
||||
_name = _info.get("longName") or _info.get("shortName") or None
|
||||
except Exception:
|
||||
_name = None
|
||||
metrics["ticker"] = ticker
|
||||
metrics["name"] = _name
|
||||
metrics["filer_type"] = f"{taxonomy}-annual"
|
||||
metrics["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
result[ticker] = metrics
|
||||
|
|
|
|||
|
|
@ -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 html
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
|
@ -791,7 +792,8 @@ def _fetch_one_frame(concept: str, unit: str, period: str) -> dict[int, float]:
|
|||
time.sleep(0.15) # stay comfortably under SEC's 10 req/sec limit
|
||||
return result
|
||||
except Exception:
|
||||
_FRAME_CACHE[key] = {}
|
||||
# Do not cache transient failures (network errors, timeouts, etc.)
|
||||
# Only 404s (already handled above) should produce a permanent empty cache entry.
|
||||
return {}
|
||||
|
||||
|
||||
|
|
@ -808,7 +810,7 @@ def _sum_frames(concepts: list[str], unit: str,
|
|||
ttm.setdefault(cik, {})
|
||||
if period not in ttm[cik]:
|
||||
ttm[cik][period] = val
|
||||
return {cik: sum(pv.values()) for cik, pv in ttm.items()}
|
||||
return {cik: sum(pv.values()) for cik, pv in ttm.items() if len(pv) >= 4}
|
||||
|
||||
|
||||
def _best_frame(concepts: list[str], unit: str,
|
||||
|
|
@ -995,11 +997,21 @@ def _fetch_edgar_bulk(close_map: dict[str, pd.Series]) -> dict[str, dict]:
|
|||
# Analyst + Fundamentals Cache (Verimund API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
from license_check import _API_URL as _API_URL, get_api_headers as _get_api_headers
|
||||
except ImportError:
|
||||
_API_URL = None
|
||||
_get_api_headers = lambda: {}
|
||||
_API_URL = "https://api.verimundsolutions.com"
|
||||
|
||||
def _get_api_headers() -> dict:
|
||||
"""Read JWT from the auth cache file directly — no dependency on obfuscated license_check."""
|
||||
try:
|
||||
import json as _json
|
||||
_cache = os.path.join(
|
||||
os.environ.get("APPDATA", os.path.expanduser("~")),
|
||||
"StockScreener", "auth_cache.dat"
|
||||
)
|
||||
with open(_cache, "r", encoding="utf-8") as _f:
|
||||
_tok = _json.load(_f).get("jwt_token", "")
|
||||
return {"Authorization": f"Bearer {_tok}", "Content-Type": "application/json"}
|
||||
except Exception:
|
||||
return {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _load_analyst_cache() -> dict[str, dict]:
|
||||
|
|
@ -1298,6 +1310,8 @@ def _fetch_fundamentals(
|
|||
price_now = float(close.iloc[-1])
|
||||
|
||||
def pct_return(days_back: int) -> float | None:
|
||||
if len(close) <= days_back:
|
||||
return None
|
||||
idx = max(0, len(close) - days_back)
|
||||
past = float(close.iloc[idx])
|
||||
return (price_now - past) / past if past > 0 else None
|
||||
|
|
@ -1350,7 +1364,7 @@ def _fetch_fundamentals(
|
|||
"ret_12m": pct_return(252),
|
||||
"roe": info.get("returnOnEquity"),
|
||||
"roa": info.get("returnOnAssets"),
|
||||
"debt_to_equity": info.get("debtToEquity"),
|
||||
"debt_to_equity": (info["debtToEquity"] / 100.0) if info.get("debtToEquity") is not None else None,
|
||||
"total_debt": info.get("totalDebt"),
|
||||
"total_cash": info.get("totalCash"),
|
||||
"book_value": info.get("bookValue"),
|
||||
|
|
@ -1366,6 +1380,7 @@ def _fetch_fundamentals(
|
|||
"analyst_target": target_price,
|
||||
"recommendation": info.get("recommendationKey", "N/A"),
|
||||
"short_percent": info.get("shortPercentOfFloat"),
|
||||
"short_ratio": info.get("shortRatio"),
|
||||
"beta": info.get("beta"),
|
||||
"volatility_30d": volatility,
|
||||
"market_cap": info.get("marketCap"),
|
||||
|
|
@ -1500,6 +1515,8 @@ def fetch_all(
|
|||
price_now = float(close.iloc[-1])
|
||||
|
||||
def pct_return(days_back: int) -> float | None:
|
||||
if len(close) <= days_back:
|
||||
return None
|
||||
idx = max(0, len(close) - days_back)
|
||||
past = float(close.iloc[idx])
|
||||
return (price_now - past) / past if past > 0 else None
|
||||
|
|
@ -1544,7 +1561,7 @@ def fetch_all(
|
|||
|
||||
results.append({
|
||||
"ticker": ticker,
|
||||
"name": edgar.get("name", ticker),
|
||||
"name": analyst.get("name") or edgar.get("name") or ticker,
|
||||
"sector": analyst.get("sector") or edgar.get("sector", "N/A"),
|
||||
"industry": analyst.get("industry") or edgar.get("industry", "N/A"),
|
||||
"price": price_now,
|
||||
|
|
@ -1586,8 +1603,15 @@ def fetch_all(
|
|||
"analyst_count": analyst.get("analyst_count"),
|
||||
"analyst_target": analyst_target,
|
||||
"recommendation": analyst.get("recommendation", "N/A"),
|
||||
# Risk — FINRA cache first, then yfinance shortPercentOfFloat as display fallback
|
||||
"short_percent": finra_short.get(ticker) or analyst.get("short_percent"),
|
||||
# Bug 10 fix: use only shortPercentOfFloat (0.0–1.0 float fraction) from yfinance.
|
||||
# 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),
|
||||
"volatility_30d": volatility,
|
||||
# Market data
|
||||
|
|
@ -1803,39 +1827,40 @@ _PROFILE_WEIGHTS: dict[str, dict[str, dict[str, float]]] = {
|
|||
"mature": {
|
||||
"value": {"pe": 0.35, "pb": 0.20, "ev_ebitda": 0.45},
|
||||
"growth": {"revenue_growth": 0.30, "earnings_growth": 0.45, "eps_growth": 0.25},
|
||||
"quality": {"roa": 0.40, "roe": 0.20, "debt_to_equity": 0.30, "current_ratio": 0.10},
|
||||
"profitability": {"fcf_yield": 0.40, "operating_margin": 0.38, "profit_margin": 0.22},
|
||||
"quality": {"roa": 0.40, "roe": 0.20, "debt_to_equity": 0.30, "current_ratio": 0.10, "piotroski": 0.20},
|
||||
"profitability": {"fcf_yield": 0.40, "operating_margin": 0.38, "profit_margin": 0.22, "accruals": 0.15},
|
||||
},
|
||||
"high_growth": {
|
||||
"value": {"pe": 0.00, "pb": 0.20, "ev_ebitda": 0.35, "ev_revenue": 0.45},
|
||||
"growth": {"revenue_growth": 0.50, "earnings_growth": 0.30, "eps_growth": 0.20},
|
||||
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.15, "current_ratio": 0.35},
|
||||
"profitability": {"fcf_yield": 0.15, "operating_margin": 0.50, "profit_margin": 0.35},
|
||||
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.15, "current_ratio": 0.35, "piotroski": 0.20},
|
||||
"profitability": {"fcf_yield": 0.15, "operating_margin": 0.50, "profit_margin": 0.35, "accruals": 0.15},
|
||||
},
|
||||
"financial": {
|
||||
"value": {"pe": 0.35, "pb": 0.50, "ev_ebitda": 0.15},
|
||||
"growth": {"revenue_growth": 0.30, "earnings_growth": 0.50, "eps_growth": 0.20},
|
||||
"quality": {"roa": 0.50, "roe": 0.30, "debt_to_equity": 0.00, "current_ratio": 0.20},
|
||||
"profitability": {"fcf_yield": 0.30, "operating_margin": 0.00, "profit_margin": 0.70},
|
||||
"quality": {"roa": 0.50, "roe": 0.30, "debt_to_equity": 0.00, "current_ratio": 0.20, "piotroski": 0.15},
|
||||
"profitability": {"fcf_yield": 0.30, "operating_margin": 0.00, "profit_margin": 0.70, "accruals": 0.15},
|
||||
},
|
||||
"capital_intensive": {
|
||||
"value": {"pe": 0.15, "pb": 0.20, "ev_ebitda": 0.65},
|
||||
"growth": {"revenue_growth": 0.35, "earnings_growth": 0.40, "eps_growth": 0.25},
|
||||
"quality": {"roa": 0.30, "roe": 0.15, "debt_to_equity": 0.40, "current_ratio": 0.15},
|
||||
"profitability": {"fcf_yield": 0.35, "operating_margin": 0.45, "profit_margin": 0.20},
|
||||
"quality": {"roa": 0.30, "roe": 0.15, "debt_to_equity": 0.40, "current_ratio": 0.15, "piotroski": 0.15},
|
||||
"profitability": {"fcf_yield": 0.35, "operating_margin": 0.45, "profit_margin": 0.20, "accruals": 0.15},
|
||||
},
|
||||
"early_stage": {
|
||||
"value": {"pe": 0.00, "pb": 0.15, "ev_ebitda": 0.00, "ev_revenue": 0.85},
|
||||
"growth": {"revenue_growth": 0.70, "earnings_growth": 0.20, "eps_growth": 0.10},
|
||||
"quality": {"roa": 0.20, "roe": 0.00, "debt_to_equity": 0.00, "current_ratio": 0.80},
|
||||
"profitability": {"fcf_yield": 0.00, "operating_margin": 0.40, "profit_margin": 0.00,
|
||||
"revenue_growth_proxy": 0.60},
|
||||
"quality": {"roa": 0.20, "roe": 0.00, "debt_to_equity": 0.00, "current_ratio": 0.80, "piotroski": 0.10},
|
||||
# revenue_growth_proxy removed — revenue growth already carries 70% of the growth score
|
||||
"profitability": {"fcf_yield": 0.00, "operating_margin": 1.00, "profit_margin": 0.00,
|
||||
"revenue_growth_proxy": 0.00},
|
||||
},
|
||||
"turnaround": {
|
||||
"value": {"pe": 0.00, "pb": 0.30, "ev_ebitda": 0.70},
|
||||
"growth": {"revenue_growth": 0.15, "earnings_growth": 0.80, "eps_growth": 0.05},
|
||||
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.35, "current_ratio": 0.15},
|
||||
"profitability": {"fcf_yield": 0.25, "operating_margin": 0.45, "profit_margin": 0.30},
|
||||
"quality": {"roa": 0.35, "roe": 0.15, "debt_to_equity": 0.35, "current_ratio": 0.15, "piotroski": 0.20},
|
||||
"profitability": {"fcf_yield": 0.25, "operating_margin": 0.45, "profit_margin": 0.30, "accruals": 0.15},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1871,23 +1896,24 @@ def compute_value_score(df: pd.DataFrame,
|
|||
"""
|
||||
ss = sector_stats or {}
|
||||
sectors = df.get("sector", pd.Series(["N/A"] * len(df), index=df.index))
|
||||
pe = df["pe_forward"].combine_first(df["pe_trailing"]).where(lambda x: x > 0)
|
||||
|
||||
# Pre-compute z-scores for each metric
|
||||
pe_s = _z_series(pe, "pe_trailing", sectors, ss)
|
||||
pb_s = _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
|
||||
ev_s = _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
|
||||
evr_s = _z_series(df.get("ev_revenue", pd.Series(
|
||||
# Z-score forward and trailing PE against their own sector distributions, then combine
|
||||
# Lower valuation ratios are better — invert z-scores so cheap stocks rank high
|
||||
fwd_s = 100.0 - _z_series(df["pe_forward"].where(df["pe_forward"] > 0), "pe_forward", sectors, ss)
|
||||
trl_s = 100.0 - _z_series(df["pe_trailing"].where(df["pe_trailing"] > 0), "pe_trailing", sectors, ss)
|
||||
pe_s = fwd_s.combine_first(trl_s)
|
||||
pb_s = 100.0 - _z_series(df["pb_ratio"], "pb_ratio", sectors, ss)
|
||||
ev_s = 100.0 - _z_series(df["ev_ebitda"], "ev_ebitda", sectors, ss)
|
||||
evr_s = 100.0 - _z_series(df.get("ev_revenue", pd.Series(
|
||||
[None]*len(df), index=df.index)),
|
||||
"ev_revenue", sectors, ss)
|
||||
|
||||
scores = []
|
||||
for i, row in df.iterrows():
|
||||
ms = {
|
||||
"pe": float(pe_s.iloc[i]),
|
||||
"pb": float(pb_s.iloc[i]),
|
||||
"ev_ebitda": float(ev_s.iloc[i]),
|
||||
"ev_revenue": float(evr_s.iloc[i]),
|
||||
"pe": float(pe_s.loc[i]),
|
||||
"pb": float(pb_s.loc[i]),
|
||||
"ev_ebitda": float(ev_s.loc[i]),
|
||||
"ev_revenue": float(evr_s.loc[i]),
|
||||
}
|
||||
scores.append(_profile_score_row(row, "value", ms))
|
||||
return pd.Series(scores, index=df.index)
|
||||
|
|
@ -1914,9 +1940,9 @@ def compute_growth_score(df: pd.DataFrame,
|
|||
scores = []
|
||||
for i, row in df.iterrows():
|
||||
ms = {
|
||||
"revenue_growth": float(rev_s.iloc[i]),
|
||||
"earnings_growth": float(ear_s.iloc[i]),
|
||||
"eps_growth": float(eps_s.iloc[i]),
|
||||
"revenue_growth": float(rev_s.loc[i]),
|
||||
"earnings_growth": float(ear_s.loc[i]),
|
||||
"eps_growth": float(eps_s.loc[i]),
|
||||
}
|
||||
scores.append(_profile_score_row(row, "growth", ms))
|
||||
return pd.Series(scores, index=df.index)
|
||||
|
|
@ -1967,19 +1993,27 @@ def compute_quality_score(df: pd.DataFrame,
|
|||
|
||||
roa_s = _z_series(df["roa"], "roa", sectors, ss)
|
||||
roe_s = _z_series(df["roe"], "roe", sectors, ss)
|
||||
de_s = _z_series(df["debt_to_equity"], "debt_to_equity", sectors, ss)
|
||||
cr_s = _z_series(df["current_ratio"], "current_ratio", sectors, ss)
|
||||
|
||||
# Negative D/E means negative equity (insolvency risk) — treat as extremely high leverage
|
||||
de_raw = df["debt_to_equity"].where(df["debt_to_equity"] >= 0, 999.0)
|
||||
# D/E z-score is inverted — lower D/E is better, so flip the z-score
|
||||
de_s = 100.0 - de_s
|
||||
de_s = 100.0 - _z_series(de_raw, "debt_to_equity", sectors, ss)
|
||||
|
||||
# Piotroski partial F-Score (0-4) → 0-100 via absolute breakpoints
|
||||
p_raw = pd.to_numeric(df.get("piotroski_score",
|
||||
pd.Series([None] * len(df), index=df.index)), errors="coerce")
|
||||
p_pts = [(0, 5), (1, 30), (2, 50), (3, 72), (4, 95)]
|
||||
piotroski_s = _score_series(p_raw, p_pts)
|
||||
|
||||
scores = []
|
||||
for i, row in df.iterrows():
|
||||
ms = {
|
||||
"roa": float(roa_s.iloc[i]),
|
||||
"roe": float(roe_s.iloc[i]),
|
||||
"debt_to_equity": float(de_s.iloc[i]),
|
||||
"current_ratio": float(cr_s.iloc[i]),
|
||||
"roa": float(roa_s.loc[i]),
|
||||
"roe": float(roe_s.loc[i]),
|
||||
"debt_to_equity": float(de_s.loc[i]),
|
||||
"current_ratio": float(cr_s.loc[i]),
|
||||
"piotroski": float(piotroski_s.loc[i]),
|
||||
}
|
||||
scores.append(_profile_score_row(row, "quality", ms))
|
||||
return pd.Series(scores, index=df.index)
|
||||
|
|
@ -1999,13 +2033,21 @@ def compute_profitability_score(df: pd.DataFrame,
|
|||
pm_s = _z_series(df["profit_margin"], "profit_margin", sectors, ss)
|
||||
rev_s = _z_series(df["revenue_growth"], "revenue_growth", sectors, ss)
|
||||
|
||||
# Sloan accruals ratio: more negative = earnings are cash-backed = better quality
|
||||
acc_raw = pd.to_numeric(df.get("accruals_ratio",
|
||||
pd.Series([None] * len(df), index=df.index)), errors="coerce")
|
||||
acc_pts = [(-0.25, 95), (-0.10, 78), (-0.03, 62), (0.0, 50),
|
||||
(0.03, 38), (0.10, 22), (0.25, 5)]
|
||||
accruals_s = _score_series(acc_raw, acc_pts)
|
||||
|
||||
scores = []
|
||||
for i, row in df.iterrows():
|
||||
ms = {
|
||||
"fcf_yield": float(fcf_s.iloc[i]),
|
||||
"operating_margin": float(om_s.iloc[i]),
|
||||
"profit_margin": float(pm_s.iloc[i]),
|
||||
"revenue_growth_proxy": float(rev_s.iloc[i]), # used by early_stage
|
||||
"fcf_yield": float(fcf_s.loc[i]),
|
||||
"operating_margin": float(om_s.loc[i]),
|
||||
"profit_margin": float(pm_s.loc[i]),
|
||||
"revenue_growth_proxy": float(rev_s.loc[i]), # used by early_stage
|
||||
"accruals": float(accruals_s.loc[i]),
|
||||
}
|
||||
scores.append(_profile_score_row(row, "profitability", ms))
|
||||
return pd.Series(scores, index=df.index)
|
||||
|
|
@ -2013,12 +2055,27 @@ def compute_profitability_score(df: pd.DataFrame,
|
|||
|
||||
def compute_sentiment_score(df: pd.DataFrame) -> pd.Series:
|
||||
"""
|
||||
News Sentiment Score — VADER compound score mapped to 0–100 (absolute scale).
|
||||
Sentiment is inherently comparable across all stocks; no z-scoring applied.
|
||||
News Sentiment Score — VADER compound score mapped to 0–100, confidence-weighted
|
||||
by headline count. Fewer headlines blend toward neutral (50) to avoid overreacting
|
||||
to a single article.
|
||||
"""
|
||||
sent_pts = [(-1.0, 0), (-0.40, 20), (-0.15, 36), (0.0, 50),
|
||||
(0.15, 64), (0.40, 80), (1.0, 100)]
|
||||
return _score_series(df["news_sentiment"], sent_pts)
|
||||
base = _score_series(df["news_sentiment"], sent_pts)
|
||||
|
||||
# Derive headline count from stored field or from news_headlines list
|
||||
if "news_headline_count" in df.columns:
|
||||
count = pd.to_numeric(df["news_headline_count"], errors="coerce").fillna(0)
|
||||
elif "news_headlines" in df.columns:
|
||||
count = df["news_headlines"].apply(
|
||||
lambda h: len(h) if isinstance(h, list) else 0
|
||||
).astype(float)
|
||||
else:
|
||||
return base
|
||||
|
||||
# 5+ headlines = full confidence; 0 headlines = 5% confidence (nearly neutral)
|
||||
confidence = (count.clip(upper=5) / 5.0).clip(lower=0.05)
|
||||
return base * confidence + 50.0 * (1.0 - confidence)
|
||||
|
||||
|
||||
def compute_analyst_score(df: pd.DataFrame,
|
||||
|
|
@ -2092,8 +2149,9 @@ def score_stocks(df: pd.DataFrame, weights: dict | None = None,
|
|||
"ret_1m", "ret_3m", "ret_6m", "ret_12m",
|
||||
"roe", "roa", "debt_to_equity", "current_ratio",
|
||||
"profit_margin", "operating_margin", "fcf_yield",
|
||||
"news_sentiment", "analyst_norm", "analyst_upside", "analyst_count",
|
||||
"short_percent", "volatility_30d", "beta", "price", "market_cap",
|
||||
"news_sentiment", "news_headline_count", "analyst_norm", "analyst_upside", "analyst_count",
|
||||
"short_percent", "short_ratio", "volatility_30d", "beta", "price", "market_cap",
|
||||
"piotroski_score", "accruals_ratio",
|
||||
]
|
||||
for col in _NUMERIC_COLS:
|
||||
if col in df.columns:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from tkinter import messagebox
|
||||
|
||||
# Add dist/ to path so screener_gui and stock_screener are importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist"))
|
||||
|
||||
# Fix SSL certificate path for PyInstaller frozen builds.
|
||||
# Without this, requests raises "Could not find a suitable TLS CA certificate bundle".
|
||||
if getattr(sys, "frozen", False):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_VERSION = "1.3.9"
|
||||
APP_VERSION = "1.4.9"
|
||||
|
||||
|
||||
def _exe_dir() -> str:
|
||||
|
|
|
|||
Loading…
Reference in New Issue