diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fe33c0f..0967ef4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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" - } - ] - } - ] - } -} +{} diff --git a/.github/workflows/cache_refresh.yml b/.github/workflows/cache_refresh.yml deleted file mode 100644 index 690efea..0000000 --- a/.github/workflows/cache_refresh.yml +++ /dev/null @@ -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." diff --git a/.gitignore b/.gitignore index f591f23..6b4404f 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/build.bat b/build.bat deleted file mode 100644 index c11cfe5..0000000 --- a/build.bat +++ /dev/null @@ -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 diff --git a/deploy_beta.bat b/deploy_beta.bat deleted file mode 100644 index a8c989b..0000000 --- a/deploy_beta.bat +++ /dev/null @@ -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 diff --git a/deploy_stable.bat b/deploy_stable.bat deleted file mode 100644 index d4510a6..0000000 --- a/deploy_stable.bat +++ /dev/null @@ -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 diff --git a/discord_bot/.env.example b/discord_bot/.env.example deleted file mode 100644 index a4d49a3..0000000 --- a/discord_bot/.env.example +++ /dev/null @@ -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 diff --git a/discord_bot/bot.py b/discord_bot/bot.py deleted file mode 100644 index f5c09dd..0000000 --- a/discord_bot/bot.py +++ /dev/null @@ -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()) diff --git a/discord_bot/requirements.txt b/discord_bot/requirements.txt deleted file mode 100644 index 22991a4..0000000 --- a/discord_bot/requirements.txt +++ /dev/null @@ -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 diff --git a/dist/screener_gui.py b/dist/screener_gui.py index 020cb02..96eadb2 100644 --- a/dist/screener_gui.py +++ b/dist/screener_gui.py @@ -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()) diff --git a/dist/stock_screener.py b/dist/stock_screener.py index 30c2949..6c47852 100644 --- a/dist/stock_screener.py +++ b/dist/stock_screener.py @@ -27,6 +27,7 @@ FORMULA (Composite Score): import argparse import html +import os import random import re import sys diff --git a/dist/update_error.log b/dist/update_error.log deleted file mode 100644 index b9497ec..0000000 --- a/dist/update_error.log +++ /dev/null @@ -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 diff --git a/health_monitor.py b/health_monitor.py deleted file mode 100644 index 449f66c..0000000 --- a/health_monitor.py +++ /dev/null @@ -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() diff --git a/nginx_default b/nginx_default deleted file mode 100644 index c8e6051..0000000 --- a/nginx_default +++ /dev/null @@ -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; -} diff --git a/pyarmor.bug.log b/pyarmor.bug.log deleted file mode 100644 index 1a9cc56..0000000 --- a/pyarmor.bug.log +++ /dev/null @@ -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 "", 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 "", line 553, in process - File "", line 559, in coserialize - File "", line 609, in _build_ast_body -RuntimeError: out of license - - diff --git a/scripts/backup.sh b/scripts/backup.sh deleted file mode 100644 index afcf630..0000000 --- a/scripts/backup.sh +++ /dev/null @@ -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" diff --git a/supabase_schema.sql b/supabase_schema.sql deleted file mode 100644 index 1eda088..0000000 --- a/supabase_schema.sql +++ /dev/null @@ -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; diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 868c7c8..0000000 --- a/tests/conftest.py +++ /dev/null @@ -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')" - ) diff --git a/tests/test_gui.py b/tests/test_gui.py deleted file mode 100644 index dea5e29..0000000 --- a/tests/test_gui.py +++ /dev/null @@ -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:]}" - ) diff --git a/tests/test_network.py b/tests/test_network.py deleted file mode 100644 index 666313e..0000000 --- a/tests/test_network.py +++ /dev/null @@ -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"] diff --git a/tests/test_scoring.py b/tests/test_scoring.py deleted file mode 100644 index 1679098..0000000 --- a/tests/test_scoring.py +++ /dev/null @@ -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() diff --git a/ui_mockup.html b/ui_mockup.html deleted file mode 100644 index ea6cd11..0000000 --- a/ui_mockup.html +++ /dev/null @@ -1,1245 +0,0 @@ - - - - - -Verimund — UI Mockup - - - - - -
-
-
-
-
-
- - -
-
-
-
-
-
- -
- - - - - -
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
⚙  Filters
- Last run: 4 mins ago -
-
-
- -
- - -
-
- Results - 87 stocks -
-
- - - -
#TickerNameScore
-
-
- - -
-
← Select a stock to view details
- -
-
- -
- Showing 87 of 500 stocks · Click a ticker to see details - Cache: 4,200 stocks · updated 4 mins ago -
-
- - - - - -
-
- - -
- - -
-
- Insider Transactions -
-
Today
1W
-
2W
1M
-
-
All
Buys
Sales
- - 24 transactions loaded -
-
- - - - - - - - - - - - - - - - - -
DateTickerCompanyInsiderTitleTypeSharesPriceValueAfter
Mar 28NVDANvidia CorpJensen HuangCEOSale120,000$878.40$105.4M887,432
Mar 27MSFTMicrosoft CorpSatya NadellaCEOSale14,500$424.10$6.2M1,234,000
Mar 27METAMeta PlatformsMark ZuckerbergCEOSale85,000$513.20$43.6M342,110,009
Mar 26JPMJPMorgan ChaseDaniel PintoPresidentBuy22,000$196.30$4.3M214,500
Mar 26AMZNAmazon.comAndy JassyCEOAward62,500$183.22$11.5M1,840,000
Mar 25GOOGLAlphabet IncSundar PichaiCEOSale41,200$168.40$6.9M928,100
Mar 25XOMExxon MobilDarren WoodsCEOBuy15,000$112.80$1.7M473,200
Mar 24UNHUnitedHealth GroupAndrew WittyCEOBuy5,000$493.10$2.5M88,400
Mar 24TSLATesla IncElon MuskCEOAward304,000$174.50$53.1M411,062,512
Mar 23AAPLApple IncLuca MaestriCFOSale18,300$211.90$3.9M106,200
-
-
- 24 transactions · last 7 days · click any row to view SEC filing - Data: SEC EDGAR Form 4 -
-
- - -
-
- Hedge Fund Holdings (13F-HR) -
-
30D
60D
-
90D
180D
-
-
All
$1M+
-
$10M+
$100M+
- - 156 holdings loaded -
-
- - - - - - - - - - - - - - - - - -
FiledFundCompanySharesValueClassOpt
Mar 15Pershing Square CapitalAlphabet Inc (GOOGL)18,500,000$3.11BA
Mar 15Bridgewater AssociatesApple Inc (AAPL)12,400,000$2.65B
Mar 14Tiger Global MgmtMicrosoft Corp (MSFT)4,200,000$1.78B
Mar 14Third Point LLCNvidia Corp (NVDA)1,850,000$1.62B
Mar 12Renaissance TechnologiesMeta Platforms (META)3,100,000$1.59BA
Mar 12Baupost GroupAmazon.com (AMZN)7,800,000$1.43BCall
Mar 11D.E. Shaw GroupTesla Inc (TSLA)9,200,000$1.31B
Mar 10Citadel AdvisorsJPMorgan Chase (JPM)6,400,000$1.26B
Mar 10Appaloosa MgmtNvidia Corp (NVDA)1,100,000$964MPut
Mar 8Lone Pine CapitalUnitedHealth Group (UNH)1,920,000$947M
-
-
- 156 holdings · last 90 days · click any row to view SEC 13F filing - Data: SEC EDGAR 13F-HR -
-
-
- -
-
- - - - diff --git a/updater.py b/updater.py index 6f715ef..2f8ef99 100644 --- a/updater.py +++ b/updater.py @@ -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: