Fix missing os import in stock_screener; force app to open on primary monitor

This commit is contained in:
Nolan Kovacs 2026-04-08 18:37:38 -04:00
parent 78f3a25022
commit df032593bd
23 changed files with 46 additions and 3418 deletions

View File

@ -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"
}
]
}
]
}
}
{}

View File

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

BIN
.gitignore vendored

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

47
dist/screener_gui.py vendored
View File

@ -2055,6 +2055,12 @@ class ScreenerPage(QWidget):
self._df_raw = df_raw
self._df = df_scored
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._status_lbl.setText(f" Done — {total} stocks scored.")
self._apply_filters()
@ -2083,15 +2089,41 @@ class ScreenerPage(QWidget):
self._progress.hide()
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:
self._apply_filters_inner()
_log4("inner completed OK")
except Exception as exc:
import traceback
QMessageBox.critical(self, "Display Error",
f"Failed to display results:\n\n{traceback.format_exc()}")
_log4(f"EXCEPTION: {_tb4.format_exc()}")
try:
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):
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
rule = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText())
if rule:
@ -2152,7 +2184,9 @@ class ScreenerPage(QWidget):
elif sort_by == "Upside ↓" and "analyst_upside" in df.columns:
df = df.sort_values("analyst_upside", ascending=False, na_position="last")
_log3(f"calling populate with {len(df)} rows")
self._table.populate(df)
_log3("populate done")
n = len(df)
self._status_lbl.setText(f" Showing {n} stocks. Click a ticker to see details.")
@ -2925,6 +2959,11 @@ def main():
app.setStyleSheet(_QSS)
win = ScreenerApp()
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())

View File

@ -27,6 +27,7 @@ FORMULA (Composite Score):
import argparse
import html
import os
import random
import re
import sys

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')"
)

View File

@ -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:]}"
)

View File

@ -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"]

View File

@ -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 0100 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()

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@ import requests
# ---------------------------------------------------------------------------
# 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: