Fix missing os import in stock_screener; force app to open on primary monitor
This commit is contained in:
parent
78f3a25022
commit
df032593bd
|
|
@ -1,34 +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)",
|
|
||||||
"Skill(update-config)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"PostToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Edit|Write",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "cd \"C:/Users/noach/Desktop/Stock Tool/Stock Tool\" && python -m py_compile dist/stock_screener.py dist/screener_gui.py cache_builder.py api/main.py 2>&1 && echo \"[hook] SYNTAX OK\" || echo \"[hook] SYNTAX ERROR — see above\""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "cd \"C:/Users/noach/Desktop/Stock Tool/Stock Tool\" && python -m pytest tests/test_scoring.py -q --tb=short 2>&1 | tail -20"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -2055,6 +2055,12 @@ class ScreenerPage(QWidget):
|
||||||
self._df_raw = df_raw
|
self._df_raw = df_raw
|
||||||
self._df = df_scored
|
self._df = df_scored
|
||||||
self._sector_stats = sector_stats
|
self._sector_stats = sector_stats
|
||||||
|
try:
|
||||||
|
with open(_lp2, "a") as _lf2b:
|
||||||
|
from datetime import datetime as _dt2b
|
||||||
|
_lf2b.write(f"[{_dt2b.now().strftime('%H:%M:%S')}] _on_done: df_scored={'None' if df_scored is None else len(df_scored)}, self._df={'None' if self._df is None else 'set'}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._progress.setValue(100)
|
self._progress.setValue(100)
|
||||||
self._status_lbl.setText(f" Done — {total} stocks scored.")
|
self._status_lbl.setText(f" Done — {total} stocks scored.")
|
||||||
self._apply_filters()
|
self._apply_filters()
|
||||||
|
|
@ -2083,15 +2089,41 @@ class ScreenerPage(QWidget):
|
||||||
self._progress.hide()
|
self._progress.hide()
|
||||||
|
|
||||||
def _apply_filters(self):
|
def _apply_filters(self):
|
||||||
if self._df is None: return
|
import os as _os4, traceback as _tb4
|
||||||
|
_lp4 = _os4.path.join(_os4.environ.get("TEMP", _os4.path.expanduser("~")), "screener_run.log")
|
||||||
|
def _log4(msg):
|
||||||
|
try:
|
||||||
|
with open(_lp4, "a") as _lf4:
|
||||||
|
from datetime import datetime as _dt4
|
||||||
|
_lf4.write(f"[{_dt4.now().strftime('%H:%M:%S')}] AF: {msg}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_log4(f"called, self._df={'None' if self._df is None else len(self._df)}")
|
||||||
|
if self._df is None:
|
||||||
|
_log4("returning early — df is None")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self._apply_filters_inner()
|
self._apply_filters_inner()
|
||||||
|
_log4("inner completed OK")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import traceback
|
_log4(f"EXCEPTION: {_tb4.format_exc()}")
|
||||||
QMessageBox.critical(self, "Display Error",
|
try:
|
||||||
f"Failed to display results:\n\n{traceback.format_exc()}")
|
QMessageBox.critical(self, "Display Error",
|
||||||
|
f"Failed to display results:\n\n{_tb4.format_exc()}")
|
||||||
|
except Exception as e2:
|
||||||
|
_log4(f"QMessageBox also failed: {e2}")
|
||||||
|
|
||||||
def _apply_filters_inner(self):
|
def _apply_filters_inner(self):
|
||||||
|
import os as _os3
|
||||||
|
_lp3 = _os3.path.join(_os3.environ.get("TEMP", _os3.path.expanduser("~")), "screener_run.log")
|
||||||
|
def _log3(msg):
|
||||||
|
try:
|
||||||
|
with open(_lp3, "a") as _lf3:
|
||||||
|
from datetime import datetime as _dt3
|
||||||
|
_lf3.write(f"[{_dt3.now().strftime('%H:%M:%S')}] AFI: {msg}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_log3(f"start, df={None if self._df is None else len(self._df)} rows")
|
||||||
df = self._df
|
df = self._df
|
||||||
rule = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText())
|
rule = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText())
|
||||||
if rule:
|
if rule:
|
||||||
|
|
@ -2152,7 +2184,9 @@ class ScreenerPage(QWidget):
|
||||||
elif sort_by == "Upside ↓" and "analyst_upside" in df.columns:
|
elif sort_by == "Upside ↓" and "analyst_upside" in df.columns:
|
||||||
df = df.sort_values("analyst_upside", ascending=False, na_position="last")
|
df = df.sort_values("analyst_upside", ascending=False, na_position="last")
|
||||||
|
|
||||||
|
_log3(f"calling populate with {len(df)} rows")
|
||||||
self._table.populate(df)
|
self._table.populate(df)
|
||||||
|
_log3("populate done")
|
||||||
n = len(df)
|
n = len(df)
|
||||||
self._status_lbl.setText(f" Showing {n} stocks. Click a ticker to see details.")
|
self._status_lbl.setText(f" Showing {n} stocks. Click a ticker to see details.")
|
||||||
|
|
||||||
|
|
@ -2925,6 +2959,11 @@ def main():
|
||||||
app.setStyleSheet(_QSS)
|
app.setStyleSheet(_QSS)
|
||||||
win = ScreenerApp()
|
win = ScreenerApp()
|
||||||
win.show()
|
win.show()
|
||||||
|
# Force window onto primary screen
|
||||||
|
primary = app.primaryScreen()
|
||||||
|
geo = primary.availableGeometry()
|
||||||
|
win.move(geo.x() + (geo.width() - win.width()) // 2,
|
||||||
|
geo.y() + (geo.height() - win.height()) // 2)
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ FORMULA (Composite Score):
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import html
|
import html
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
"""
|
|
||||||
pytest configuration. Registers custom markers so -m "not network" works
|
|
||||||
without warnings.
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
|
||||||
config.addinivalue_line(
|
|
||||||
"markers", "network: marks tests that require internet access (deselect with -m 'not network')"
|
|
||||||
)
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
"""
|
|
||||||
GUI smoke tests — headless PySide6 (QT_QPA_PLATFORM=offscreen).
|
|
||||||
Verifies that all modules import and core widgets instantiate without crashing.
|
|
||||||
No network calls, no DB, no real data.
|
|
||||||
|
|
||||||
Run automatically via PostToolUse hook on every edit to screener_gui.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
DIST_DIR = PROJECT_ROOT / "dist"
|
|
||||||
PYTHON = sys.executable
|
|
||||||
|
|
||||||
|
|
||||||
def _run(code: str) -> subprocess.CompletedProcess:
|
|
||||||
"""Run Python code in a subprocess with offscreen Qt platform."""
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
|
||||||
return subprocess.run(
|
|
||||||
[PYTHON, "-c", code],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(DIST_DIR),
|
|
||||||
env=env,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stock_screener_imports():
|
|
||||||
"""stock_screener.py imports and exposes expected symbols."""
|
|
||||||
result = _run("""
|
|
||||||
import stock_screener as ss
|
|
||||||
required = [
|
|
||||||
'compute_value_score', 'compute_quality_score',
|
|
||||||
'compute_growth_score', 'compute_profitability_score',
|
|
||||||
'compute_sentiment_score', 'score_stocks',
|
|
||||||
'STRATEGY_PRESETS', 'WEIGHTS',
|
|
||||||
]
|
|
||||||
missing = [s for s in required if not hasattr(ss, s)]
|
|
||||||
assert not missing, f"Missing symbols: {missing}"
|
|
||||||
print("SCREENER_OK")
|
|
||||||
""")
|
|
||||||
assert "SCREENER_OK" in result.stdout, f"stock_screener import failed:\n{result.stderr}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_screener_gui_imports():
|
|
||||||
"""screener_gui.py imports without error (PySide6, matplotlib, etc.)."""
|
|
||||||
result = _run("""
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
import sys
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
import screener_gui
|
|
||||||
print("GUI_IMPORT_OK")
|
|
||||||
""")
|
|
||||||
assert "GUI_IMPORT_OK" in result.stdout, (
|
|
||||||
f"screener_gui import failed:\n{result.stderr[-2000:]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stock_table_model():
|
|
||||||
"""StockTableModel can be created and populated via set_data."""
|
|
||||||
result = _run("""
|
|
||||||
import sys
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
from screener_gui import StockTableModel
|
|
||||||
model = StockTableModel()
|
|
||||||
assert model.rowCount() == 0
|
|
||||||
model.set_data([("AAPL", "Apple Inc", 82.5, "Technology")])
|
|
||||||
assert model.rowCount() == 1
|
|
||||||
assert model.columnCount() == 5
|
|
||||||
print("TABLE_MODEL_OK")
|
|
||||||
""")
|
|
||||||
assert "TABLE_MODEL_OK" in result.stdout, (
|
|
||||||
f"StockTableModel test failed:\n{result.stderr[-2000:]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_screener_app_instantiates():
|
|
||||||
"""ScreenerApp (main window) instantiates without crashing."""
|
|
||||||
result = _run("""
|
|
||||||
import sys
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
from screener_gui import ScreenerApp
|
|
||||||
win = ScreenerApp()
|
|
||||||
# Verify expected attributes exist
|
|
||||||
assert hasattr(win, '_screener_page')
|
|
||||||
assert hasattr(win, '_search_page')
|
|
||||||
assert hasattr(win, '_sidebar')
|
|
||||||
print("APP_OK")
|
|
||||||
""")
|
|
||||||
assert "APP_OK" in result.stdout, (
|
|
||||||
f"ScreenerApp instantiation failed:\n{result.stderr[-2000:]}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"""
|
|
||||||
Network integration tests — requires internet access and live API.
|
|
||||||
NOT run automatically on every edit (slow, rate-limited).
|
|
||||||
|
|
||||||
Run manually before pushing an update:
|
|
||||||
python -m pytest tests/test_network.py -v
|
|
||||||
|
|
||||||
Or run with the fast tests excluded:
|
|
||||||
python -m pytest tests/ -v -m "not network"
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "dist"))
|
|
||||||
|
|
||||||
API_BASE = "https://api.verimundsolutions.com"
|
|
||||||
EDGAR_BASE = "https://data.sec.gov"
|
|
||||||
EDGAR_UA = {"User-Agent": "Verimund Solutions support@verimundsolutions.com"}
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.network
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API health checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestAPIHealth:
|
|
||||||
def test_update_endpoint_alive(self):
|
|
||||||
"""GET /update should respond (not timeout, not 500)."""
|
|
||||||
r = requests.get(f"{API_BASE}/update", timeout=10)
|
|
||||||
assert r.status_code != 500, f"/update returned 500: {r.text}"
|
|
||||||
assert r.status_code in (200, 400, 401, 422), (
|
|
||||||
f"Unexpected status from /update: {r.status_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_versions_endpoint_alive(self):
|
|
||||||
"""GET /admin/versions returns 401 without auth (not 500 or timeout)."""
|
|
||||||
r = requests.get(f"{API_BASE}/admin/versions", timeout=10)
|
|
||||||
assert r.status_code in (200, 401, 403), (
|
|
||||||
f"/admin/versions returned unexpected status: {r.status_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_auth_endpoint_rejects_bad_request(self):
|
|
||||||
"""POST /auth with garbage data returns 400/422, not 500."""
|
|
||||||
r = requests.post(
|
|
||||||
f"{API_BASE}/auth",
|
|
||||||
json={"license_key": "bad", "hwid": "bad", "timestamp": "0", "signature": "bad"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
assert r.status_code in (400, 401, 422), (
|
|
||||||
f"/auth returned unexpected status: {r.status_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_500_errors_on_any_public_endpoint(self):
|
|
||||||
endpoints = ["/update", "/admin/versions"]
|
|
||||||
for ep in endpoints:
|
|
||||||
r = requests.get(f"{API_BASE}{ep}", timeout=10)
|
|
||||||
assert r.status_code != 500, f"{ep} returned 500: {r.text}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# yfinance field checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestYFinanceFields:
|
|
||||||
"""
|
|
||||||
Verify that yfinance still returns the fields our scoring depends on.
|
|
||||||
Uses AAPL as a known-stable ticker.
|
|
||||||
If these fail, yfinance has renamed a field we use — scoring will silently
|
|
||||||
return neutral 50 for affected metrics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture(scope="class")
|
|
||||||
def aapl_info(self):
|
|
||||||
import yfinance as yf
|
|
||||||
return yf.Ticker("AAPL").info
|
|
||||||
|
|
||||||
REQUIRED_FIELDS = [
|
|
||||||
"trailingPE",
|
|
||||||
"forwardPE",
|
|
||||||
"priceToBook",
|
|
||||||
"returnOnAssets",
|
|
||||||
"returnOnEquity",
|
|
||||||
"currentRatio",
|
|
||||||
"debtToEquity",
|
|
||||||
"revenueGrowth",
|
|
||||||
"earningsGrowth",
|
|
||||||
"shortPercentOfFloat",
|
|
||||||
"recommendationMean",
|
|
||||||
"targetMeanPrice",
|
|
||||||
"currentPrice",
|
|
||||||
"marketCap",
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_required_fields_present(self, aapl_info):
|
|
||||||
missing = [f for f in self.REQUIRED_FIELDS if f not in aapl_info]
|
|
||||||
assert not missing, (
|
|
||||||
f"yfinance dropped fields we depend on: {missing}\n"
|
|
||||||
f"Update scoring functions or field mappings."
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_de_is_percentage_scale(self, aapl_info):
|
|
||||||
"""
|
|
||||||
yfinance returns debtToEquity as percentage (e.g. 150 not 1.5).
|
|
||||||
Our code divides by 100. If this breaks, D/E scoring silently becomes wrong.
|
|
||||||
"""
|
|
||||||
de = aapl_info.get("debtToEquity")
|
|
||||||
if de is not None:
|
|
||||||
assert de > 1.0, (
|
|
||||||
f"debtToEquity={de} looks like a ratio, not percentage. "
|
|
||||||
f"Remove the /100.0 normalization in stock_screener.py line ~1356."
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_price_history_available(self):
|
|
||||||
import yfinance as yf
|
|
||||||
hist = yf.download("AAPL", period="1mo", progress=False, auto_adjust=True)
|
|
||||||
assert len(hist) > 10, "yfinance price history returned fewer than 10 bars for AAPL"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# EDGAR API checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEDGARAPI:
|
|
||||||
def test_frames_endpoint_alive(self):
|
|
||||||
"""EDGAR XBRL frames API returns valid data for a known concept/period."""
|
|
||||||
r = requests.get(
|
|
||||||
f"{EDGAR_BASE}/api/xbrl/frames/us-gaap/Revenues/USD/CY2023Q4.json",
|
|
||||||
headers=EDGAR_UA,
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, f"EDGAR frames API returned {r.status_code}"
|
|
||||||
data = r.json()
|
|
||||||
assert "data" in data, "EDGAR response missing 'data' key"
|
|
||||||
assert len(data["data"]) > 100, "EDGAR returned suspiciously few companies"
|
|
||||||
|
|
||||||
def test_company_facts_endpoint_alive(self):
|
|
||||||
"""EDGAR company facts for Apple (CIK 320193) returns valid data."""
|
|
||||||
r = requests.get(
|
|
||||||
f"{EDGAR_BASE}/api/xbrl/companyfacts/CIK0000320193.json",
|
|
||||||
headers=EDGAR_UA,
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, f"EDGAR company facts returned {r.status_code}"
|
|
||||||
data = r.json()
|
|
||||||
assert "facts" in data
|
|
||||||
assert "us-gaap" in data["facts"]
|
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
"""
|
|
||||||
Fast scoring unit tests — no DB, no network, no GUI.
|
|
||||||
Run automatically via PostToolUse hook on every edit to stock_screener.py.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Value score direction (cheap stocks score higher than expensive)
|
|
||||||
- Negative equity → low quality score (not high)
|
|
||||||
- Piotroski score feeds into quality correctly
|
|
||||||
- Sentiment confidence weighting (1 headline ≠ 10 headlines)
|
|
||||||
- All-None row doesn't crash any scoring function
|
|
||||||
- All scores are within 0–100 bounds
|
|
||||||
- Profile classification (early_stage, financial, high_growth, mature)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "dist"))
|
|
||||||
|
|
||||||
from stock_screener import (
|
|
||||||
compute_value_score,
|
|
||||||
compute_growth_score,
|
|
||||||
compute_quality_score,
|
|
||||||
compute_profitability_score,
|
|
||||||
compute_sentiment_score,
|
|
||||||
compute_analyst_score,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mock sector stats — median/MAD pairs per metric for "Technology" sector.
|
|
||||||
# Used so z-score functions return meaningful values instead of neutral 50.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
MOCK_SS = {
|
|
||||||
"Technology": {
|
|
||||||
"pe_trailing": (25.0, 15.0),
|
|
||||||
"pe_forward": (22.0, 12.0),
|
|
||||||
"pb_ratio": (5.0, 3.0),
|
|
||||||
"ev_ebitda": (18.0, 10.0),
|
|
||||||
"ev_revenue": (8.0, 5.0),
|
|
||||||
"revenue_growth": (0.12, 0.10),
|
|
||||||
"earnings_growth": (0.15, 0.12),
|
|
||||||
"eps_growth": (0.10, 0.15),
|
|
||||||
"roa": (0.08, 0.06),
|
|
||||||
"roe": (0.15, 0.10),
|
|
||||||
"debt_to_equity": (0.50, 0.40),
|
|
||||||
"current_ratio": (2.0, 0.8),
|
|
||||||
"fcf_yield": (0.04, 0.03),
|
|
||||||
"operating_margin": (0.15, 0.10),
|
|
||||||
"profit_margin": (0.12, 0.08),
|
|
||||||
"analyst_upside": (0.10, 0.15),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _base_row(**overrides):
|
|
||||||
"""Return a dict with all required fields set to safe defaults."""
|
|
||||||
defaults = dict(
|
|
||||||
sector="Technology",
|
|
||||||
pe_trailing=25.0, pe_forward=22.0,
|
|
||||||
pb_ratio=5.0, ev_ebitda=18.0, ev_revenue=8.0,
|
|
||||||
revenue_growth=0.12, earnings_growth=0.15,
|
|
||||||
eps_trailing=3.0, eps_forward=3.5,
|
|
||||||
roa=0.08, roe=0.15,
|
|
||||||
debt_to_equity=0.50, current_ratio=2.0,
|
|
||||||
piotroski_score=2,
|
|
||||||
fcf_yield=0.04, operating_margin=0.15,
|
|
||||||
profit_margin=0.12, accruals_ratio=-0.02,
|
|
||||||
news_sentiment=0.0, news_headline_count=5,
|
|
||||||
analyst_count=8, analyst_upside=0.10,
|
|
||||||
analyst_norm=50.0,
|
|
||||||
revenue=500_000_000, market_cap=5_000_000_000,
|
|
||||||
short_percent=0.03, volatility=0.25, beta=1.0,
|
|
||||||
)
|
|
||||||
defaults.update(overrides)
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def _df(*rows):
|
|
||||||
"""Build a DataFrame from _base_row dicts, indexed 0..n."""
|
|
||||||
return pd.DataFrame(list(rows)).reset_index(drop=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Value score tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestValueScore:
|
|
||||||
def test_cheap_beats_expensive(self):
|
|
||||||
df = _df(
|
|
||||||
_base_row(pe_trailing=8, pe_forward=7, pb_ratio=1.5, ev_ebitda=6), # cheap
|
|
||||||
_base_row(pe_trailing=120, pe_forward=100, pb_ratio=20, ev_ebitda=60), # expensive
|
|
||||||
)
|
|
||||||
scores = compute_value_score(df, MOCK_SS)
|
|
||||||
assert scores.iloc[0] > scores.iloc[1], (
|
|
||||||
f"Cheap stock ({scores.iloc[0]:.1f}) should beat expensive ({scores.iloc[1]:.1f})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_none_pe_doesnt_crash(self):
|
|
||||||
df = _df(_base_row(pe_trailing=None, pe_forward=None))
|
|
||||||
scores = compute_value_score(df, MOCK_SS)
|
|
||||||
assert 0 <= float(scores.iloc[0]) <= 100
|
|
||||||
|
|
||||||
def test_scores_in_bounds(self):
|
|
||||||
rows = [
|
|
||||||
_base_row(pe_trailing=5),
|
|
||||||
_base_row(pe_trailing=200),
|
|
||||||
_base_row(pe_trailing=None),
|
|
||||||
]
|
|
||||||
scores = compute_value_score(_df(*rows), MOCK_SS)
|
|
||||||
assert scores.between(0, 100).all(), f"Out-of-bounds scores: {scores.tolist()}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Quality score tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestQualityScore:
|
|
||||||
def test_negative_equity_scores_low(self):
|
|
||||||
"""Negative D/E (negative book equity) must score low, not high."""
|
|
||||||
df = _df(
|
|
||||||
_base_row(debt_to_equity=-2.0, roa=0.05), # negative equity
|
|
||||||
_base_row(debt_to_equity=0.3, roa=0.12), # healthy balance sheet
|
|
||||||
)
|
|
||||||
scores = compute_quality_score(df, MOCK_SS)
|
|
||||||
assert scores.iloc[0] < 40, (
|
|
||||||
f"Negative equity should score < 40, got {scores.iloc[0]:.1f}"
|
|
||||||
)
|
|
||||||
assert scores.iloc[1] > scores.iloc[0], (
|
|
||||||
f"Healthy balance sheet ({scores.iloc[1]:.1f}) should beat negative equity ({scores.iloc[0]:.1f})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_high_piotroski_beats_low(self):
|
|
||||||
df = _df(
|
|
||||||
_base_row(piotroski_score=4), # strong
|
|
||||||
_base_row(piotroski_score=0), # weak
|
|
||||||
)
|
|
||||||
scores = compute_quality_score(df, MOCK_SS)
|
|
||||||
assert scores.iloc[0] > scores.iloc[1], (
|
|
||||||
f"Piotroski 4 ({scores.iloc[0]:.1f}) should beat Piotroski 0 ({scores.iloc[1]:.1f})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_high_de_scores_lower(self):
|
|
||||||
df = _df(
|
|
||||||
_base_row(debt_to_equity=0.1), # low leverage
|
|
||||||
_base_row(debt_to_equity=5.0), # high leverage
|
|
||||||
)
|
|
||||||
scores = compute_quality_score(df, MOCK_SS)
|
|
||||||
assert scores.iloc[0] > scores.iloc[1], (
|
|
||||||
f"Low D/E ({scores.iloc[0]:.1f}) should beat high D/E ({scores.iloc[1]:.1f})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_scores_in_bounds(self):
|
|
||||||
rows = [
|
|
||||||
_base_row(debt_to_equity=-5.0),
|
|
||||||
_base_row(debt_to_equity=999.0),
|
|
||||||
_base_row(roa=None, roe=None),
|
|
||||||
]
|
|
||||||
scores = compute_quality_score(_df(*rows), MOCK_SS)
|
|
||||||
assert scores.between(0, 100).all(), f"Out-of-bounds scores: {scores.tolist()}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sentiment score tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSentimentScore:
|
|
||||||
def test_confidence_weighting(self):
|
|
||||||
"""1 headline should blend toward neutral vs 10 headlines."""
|
|
||||||
df = _df(
|
|
||||||
_base_row(news_sentiment=0.8, news_headline_count=1), # low confidence
|
|
||||||
_base_row(news_sentiment=0.8, news_headline_count=10), # high confidence
|
|
||||||
)
|
|
||||||
scores = compute_sentiment_score(df)
|
|
||||||
assert scores.iloc[1] > scores.iloc[0], (
|
|
||||||
f"10 headlines ({scores.iloc[1]:.1f}) should outscore 1 headline ({scores.iloc[0]:.1f}) "
|
|
||||||
f"for same positive sentiment"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_single_headline_blends_toward_neutral(self):
|
|
||||||
df = _df(_base_row(news_sentiment=0.8, news_headline_count=1))
|
|
||||||
score = float(compute_sentiment_score(df).iloc[0])
|
|
||||||
# 1 headline → confidence=0.2, so score should be closer to 50 than to 80+
|
|
||||||
assert score < 70, f"1-headline score should blend toward neutral, got {score:.1f}"
|
|
||||||
|
|
||||||
def test_negative_sentiment_scores_low(self):
|
|
||||||
df = _df(
|
|
||||||
_base_row(news_sentiment=-0.7, news_headline_count=5),
|
|
||||||
_base_row(news_sentiment=0.7, news_headline_count=5),
|
|
||||||
)
|
|
||||||
scores = compute_sentiment_score(df)
|
|
||||||
assert scores.iloc[0] < scores.iloc[1]
|
|
||||||
|
|
||||||
def test_scores_in_bounds(self):
|
|
||||||
rows = [
|
|
||||||
_base_row(news_sentiment=-1.0, news_headline_count=0),
|
|
||||||
_base_row(news_sentiment=1.0, news_headline_count=20),
|
|
||||||
_base_row(news_sentiment=None, news_headline_count=None),
|
|
||||||
]
|
|
||||||
scores = compute_sentiment_score(_df(*rows))
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Growth score tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGrowthScore:
|
|
||||||
def test_high_growth_beats_low(self):
|
|
||||||
df = _df(
|
|
||||||
_base_row(revenue_growth=0.50, earnings_growth=0.60),
|
|
||||||
_base_row(revenue_growth=-0.10, earnings_growth=-0.20),
|
|
||||||
)
|
|
||||||
scores = compute_growth_score(df, MOCK_SS)
|
|
||||||
assert scores.iloc[0] > scores.iloc[1]
|
|
||||||
|
|
||||||
def test_scores_in_bounds(self):
|
|
||||||
rows = [
|
|
||||||
_base_row(revenue_growth=None, earnings_growth=None),
|
|
||||||
_base_row(revenue_growth=5.0, earnings_growth=5.0),
|
|
||||||
_base_row(revenue_growth=-1.0, earnings_growth=-1.0),
|
|
||||||
]
|
|
||||||
scores = compute_growth_score(_df(*rows), MOCK_SS)
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Profitability score tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestProfitabilityScore:
|
|
||||||
def test_cash_backed_earnings_score_higher(self):
|
|
||||||
"""Negative accruals (cash-backed earnings) should beat high accruals."""
|
|
||||||
df = _df(
|
|
||||||
_base_row(accruals_ratio=-0.20, operating_margin=0.20), # cash-backed
|
|
||||||
_base_row(accruals_ratio=0.20, operating_margin=0.20), # accrual-heavy
|
|
||||||
)
|
|
||||||
scores = compute_profitability_score(df, MOCK_SS)
|
|
||||||
assert scores.iloc[0] > scores.iloc[1]
|
|
||||||
|
|
||||||
def test_scores_in_bounds(self):
|
|
||||||
rows = [
|
|
||||||
_base_row(fcf_yield=None, operating_margin=None, profit_margin=None),
|
|
||||||
_base_row(accruals_ratio=-0.5),
|
|
||||||
_base_row(accruals_ratio=0.5),
|
|
||||||
]
|
|
||||||
scores = compute_profitability_score(_df(*rows), MOCK_SS)
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# All-None row doesn't crash any scorer
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNoneRobustness:
|
|
||||||
NULL_ROW = dict(
|
|
||||||
sector=None, pe_trailing=None, pe_forward=None,
|
|
||||||
pb_ratio=None, ev_ebitda=None, ev_revenue=None,
|
|
||||||
revenue_growth=None, earnings_growth=None,
|
|
||||||
eps_trailing=None, eps_forward=None,
|
|
||||||
roa=None, roe=None, debt_to_equity=None,
|
|
||||||
current_ratio=None, piotroski_score=None,
|
|
||||||
fcf_yield=None, operating_margin=None,
|
|
||||||
profit_margin=None, accruals_ratio=None,
|
|
||||||
news_sentiment=None, news_headline_count=None,
|
|
||||||
analyst_count=None, analyst_upside=None, analyst_norm=None,
|
|
||||||
revenue=None, market_cap=None,
|
|
||||||
short_percent=None, volatility=None, beta=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_value_no_crash(self):
|
|
||||||
df = pd.DataFrame([self.NULL_ROW])
|
|
||||||
scores = compute_value_score(df, {})
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
|
|
||||||
def test_quality_no_crash(self):
|
|
||||||
df = pd.DataFrame([self.NULL_ROW])
|
|
||||||
scores = compute_quality_score(df, {})
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
|
|
||||||
def test_growth_no_crash(self):
|
|
||||||
df = pd.DataFrame([self.NULL_ROW])
|
|
||||||
scores = compute_growth_score(df, {})
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
|
|
||||||
def test_profitability_no_crash(self):
|
|
||||||
df = pd.DataFrame([self.NULL_ROW])
|
|
||||||
scores = compute_profitability_score(df, {})
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
|
|
||||||
def test_sentiment_no_crash(self):
|
|
||||||
df = pd.DataFrame([self.NULL_ROW])
|
|
||||||
scores = compute_sentiment_score(df)
|
|
||||||
assert scores.between(0, 100).all()
|
|
||||||
1245
ui_mockup.html
1245
ui_mockup.html
File diff suppressed because it is too large
Load Diff
|
|
@ -19,7 +19,7 @@ import requests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Current app version — kept in sync by push_update.py before each build.
|
# Current app version — kept in sync by push_update.py before each build.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
APP_VERSION = "1.4.8"
|
APP_VERSION = "1.4.9"
|
||||||
|
|
||||||
|
|
||||||
def _exe_dir() -> str:
|
def _exe_dir() -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue