feat: add daily discovery workflow, recommendation history, and scanner improvements
- Add GitHub Actions workflow for daily discovery (8:30 AM ET, weekdays) - Add headless run_daily_discovery.py script for scheduling - Expand options_flow scanner to use tickers.txt with parallel execution - Add recommendation history section to Performance page with filters and charts - Fix strategy name normalization (momentum/Momentum/Momentum-Hype → momentum) - Fix strategy metrics to count all recs, not just evaluated ones - Add error handling to Streamlit page rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ab8d174990
commit
f4aceef857
|
|
@ -0,0 +1,120 @@
|
||||||
|
name: Daily Discovery
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 8:30 AM ET (13:30 UTC) on weekdays
|
||||||
|
- cron: "30 13 * * 1-5"
|
||||||
|
workflow_dispatch:
|
||||||
|
# Manual trigger with optional overrides
|
||||||
|
inputs:
|
||||||
|
date:
|
||||||
|
description: "Analysis date (YYYY-MM-DD, blank = today)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
provider:
|
||||||
|
description: "LLM provider"
|
||||||
|
required: false
|
||||||
|
default: "google"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- google
|
||||||
|
- openai
|
||||||
|
- anthropic
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.10"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
discovery:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: TradingAgent
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
- name: Determine analysis date
|
||||||
|
id: date
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ github.event.inputs.date }}" ]; then
|
||||||
|
echo "analysis_date=${{ github.event.inputs.date }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "analysis_date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run discovery pipeline
|
||||||
|
env:
|
||||||
|
# LLM keys (set whichever provider you use)
|
||||||
|
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# Data source keys
|
||||||
|
FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
|
||||||
|
ALPHA_VANTAGE_API_KEY: ${{ secrets.ALPHA_VANTAGE_API_KEY }}
|
||||||
|
FMP_API_KEY: ${{ secrets.FMP_API_KEY }}
|
||||||
|
REDDIT_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }}
|
||||||
|
REDDIT_CLIENT_SECRET: ${{ secrets.REDDIT_CLIENT_SECRET }}
|
||||||
|
TRADIER_API_KEY: ${{ secrets.TRADIER_API_KEY }}
|
||||||
|
run: |
|
||||||
|
python scripts/run_daily_discovery.py \
|
||||||
|
--date "${{ steps.date.outputs.analysis_date }}" \
|
||||||
|
--no-update-positions
|
||||||
|
|
||||||
|
- name: Commit recommendations to repo
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Stage new/updated recommendation files
|
||||||
|
git add data/recommendations/ || true
|
||||||
|
git add results/ || true
|
||||||
|
|
||||||
|
# Only commit if there are changes
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No new recommendations to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore: daily discovery ${{ steps.date.outputs.analysis_date }}"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update positions
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
|
||||||
|
run: |
|
||||||
|
python scripts/update_positions.py
|
||||||
|
|
||||||
|
- name: Commit position updates
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add data/recommendations/ || true
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No position updates"
|
||||||
|
else
|
||||||
|
git commit -m "chore: update positions ${{ steps.date.outputs.analysis_date }}"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload results as artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: discovery-${{ steps.date.outputs.analysis_date }}
|
||||||
|
path: |
|
||||||
|
data/recommendations/${{ steps.date.outputs.analysis_date }}*.json
|
||||||
|
results/discovery/${{ steps.date.outputs.analysis_date }}/
|
||||||
|
retention-days: 30
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,36 +1,36 @@
|
||||||
{
|
{
|
||||||
"total_recommendations": 185,
|
"total_recommendations": 200,
|
||||||
"by_strategy": {
|
"by_strategy": {
|
||||||
"momentum": {
|
"momentum": {
|
||||||
"count": 92,
|
"count": 92,
|
||||||
"wins_1d": 45,
|
"wins_1d": 47,
|
||||||
"losses_1d": 33,
|
"losses_1d": 45,
|
||||||
"wins_7d": 25,
|
"wins_7d": 25,
|
||||||
"losses_7d": 23,
|
"losses_7d": 23,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": 1.0,
|
"avg_return_1d": 0.32,
|
||||||
"avg_return_7d": 0.71,
|
"avg_return_7d": 0.71,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 57.7,
|
"win_rate_1d": 51.1,
|
||||||
"win_rate_7d": 52.1
|
"win_rate_7d": 52.1
|
||||||
},
|
},
|
||||||
"volume_accumulation": {
|
"volume_accumulation": {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
"wins_1d": 1,
|
"wins_1d": 1,
|
||||||
"losses_1d": 0,
|
"losses_1d": 1,
|
||||||
"wins_7d": 1,
|
"wins_7d": 1,
|
||||||
"losses_7d": 0,
|
"losses_7d": 0,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": 19.7,
|
"avg_return_1d": 7.41,
|
||||||
"avg_return_7d": 19.7,
|
"avg_return_7d": 19.7,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 100.0,
|
"win_rate_1d": 50.0,
|
||||||
"win_rate_7d": 100.0
|
"win_rate_7d": 100.0
|
||||||
},
|
},
|
||||||
"insider_buying": {
|
"insider_buying": {
|
||||||
"count": 21,
|
"count": 25,
|
||||||
"wins_1d": 15,
|
"wins_1d": 15,
|
||||||
"losses_1d": 6,
|
"losses_1d": 6,
|
||||||
"wins_7d": 10,
|
"wins_7d": 10,
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
"win_rate_7d": 66.7
|
"win_rate_7d": 66.7
|
||||||
},
|
},
|
||||||
"options_flow": {
|
"options_flow": {
|
||||||
"count": 5,
|
"count": 11,
|
||||||
"wins_1d": 4,
|
"wins_1d": 4,
|
||||||
"losses_1d": 1,
|
"losses_1d": 1,
|
||||||
"wins_7d": 0,
|
"wins_7d": 0,
|
||||||
|
|
@ -57,18 +57,18 @@
|
||||||
"win_rate_1d": 80.0
|
"win_rate_1d": 80.0
|
||||||
},
|
},
|
||||||
"earnings_calendar": {
|
"earnings_calendar": {
|
||||||
"count": 17,
|
"count": 20,
|
||||||
"wins_1d": 6,
|
"wins_1d": 6,
|
||||||
"losses_1d": 11,
|
"losses_1d": 11,
|
||||||
"wins_7d": 4,
|
"wins_7d": 5,
|
||||||
"losses_7d": 8,
|
"losses_7d": 8,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": -0.23,
|
"avg_return_1d": -0.23,
|
||||||
"avg_return_7d": 0.36,
|
"avg_return_7d": 2.79,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 35.3,
|
"win_rate_1d": 35.3,
|
||||||
"win_rate_7d": 33.3
|
"win_rate_7d": 38.5
|
||||||
},
|
},
|
||||||
"contrarian_value": {
|
"contrarian_value": {
|
||||||
"count": 6,
|
"count": 6,
|
||||||
|
|
@ -102,15 +102,15 @@
|
||||||
"count": 10,
|
"count": 10,
|
||||||
"wins_1d": 5,
|
"wins_1d": 5,
|
||||||
"losses_1d": 5,
|
"losses_1d": 5,
|
||||||
"wins_7d": 4,
|
"wins_7d": 6,
|
||||||
"losses_7d": 3,
|
"losses_7d": 4,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": 0.56,
|
"avg_return_1d": 0.56,
|
||||||
"avg_return_7d": 0.85,
|
"avg_return_7d": 2.15,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 50.0,
|
"win_rate_1d": 50.0,
|
||||||
"win_rate_7d": 57.1
|
"win_rate_7d": 60.0
|
||||||
},
|
},
|
||||||
"early_accumulation": {
|
"early_accumulation": {
|
||||||
"count": 1,
|
"count": 1,
|
||||||
|
|
@ -131,28 +131,28 @@
|
||||||
"wins_1d": 2,
|
"wins_1d": 2,
|
||||||
"losses_1d": 5,
|
"losses_1d": 5,
|
||||||
"wins_7d": 2,
|
"wins_7d": 2,
|
||||||
"losses_7d": 4,
|
"losses_7d": 5,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": -2.0,
|
"avg_return_1d": -2.0,
|
||||||
"avg_return_7d": -2.06,
|
"avg_return_7d": -1.94,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 28.6,
|
"win_rate_1d": 28.6,
|
||||||
"win_rate_7d": 33.3
|
"win_rate_7d": 28.6
|
||||||
},
|
},
|
||||||
"analyst_upgrade": {
|
"analyst_upgrade": {
|
||||||
"count": 8,
|
"count": 8,
|
||||||
"wins_1d": 6,
|
"wins_1d": 6,
|
||||||
"losses_1d": 2,
|
"losses_1d": 2,
|
||||||
"wins_7d": 4,
|
"wins_7d": 6,
|
||||||
"losses_7d": 2,
|
"losses_7d": 2,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": 1.32,
|
"avg_return_1d": 1.32,
|
||||||
"avg_return_7d": -0.1,
|
"avg_return_7d": 0.99,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 75.0,
|
"win_rate_1d": 75.0,
|
||||||
"win_rate_7d": 66.7
|
"win_rate_7d": 75.0
|
||||||
},
|
},
|
||||||
"ipo_opportunity": {
|
"ipo_opportunity": {
|
||||||
"count": 1,
|
"count": 1,
|
||||||
|
|
@ -201,78 +201,95 @@
|
||||||
"wins_1d": 1,
|
"wins_1d": 1,
|
||||||
"losses_1d": 1,
|
"losses_1d": 1,
|
||||||
"wins_7d": 0,
|
"wins_7d": 0,
|
||||||
"losses_7d": 0,
|
"losses_7d": 2,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": -3.38,
|
"avg_return_1d": -3.38,
|
||||||
"avg_return_7d": 0,
|
"avg_return_7d": -3.85,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 50.0
|
"win_rate_1d": 50.0,
|
||||||
|
"win_rate_7d": 0.0
|
||||||
},
|
},
|
||||||
"momentum_options": {
|
"momentum_options": {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
"wins_1d": 1,
|
"wins_1d": 1,
|
||||||
"losses_1d": 1,
|
"losses_1d": 1,
|
||||||
"wins_7d": 0,
|
"wins_7d": 2,
|
||||||
"losses_7d": 0,
|
"losses_7d": 0,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": 0.93,
|
"avg_return_1d": 0.93,
|
||||||
"avg_return_7d": 0,
|
"avg_return_7d": 2.27,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 50.0
|
"win_rate_1d": 50.0,
|
||||||
|
"win_rate_7d": 100.0
|
||||||
},
|
},
|
||||||
"oversold_reversal": {
|
"oversold_reversal": {
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"wins_1d": 0,
|
"wins_1d": 0,
|
||||||
"losses_1d": 1,
|
"losses_1d": 1,
|
||||||
"wins_7d": 0,
|
"wins_7d": 0,
|
||||||
"losses_7d": 0,
|
"losses_7d": 1,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": -5.11,
|
"avg_return_1d": -5.11,
|
||||||
"avg_return_7d": 0,
|
"avg_return_7d": -7.41,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 0.0
|
"win_rate_1d": 0.0,
|
||||||
|
"win_rate_7d": 0.0
|
||||||
},
|
},
|
||||||
"earnings_reversal": {
|
"earnings_reversal": {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
"wins_1d": 1,
|
"wins_1d": 1,
|
||||||
"losses_1d": 1,
|
"losses_1d": 1,
|
||||||
"wins_7d": 0,
|
"wins_7d": 1,
|
||||||
"losses_7d": 0,
|
"losses_7d": 1,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": -1.47,
|
"avg_return_1d": -1.47,
|
||||||
"avg_return_7d": 0,
|
"avg_return_7d": -2.82,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 50.0
|
"win_rate_1d": 50.0,
|
||||||
|
"win_rate_7d": 50.0
|
||||||
},
|
},
|
||||||
"earnings_growth": {
|
"earnings_growth": {
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"wins_1d": 1,
|
"wins_1d": 1,
|
||||||
"losses_1d": 0,
|
"losses_1d": 0,
|
||||||
"wins_7d": 0,
|
"wins_7d": 0,
|
||||||
"losses_7d": 0,
|
"losses_7d": 1,
|
||||||
"wins_30d": 0,
|
"wins_30d": 0,
|
||||||
"losses_30d": 0,
|
"losses_30d": 0,
|
||||||
"avg_return_1d": 1.36,
|
"avg_return_1d": 1.36,
|
||||||
"avg_return_7d": 0,
|
"avg_return_7d": -1.94,
|
||||||
"avg_return_30d": 0,
|
"avg_return_30d": 0,
|
||||||
"win_rate_1d": 100.0
|
"win_rate_1d": 100.0,
|
||||||
|
"win_rate_7d": 0.0
|
||||||
|
},
|
||||||
|
"reddit_dd": {
|
||||||
|
"count": 2,
|
||||||
|
"wins_1d": 0,
|
||||||
|
"losses_1d": 0,
|
||||||
|
"wins_7d": 0,
|
||||||
|
"losses_7d": 0,
|
||||||
|
"wins_30d": 0,
|
||||||
|
"losses_30d": 0,
|
||||||
|
"avg_return_1d": 0,
|
||||||
|
"avg_return_7d": 0,
|
||||||
|
"avg_return_30d": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_1d": {
|
"overall_1d": {
|
||||||
"count": 170,
|
"count": 185,
|
||||||
"wins": 94,
|
"wins": 96,
|
||||||
"avg_return": 0.26,
|
"avg_return": -0.05,
|
||||||
"win_rate": 55.3
|
"win_rate": 51.9
|
||||||
},
|
},
|
||||||
"overall_7d": {
|
"overall_7d": {
|
||||||
"count": 110,
|
"count": 125,
|
||||||
"wins": 56,
|
"wins": 64,
|
||||||
"avg_return": -0.18,
|
"avg_return": 0.13,
|
||||||
"win_rate": 50.9
|
"win_rate": 51.2
|
||||||
},
|
},
|
||||||
"overall_30d": {
|
"overall_30d": {
|
||||||
"count": 0,
|
"count": 0,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Daily Discovery Runner — non-interactive script for cron/launchd scheduling.
|
||||||
|
|
||||||
|
Runs the full discovery pipeline (scan → filter → rank), saves recommendations,
|
||||||
|
and updates position tracking. Designed to run before market open (~8:30 AM ET).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/run_daily_discovery.py # Uses defaults
|
||||||
|
python scripts/run_daily_discovery.py --date 2026-02-12 # Specific date
|
||||||
|
python scripts/run_daily_discovery.py --provider google # Override LLM provider
|
||||||
|
|
||||||
|
Scheduling (macOS launchd):
|
||||||
|
See the companion plist at scripts/com.tradingagents.discovery.plist
|
||||||
|
|
||||||
|
Scheduling (cron):
|
||||||
|
30 13 * * 1-5 cd /path/to/TradingAgents && .venv/bin/python scripts/run_daily_discovery.py >> logs/discovery_cron.log 2>&1
|
||||||
|
(13:30 UTC = 8:30 AM ET, weekdays only)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure project root is on sys.path
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.chdir(ROOT)
|
||||||
|
|
||||||
|
from tradingagents.dataflows.config import set_config
|
||||||
|
from tradingagents.default_config import DEFAULT_CONFIG
|
||||||
|
from tradingagents.graph.discovery_graph import DiscoveryGraph
|
||||||
|
from tradingagents.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("daily_discovery")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Run daily discovery pipeline")
|
||||||
|
parser.add_argument(
|
||||||
|
"--date",
|
||||||
|
default=datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
help="Analysis date (YYYY-MM-DD), defaults to today",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--provider",
|
||||||
|
default=None,
|
||||||
|
help="LLM provider override (openai, google, anthropic)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--shallow-model",
|
||||||
|
default=None,
|
||||||
|
help="Override quick_think_llm model name",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--deep-model",
|
||||||
|
default=None,
|
||||||
|
help="Override deep_think_llm model name",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--update-positions",
|
||||||
|
action="store_true",
|
||||||
|
default=True,
|
||||||
|
help="Update position tracking after discovery (default: True)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-update-positions",
|
||||||
|
action="store_false",
|
||||||
|
dest="update_positions",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def run_discovery(args):
|
||||||
|
"""Run the discovery pipeline with the given arguments."""
|
||||||
|
config = DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
# Apply overrides
|
||||||
|
if args.provider:
|
||||||
|
config["llm_provider"] = args.provider.lower()
|
||||||
|
if args.shallow_model:
|
||||||
|
config["quick_think_llm"] = args.shallow_model
|
||||||
|
if args.deep_model:
|
||||||
|
config["deep_think_llm"] = args.deep_model
|
||||||
|
|
||||||
|
set_config(config)
|
||||||
|
|
||||||
|
# Create results directory
|
||||||
|
run_timestamp = datetime.now().strftime("%H_%M_%S")
|
||||||
|
results_dir = Path(config["results_dir"]) / "discovery" / args.date / f"run_{run_timestamp}"
|
||||||
|
results_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config["discovery_run_dir"] = str(results_dir)
|
||||||
|
|
||||||
|
logger.info(f"Starting daily discovery for {args.date}")
|
||||||
|
logger.info(
|
||||||
|
f"Provider: {config['llm_provider']} | "
|
||||||
|
f"Shallow: {config['quick_think_llm']} | "
|
||||||
|
f"Deep: {config['deep_think_llm']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run discovery
|
||||||
|
graph = DiscoveryGraph(config=config)
|
||||||
|
result = graph.run(trade_date=args.date)
|
||||||
|
|
||||||
|
final_ranking = result.get("final_ranking", "No ranking available")
|
||||||
|
logger.info(f"Discovery complete. Results saved to {results_dir}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def update_positions():
|
||||||
|
"""Run position updates after discovery."""
|
||||||
|
try:
|
||||||
|
from scripts.update_positions import main as update_main
|
||||||
|
|
||||||
|
logger.info("Updating position tracking...")
|
||||||
|
update_main()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Position update failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f"DAILY DISCOVERY RUN — {datetime.now().isoformat()}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_discovery(args)
|
||||||
|
|
||||||
|
if args.update_positions:
|
||||||
|
update_positions()
|
||||||
|
|
||||||
|
logger.info("Daily discovery completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Discovery failed: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"""Unusual options activity scanner."""
|
"""Unusual options activity scanner.
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
Scans a ticker universe (loaded from data/tickers.txt by default) for
|
||||||
|
unusual options volume relative to open interest. Uses ThreadPoolExecutor
|
||||||
|
for parallel chain fetching so large universes remain practical.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from tradingagents.dataflows.discovery.scanner_registry import SCANNER_REGISTRY, BaseScanner
|
from tradingagents.dataflows.discovery.scanner_registry import SCANNER_REGISTRY, BaseScanner
|
||||||
from tradingagents.dataflows.y_finance import get_option_chain, get_ticker_options
|
from tradingagents.dataflows.y_finance import get_option_chain, get_ticker_options
|
||||||
|
|
@ -8,9 +14,30 @@ from tradingagents.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_TICKER_FILE = "data/tickers.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tickers_from_file(path: str) -> List[str]:
|
||||||
|
"""Load ticker symbols from a text file (one per line, # comments allowed)."""
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
tickers = [
|
||||||
|
line.strip().upper()
|
||||||
|
for line in f
|
||||||
|
if line.strip() and not line.strip().startswith("#")
|
||||||
|
]
|
||||||
|
if tickers:
|
||||||
|
logger.info(f"Options scanner: loaded {len(tickers)} tickers from {path}")
|
||||||
|
return tickers
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning(f"Ticker file not found: {path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load ticker file {path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class OptionsFlowScanner(BaseScanner):
|
class OptionsFlowScanner(BaseScanner):
|
||||||
"""Scan for unusual options activity."""
|
"""Scan for unusual options activity across a ticker universe."""
|
||||||
|
|
||||||
name = "options_flow"
|
name = "options_flow"
|
||||||
pipeline = "edge"
|
pipeline = "edge"
|
||||||
|
|
@ -20,32 +47,55 @@ class OptionsFlowScanner(BaseScanner):
|
||||||
self.min_volume_oi_ratio = self.scanner_config.get("unusual_volume_multiple", 2.0)
|
self.min_volume_oi_ratio = self.scanner_config.get("unusual_volume_multiple", 2.0)
|
||||||
self.min_volume = self.scanner_config.get("min_volume", 1000)
|
self.min_volume = self.scanner_config.get("min_volume", 1000)
|
||||||
self.min_premium = self.scanner_config.get("min_premium", 25000)
|
self.min_premium = self.scanner_config.get("min_premium", 25000)
|
||||||
self.ticker_universe = self.scanner_config.get(
|
self.max_tickers = self.scanner_config.get("max_tickers", 150)
|
||||||
"ticker_universe", ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "AMD", "TSLA"]
|
self.max_workers = self.scanner_config.get("max_workers", 8)
|
||||||
)
|
|
||||||
|
# Load universe: explicit list > ticker_file > default file
|
||||||
|
if "ticker_universe" in self.scanner_config:
|
||||||
|
self.ticker_universe = self.scanner_config["ticker_universe"]
|
||||||
|
else:
|
||||||
|
ticker_file = self.scanner_config.get(
|
||||||
|
"ticker_file",
|
||||||
|
config.get("tickers_file", DEFAULT_TICKER_FILE),
|
||||||
|
)
|
||||||
|
self.ticker_universe = _load_tickers_from_file(ticker_file)
|
||||||
|
if not self.ticker_universe:
|
||||||
|
logger.warning("No tickers loaded — options scanner will be empty")
|
||||||
|
|
||||||
def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
|
def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
if not self.is_enabled():
|
if not self.is_enabled():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
logger.info("Scanning unusual options activity...")
|
universe = self.ticker_universe[: self.max_tickers]
|
||||||
|
logger.info(
|
||||||
|
f"Scanning {len(universe)} tickers for unusual options activity "
|
||||||
|
f"({self.max_workers} workers)..."
|
||||||
|
)
|
||||||
|
|
||||||
candidates = []
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for ticker in self.ticker_universe[:20]: # Limit for speed
|
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
|
||||||
try:
|
futures = {
|
||||||
unusual = self._analyze_ticker_options(ticker)
|
pool.submit(self._analyze_ticker_options, ticker): ticker
|
||||||
if unusual:
|
for ticker in universe
|
||||||
candidates.append(unusual)
|
}
|
||||||
if len(candidates) >= self.limit:
|
for future in as_completed(futures):
|
||||||
break
|
try:
|
||||||
except Exception:
|
result = future.result()
|
||||||
continue
|
if result:
|
||||||
|
candidates.append(result)
|
||||||
|
if len(candidates) >= self.limit:
|
||||||
|
# Cancel remaining futures
|
||||||
|
for f in futures:
|
||||||
|
f.cancel()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(f"Found {len(candidates)} unusual options flows")
|
logger.info(f"Found {len(candidates)} unusual options flows")
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
def _analyze_ticker_options(self, ticker: str) -> Dict[str, Any]:
|
def _analyze_ticker_options(self, ticker: str) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
expirations = get_ticker_options(ticker)
|
expirations = get_ticker_options(ticker)
|
||||||
if not expirations:
|
if not expirations:
|
||||||
|
|
@ -58,8 +108,8 @@ class OptionsFlowScanner(BaseScanner):
|
||||||
# Find unusual strikes
|
# Find unusual strikes
|
||||||
unusual_strikes = []
|
unusual_strikes = []
|
||||||
for _, opt in calls.iterrows():
|
for _, opt in calls.iterrows():
|
||||||
vol = opt.get("volume", 0)
|
vol = opt.get("volume", 0) or 0
|
||||||
oi = opt.get("openInterest", 0)
|
oi = opt.get("openInterest", 0) or 0
|
||||||
if oi > 0 and vol > self.min_volume and (vol / oi) >= self.min_volume_oi_ratio:
|
if oi > 0 and vol > self.min_volume and (vol / oi) >= self.min_volume_oi_ratio:
|
||||||
unusual_strikes.append(
|
unusual_strikes.append(
|
||||||
{"type": "call", "strike": opt["strike"], "volume": vol, "oi": oi}
|
{"type": "call", "strike": opt["strike"], "volume": vol, "oi": oi}
|
||||||
|
|
@ -78,7 +128,10 @@ class OptionsFlowScanner(BaseScanner):
|
||||||
return {
|
return {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"source": self.name,
|
"source": self.name,
|
||||||
"context": f"Unusual options: {len(unusual_strikes)} strikes, P/C={pc_ratio:.2f} ({sentiment})",
|
"context": (
|
||||||
|
f"Unusual options: {len(unusual_strikes)} strikes, "
|
||||||
|
f"P/C={pc_ratio:.2f} ({sentiment})"
|
||||||
|
),
|
||||||
"priority": "high" if sentiment == "bullish" else "medium",
|
"priority": "high" if sentiment == "bullish" else "medium",
|
||||||
"strategy": "options_flow",
|
"strategy": "options_flow",
|
||||||
"put_call_ratio": round(pc_ratio, 2),
|
"put_call_ratio": round(pc_ratio, 2),
|
||||||
|
|
|
||||||
|
|
@ -137,28 +137,10 @@ DEFAULT_CONFIG = {
|
||||||
"unusual_volume_multiple": 2.0, # Min volume/OI ratio for unusual activity
|
"unusual_volume_multiple": 2.0, # Min volume/OI ratio for unusual activity
|
||||||
"min_premium": 25000, # Minimum premium ($) to filter noise
|
"min_premium": 25000, # Minimum premium ($) to filter noise
|
||||||
"min_volume": 1000, # Minimum option volume to consider
|
"min_volume": 1000, # Minimum option volume to consider
|
||||||
"ticker_universe": [
|
# ticker_file: path to ticker list (defaults to tickers_file from root config)
|
||||||
"AAPL",
|
# ticker_universe: explicit list overrides ticker_file if set
|
||||||
"MSFT",
|
"max_tickers": 150, # Max tickers to scan (from start of file)
|
||||||
"GOOGL",
|
"max_workers": 8, # Parallel option chain fetch threads
|
||||||
"AMZN",
|
|
||||||
"META",
|
|
||||||
"NVDA",
|
|
||||||
"AMD",
|
|
||||||
"TSLA",
|
|
||||||
"TSMC",
|
|
||||||
"ASML",
|
|
||||||
"AVGO",
|
|
||||||
"ORCL",
|
|
||||||
"CRM",
|
|
||||||
"ADBE",
|
|
||||||
"INTC",
|
|
||||||
"QCOM",
|
|
||||||
"TXN",
|
|
||||||
"AMAT",
|
|
||||||
"LRCX",
|
|
||||||
"KLAC",
|
|
||||||
], # Top 20 liquid options
|
|
||||||
},
|
},
|
||||||
"congress_trades": {
|
"congress_trades": {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
|
|
@ -178,7 +160,7 @@ DEFAULT_CONFIG = {
|
||||||
"compression_min_volume_ratio": 1.3, # Min volume ratio for compression
|
"compression_min_volume_ratio": 1.3, # Min volume ratio for compression
|
||||||
},
|
},
|
||||||
"market_movers": {
|
"market_movers": {
|
||||||
"enabled": True,
|
"enabled": False,
|
||||||
"pipeline": "momentum",
|
"pipeline": "momentum",
|
||||||
"limit": 10,
|
"limit": 10,
|
||||||
},
|
},
|
||||||
|
|
@ -195,7 +177,7 @@ DEFAULT_CONFIG = {
|
||||||
"news_lookback_days": 0.5, # Days of news history to analyze
|
"news_lookback_days": 0.5, # Days of news history to analyze
|
||||||
},
|
},
|
||||||
"analyst_upgrade": {
|
"analyst_upgrade": {
|
||||||
"enabled": False,
|
"enabled": True,
|
||||||
"pipeline": "news",
|
"pipeline": "news",
|
||||||
"limit": 5,
|
"limit": 5,
|
||||||
"lookback_days": 1, # Days to look back for rating changes
|
"lookback_days": 1, # Days to look back for rating changes
|
||||||
|
|
@ -221,7 +203,7 @@ DEFAULT_CONFIG = {
|
||||||
"min_market_cap": 0, # Minimum market cap in billions (0 = no filter)
|
"min_market_cap": 0, # Minimum market cap in billions (0 = no filter)
|
||||||
},
|
},
|
||||||
"short_squeeze": {
|
"short_squeeze": {
|
||||||
"enabled": False,
|
"enabled": True,
|
||||||
"pipeline": "events",
|
"pipeline": "events",
|
||||||
"limit": 5,
|
"limit": 5,
|
||||||
"min_short_interest_pct": 15.0, # Minimum short interest %
|
"min_short_interest_pct": 15.0, # Minimum short interest %
|
||||||
|
|
|
||||||
|
|
@ -119,10 +119,16 @@ def route_page(page):
|
||||||
"Config": pages.settings,
|
"Config": pages.settings,
|
||||||
}
|
}
|
||||||
module = page_map.get(page)
|
module = page_map.get(page)
|
||||||
if module:
|
if module is None:
|
||||||
module.render()
|
|
||||||
else:
|
|
||||||
st.error(f"Unknown page: {page}")
|
st.error(f"Unknown page: {page}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
module.render()
|
||||||
|
except Exception as exc:
|
||||||
|
st.error(f"Error rendering {page}: {exc}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
st.code(traceback.format_exc(), language="python")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -5,29 +5,38 @@ This package contains all page modules that can be rendered in the dashboard.
|
||||||
Each module should have a render() function that displays the page content.
|
Each module should have a render() function that displays the page content.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tradingagents.ui.pages import home
|
from tradingagents.ui.pages import home
|
||||||
except ImportError:
|
except Exception as _e:
|
||||||
|
_logger.error("Failed to import home page: %s", _e, exc_info=True)
|
||||||
home = None
|
home = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tradingagents.ui.pages import todays_picks
|
from tradingagents.ui.pages import todays_picks
|
||||||
except ImportError:
|
except Exception as _e:
|
||||||
|
_logger.error("Failed to import todays_picks page: %s", _e, exc_info=True)
|
||||||
todays_picks = None
|
todays_picks = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tradingagents.ui.pages import portfolio
|
from tradingagents.ui.pages import portfolio
|
||||||
except ImportError:
|
except Exception as _e:
|
||||||
|
_logger.error("Failed to import portfolio page: %s", _e, exc_info=True)
|
||||||
portfolio = None
|
portfolio = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tradingagents.ui.pages import performance
|
from tradingagents.ui.pages import performance
|
||||||
except ImportError:
|
except Exception as _e:
|
||||||
|
_logger.error("Failed to import performance page: %s", _e, exc_info=True)
|
||||||
performance = None
|
performance = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tradingagents.ui.pages import settings
|
from tradingagents.ui.pages import settings
|
||||||
except ImportError:
|
except Exception as _e:
|
||||||
|
_logger.error("Failed to import settings page: %s", _e, exc_info=True)
|
||||||
settings = None
|
settings = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,21 @@
|
||||||
Performance analytics page — strategy comparison and win/loss analysis.
|
Performance analytics page — strategy comparison and win/loss analysis.
|
||||||
|
|
||||||
Shows strategy scatter plot with themed Plotly charts, per-strategy
|
Shows strategy scatter plot with themed Plotly charts, per-strategy
|
||||||
breakdown table, and win rate distribution.
|
breakdown table, win rate distribution, and full recommendation history.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from tradingagents.ui.theme import COLORS, get_plotly_template, page_header
|
from tradingagents.ui.theme import COLORS, get_plotly_template, page_header, pnl_color
|
||||||
from tradingagents.ui.utils import load_statistics, load_strategy_metrics
|
from tradingagents.ui.utils import (
|
||||||
|
load_performance_database,
|
||||||
|
load_statistics,
|
||||||
|
load_strategy_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render() -> None:
|
def render() -> None:
|
||||||
|
|
@ -35,8 +40,19 @@ def render() -> None:
|
||||||
|
|
||||||
# ---- Summary KPIs ----
|
# ---- Summary KPIs ----
|
||||||
total_trades = df["Count"].sum()
|
total_trades = df["Count"].sum()
|
||||||
avg_wr = (df["Win Rate"] * df["Count"]).sum() / total_trades if total_trades > 0 else 0
|
# Weighted averages only over strategies that have evaluated data (non-NaN)
|
||||||
avg_ret = (df["Avg Return"] * df["Count"]).sum() / total_trades if total_trades > 0 else 0
|
eval_df = df.dropna(subset=["Win Rate", "Avg Return"])
|
||||||
|
eval_trades = eval_df["Count"].sum()
|
||||||
|
avg_wr = (
|
||||||
|
(eval_df["Win Rate"] * eval_df["Count"]).sum() / eval_trades
|
||||||
|
if eval_trades > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
avg_ret = (
|
||||||
|
(eval_df["Avg Return"] * eval_df["Count"]).sum() / eval_trades
|
||||||
|
if eval_trades > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
n_strategies = len(df)
|
n_strategies = len(df)
|
||||||
|
|
||||||
cols = st.columns(4)
|
cols = st.columns(4)
|
||||||
|
|
@ -107,16 +123,16 @@ def render() -> None:
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
df_sorted = df.sort_values("Win Rate", ascending=True)
|
df_bar = df.dropna(subset=["Win Rate"]).sort_values("Win Rate", ascending=True)
|
||||||
colors = [COLORS["green"] if wr >= 50 else COLORS["red"] for wr in df_sorted["Win Rate"]]
|
colors = [COLORS["green"] if wr >= 50 else COLORS["red"] for wr in df_bar["Win Rate"]]
|
||||||
|
|
||||||
fig_bar = go.Figure(
|
fig_bar = go.Figure(
|
||||||
go.Bar(
|
go.Bar(
|
||||||
x=df_sorted["Win Rate"],
|
x=df_bar["Win Rate"],
|
||||||
y=df_sorted["Strategy"],
|
y=df_bar["Strategy"],
|
||||||
orientation="h",
|
orientation="h",
|
||||||
marker_color=colors,
|
marker_color=colors,
|
||||||
text=[f"{wr:.0f}%" for wr in df_sorted["Win Rate"]],
|
text=[f"{wr:.0f}%" for wr in df_bar["Win Rate"]],
|
||||||
textposition="auto",
|
textposition="auto",
|
||||||
textfont=dict(family="JetBrains Mono", size=11, color=COLORS["text_primary"]),
|
textfont=dict(family="JetBrains Mono", size=11, color=COLORS["text_primary"]),
|
||||||
)
|
)
|
||||||
|
|
@ -169,18 +185,334 @@ def render() -> None:
|
||||||
{
|
{
|
||||||
"Strategy": strat_name,
|
"Strategy": strat_name,
|
||||||
"Count": data.get("count", 0),
|
"Count": data.get("count", 0),
|
||||||
"Win Rate 1d": (
|
"Win Rate 1d": data.get("win_rate_1d") if "win_rate_1d" in data else None,
|
||||||
f"{data.get('win_rate_1d', 0):.0f}%" if "win_rate_1d" in data else "N/A"
|
"Avg Ret 1d": data.get("avg_return_1d") if "avg_return_1d" in data else None,
|
||||||
|
"W/L 1d": (
|
||||||
|
f"{data.get('wins_1d', 0)}W/{data.get('losses_1d', 0)}L"
|
||||||
|
if data.get("wins_1d", 0) + data.get("losses_1d", 0) > 0
|
||||||
|
else "—"
|
||||||
),
|
),
|
||||||
"Win Rate 7d": (
|
"Win Rate 7d": data.get("win_rate_7d") if "win_rate_7d" in data else None,
|
||||||
f"{data.get('win_rate_7d', 0):.0f}%" if "win_rate_7d" in data else "N/A"
|
"Avg Ret 7d": data.get("avg_return_7d") if "avg_return_7d" in data else None,
|
||||||
|
"W/L 7d": (
|
||||||
|
f"{data.get('wins_7d', 0)}W/{data.get('losses_7d', 0)}L"
|
||||||
|
if data.get("wins_7d", 0) + data.get("losses_7d", 0) > 0
|
||||||
|
else "—"
|
||||||
),
|
),
|
||||||
"Wins 1d": data.get("wins_1d", 0),
|
|
||||||
"Losses 1d": data.get("losses_1d", 0),
|
|
||||||
"Wins 7d": data.get("wins_7d", 0),
|
|
||||||
"Losses 7d": data.get("losses_7d", 0),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if rows:
|
if rows:
|
||||||
st.dataframe(pd.DataFrame(rows), width="stretch", hide_index=True)
|
period_df = pd.DataFrame(rows).sort_values("Count", ascending=False)
|
||||||
|
st.dataframe(
|
||||||
|
period_df,
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
|
column_config={
|
||||||
|
"Count": st.column_config.NumberColumn(format="%d"),
|
||||||
|
"Win Rate 1d": st.column_config.NumberColumn(format="%.1f%%"),
|
||||||
|
"Avg Ret 1d": st.column_config.NumberColumn(format="%+.2f%%"),
|
||||||
|
"Win Rate 7d": st.column_config.NumberColumn(format="%.1f%%"),
|
||||||
|
"Avg Ret 7d": st.column_config.NumberColumn(format="%+.2f%%"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Recommendation History ----
|
||||||
|
_render_recommendation_history(template)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Recommendation history helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _return_cell(val) -> str:
|
||||||
|
"""Format a return value as a colored HTML span."""
|
||||||
|
if val is None or (isinstance(val, float) and np.isnan(val)):
|
||||||
|
return '<span style="color:{c};">—</span>'.format(c=COLORS["text_muted"])
|
||||||
|
color = pnl_color(val)
|
||||||
|
return f'<span style="color:{color};font-weight:600;">{val:+.2f}%</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _win_dot(val) -> str:
|
||||||
|
"""Green/red dot for win/loss boolean."""
|
||||||
|
if val is None or (isinstance(val, float) and np.isnan(val)):
|
||||||
|
return ""
|
||||||
|
color = COLORS["green"] if val else COLORS["red"]
|
||||||
|
return f'<span style="color:{color};font-size:0.7rem;">●</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_recommendation_history(template: dict) -> None:
|
||||||
|
"""Full recommendation history with charts and filterable table."""
|
||||||
|
recs = load_performance_database()
|
||||||
|
if not recs:
|
||||||
|
return
|
||||||
|
|
||||||
|
st.markdown("<div style='height:2rem;'></div>", unsafe_allow_html=True)
|
||||||
|
st.markdown(
|
||||||
|
'<div class="section-title">Recommendation History '
|
||||||
|
'<span class="accent">// all picks</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build DataFrame
|
||||||
|
hist_df = pd.DataFrame(recs)
|
||||||
|
|
||||||
|
# Ensure numeric types
|
||||||
|
for col in ["return_1d", "return_7d", "return_30d", "return_pct", "final_score", "confidence"]:
|
||||||
|
if col in hist_df.columns:
|
||||||
|
hist_df[col] = pd.to_numeric(hist_df[col], errors="coerce")
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
if "discovery_date" in hist_df.columns:
|
||||||
|
hist_df["discovery_date"] = pd.to_datetime(hist_df["discovery_date"], errors="coerce")
|
||||||
|
|
||||||
|
# ---- Filters row ----
|
||||||
|
filter_cols = st.columns([2, 2, 2, 1])
|
||||||
|
|
||||||
|
with filter_cols[0]:
|
||||||
|
strategies = sorted(hist_df["strategy_match"].dropna().unique())
|
||||||
|
selected_strategies = st.multiselect(
|
||||||
|
"Strategy",
|
||||||
|
strategies,
|
||||||
|
default=[],
|
||||||
|
placeholder="All strategies",
|
||||||
|
)
|
||||||
|
|
||||||
|
with filter_cols[1]:
|
||||||
|
dates = hist_df["discovery_date"].dropna().sort_values()
|
||||||
|
min_date = dates.min().date() if len(dates) > 0 else None
|
||||||
|
max_date = dates.max().date() if len(dates) > 0 else None
|
||||||
|
if min_date and max_date:
|
||||||
|
date_range = st.date_input(
|
||||||
|
"Date range",
|
||||||
|
value=(min_date, max_date),
|
||||||
|
min_value=min_date,
|
||||||
|
max_value=max_date,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
date_range = None
|
||||||
|
|
||||||
|
with filter_cols[2]:
|
||||||
|
outcome_filter = st.selectbox(
|
||||||
|
"Outcome (7d)",
|
||||||
|
["All", "Winners", "Losers", "Pending"],
|
||||||
|
index=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with filter_cols[3]:
|
||||||
|
sort_by = st.selectbox("Sort", ["Date", "Return 1d", "Return 7d", "Score"], index=0)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
mask = pd.Series(True, index=hist_df.index)
|
||||||
|
|
||||||
|
if selected_strategies:
|
||||||
|
mask &= hist_df["strategy_match"].isin(selected_strategies)
|
||||||
|
|
||||||
|
if date_range and len(date_range) == 2:
|
||||||
|
start, end = pd.Timestamp(date_range[0]), pd.Timestamp(date_range[1])
|
||||||
|
mask &= (hist_df["discovery_date"] >= start) & (hist_df["discovery_date"] <= end)
|
||||||
|
|
||||||
|
if outcome_filter == "Winners":
|
||||||
|
mask &= hist_df.get("win_7d", pd.Series(dtype=bool)) == True # noqa: E712
|
||||||
|
elif outcome_filter == "Losers":
|
||||||
|
mask &= hist_df.get("win_7d", pd.Series(dtype=bool)) == False # noqa: E712
|
||||||
|
elif outcome_filter == "Pending":
|
||||||
|
mask &= hist_df.get("return_7d").isna() if "return_7d" in hist_df.columns else True
|
||||||
|
|
||||||
|
filtered = hist_df[mask].copy()
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
sort_map = {
|
||||||
|
"Date": ("discovery_date", False),
|
||||||
|
"Return 1d": ("return_1d", False),
|
||||||
|
"Return 7d": ("return_7d", False),
|
||||||
|
"Score": ("final_score", False),
|
||||||
|
}
|
||||||
|
sort_col, sort_asc = sort_map.get(sort_by, ("discovery_date", False))
|
||||||
|
if sort_col in filtered.columns:
|
||||||
|
filtered = filtered.sort_values(sort_col, ascending=sort_asc, na_position="last")
|
||||||
|
|
||||||
|
st.caption(f"Showing {len(filtered)} of {len(hist_df)} recommendations")
|
||||||
|
|
||||||
|
# ---- Two-column charts ----
|
||||||
|
if len(filtered) > 0:
|
||||||
|
left_ch, right_ch = st.columns(2)
|
||||||
|
|
||||||
|
with left_ch:
|
||||||
|
st.markdown(
|
||||||
|
'<div class="section-title">Return Distribution '
|
||||||
|
'<span class="accent">// 1d vs 7d</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
_render_return_distribution(filtered, template)
|
||||||
|
|
||||||
|
with right_ch:
|
||||||
|
st.markdown(
|
||||||
|
'<div class="section-title">Cumulative P/L by Date '
|
||||||
|
'<span class="accent">// equity curve</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
_render_cumulative_pnl(filtered, template)
|
||||||
|
|
||||||
|
# ---- Full history table ----
|
||||||
|
st.markdown("<div style='height:1rem;'></div>", unsafe_allow_html=True)
|
||||||
|
st.markdown(
|
||||||
|
'<div class="section-title">All Picks '
|
||||||
|
'<span class="accent">// detail table</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
_render_history_table(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_return_distribution(df: pd.DataFrame, template: dict) -> None:
|
||||||
|
"""Box plot comparing 1d vs 7d return distributions."""
|
||||||
|
ret_data = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
if pd.notna(row.get("return_1d")):
|
||||||
|
ret_data.append({"Period": "1-Day", "Return (%)": row["return_1d"]})
|
||||||
|
if pd.notna(row.get("return_7d")):
|
||||||
|
ret_data.append({"Period": "7-Day", "Return (%)": row["return_7d"]})
|
||||||
|
|
||||||
|
if not ret_data:
|
||||||
|
st.info("No return data available for the selected filters.")
|
||||||
|
return
|
||||||
|
|
||||||
|
ret_df = pd.DataFrame(ret_data)
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
for period, color in [("1-Day", COLORS["blue"]), ("7-Day", COLORS["cyan"])]:
|
||||||
|
subset = ret_df[ret_df["Period"] == period]["Return (%)"]
|
||||||
|
if len(subset) == 0:
|
||||||
|
continue
|
||||||
|
fig.add_trace(
|
||||||
|
go.Box(
|
||||||
|
y=subset,
|
||||||
|
name=period,
|
||||||
|
marker_color=color,
|
||||||
|
boxmean=True,
|
||||||
|
jitter=0.3,
|
||||||
|
pointpos=-1.5,
|
||||||
|
boxpoints="outliers",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4)
|
||||||
|
fig.update_layout(
|
||||||
|
**template,
|
||||||
|
height=350,
|
||||||
|
showlegend=True,
|
||||||
|
legend=dict(orientation="h", y=1.02, x=0.5, xanchor="center"),
|
||||||
|
yaxis_title="Return (%)",
|
||||||
|
)
|
||||||
|
st.plotly_chart(fig, width="stretch")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_cumulative_pnl(df: pd.DataFrame, template: dict) -> None:
|
||||||
|
"""Cumulative average return by discovery date (equity curve style)."""
|
||||||
|
if "discovery_date" not in df.columns:
|
||||||
|
st.info("No date data available.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use 7d return where available, fall back to 1d
|
||||||
|
df_dated = df.dropna(subset=["discovery_date"]).copy()
|
||||||
|
df_dated["best_return"] = df_dated["return_7d"].fillna(df_dated.get("return_1d", 0))
|
||||||
|
df_dated = df_dated.dropna(subset=["best_return"])
|
||||||
|
|
||||||
|
if len(df_dated) == 0:
|
||||||
|
st.info("No return data available for equity curve.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Group by date, get mean return per day
|
||||||
|
daily = (
|
||||||
|
df_dated.groupby("discovery_date")["best_return"]
|
||||||
|
.mean()
|
||||||
|
.reset_index()
|
||||||
|
.sort_values("discovery_date")
|
||||||
|
)
|
||||||
|
daily.columns = ["Date", "Avg Return"]
|
||||||
|
daily["Cumulative"] = daily["Avg Return"].cumsum()
|
||||||
|
|
||||||
|
# Color based on cumulative being positive/negative
|
||||||
|
colors = [COLORS["green"] if v >= 0 else COLORS["red"] for v in daily["Cumulative"]]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=daily["Date"],
|
||||||
|
y=daily["Cumulative"],
|
||||||
|
mode="lines+markers",
|
||||||
|
line=dict(color=COLORS["green"], width=2),
|
||||||
|
marker=dict(color=colors, size=7, line=dict(color=COLORS["bg_card"], width=1)),
|
||||||
|
fill="tozeroy",
|
||||||
|
fillcolor="rgba(34, 197, 94, 0.08)",
|
||||||
|
hovertemplate="Date: %{x|%b %d}<br>Cumulative: %{y:+.2f}%<extra></extra>",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4)
|
||||||
|
fig.update_layout(
|
||||||
|
**template,
|
||||||
|
height=350,
|
||||||
|
showlegend=False,
|
||||||
|
yaxis_title="Cumulative Avg Return (%)",
|
||||||
|
xaxis_title="",
|
||||||
|
)
|
||||||
|
st.plotly_chart(fig, width="stretch")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_history_table(df: pd.DataFrame) -> None:
|
||||||
|
"""Render the full recommendation history as a styled dataframe."""
|
||||||
|
if len(df) == 0:
|
||||||
|
st.info("No recommendations match the selected filters.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build display dataframe with readable columns
|
||||||
|
display_rows = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
disc_date = row.get("discovery_date")
|
||||||
|
date_str = disc_date.strftime("%Y-%m-%d") if pd.notna(disc_date) else "—"
|
||||||
|
|
||||||
|
display_rows.append(
|
||||||
|
{
|
||||||
|
"Date": date_str,
|
||||||
|
"Ticker": row.get("ticker", "—"),
|
||||||
|
"#": int(row["rank"]) if pd.notna(row.get("rank")) else 0,
|
||||||
|
"Strategy": row.get("strategy_match", "—"),
|
||||||
|
"Score": row.get("final_score"),
|
||||||
|
"Conf": int(row["confidence"]) if pd.notna(row.get("confidence")) else None,
|
||||||
|
"Entry $": row.get("entry_price"),
|
||||||
|
"Now $": row.get("current_price"),
|
||||||
|
"Ret 1d %": row.get("return_1d"),
|
||||||
|
"Ret 7d %": row.get("return_7d"),
|
||||||
|
"Ret 30d %": row.get("return_30d") if "return_30d" in row.index else None,
|
||||||
|
"Current %": row.get("return_pct"),
|
||||||
|
"Days": int(row["days_held"]) if pd.notna(row.get("days_held")) else None,
|
||||||
|
"Status": row.get("status", "—"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
table_df = pd.DataFrame(display_rows)
|
||||||
|
|
||||||
|
st.dataframe(
|
||||||
|
table_df,
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
|
height=min(len(table_df) * 35 + 38, 600),
|
||||||
|
column_config={
|
||||||
|
"Date": st.column_config.TextColumn(width="small"),
|
||||||
|
"Ticker": st.column_config.TextColumn(width="small"),
|
||||||
|
"#": st.column_config.NumberColumn(format="%d", width="small"),
|
||||||
|
"Strategy": st.column_config.TextColumn(width="medium"),
|
||||||
|
"Score": st.column_config.NumberColumn(format="%.0f", width="small"),
|
||||||
|
"Conf": st.column_config.NumberColumn(format="%d/10", width="small"),
|
||||||
|
"Entry $": st.column_config.NumberColumn(format="$%.2f"),
|
||||||
|
"Now $": st.column_config.NumberColumn(format="$%.2f"),
|
||||||
|
"Ret 1d %": st.column_config.NumberColumn(format="%+.2f%%"),
|
||||||
|
"Ret 7d %": st.column_config.NumberColumn(format="%+.2f%%"),
|
||||||
|
"Ret 30d %": st.column_config.NumberColumn(format="%+.2f%%"),
|
||||||
|
"Current %": st.column_config.NumberColumn(format="%+.2f%%"),
|
||||||
|
"Days": st.column_config.NumberColumn(format="%d"),
|
||||||
|
"Status": st.column_config.TextColumn(width="small"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -196,42 +196,73 @@ def load_performance_database() -> List[Dict[str, Any]]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_STRATEGY_ALIASES: Dict[str, str] = {
|
||||||
|
"momentum": "momentum",
|
||||||
|
"momentum/hype": "momentum",
|
||||||
|
"momentum/hype / short squeeze": "momentum",
|
||||||
|
"insider play": "insider_buying",
|
||||||
|
"insider_buying": "insider_buying",
|
||||||
|
"earnings play": "earnings_play",
|
||||||
|
"earnings_play": "earnings_play",
|
||||||
|
"earnings_calendar": "earnings_calendar",
|
||||||
|
"news catalyst": "news_catalyst",
|
||||||
|
"news_catalyst": "news_catalyst",
|
||||||
|
"volume accumulation": "volume_accumulation",
|
||||||
|
"volume_accumulation": "volume_accumulation",
|
||||||
|
"contrarian value": "contrarian_value",
|
||||||
|
"contrarian_value": "contrarian_value",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_strategy(raw: str) -> str:
|
||||||
|
"""Map strategy name variants to a canonical lowercase form."""
|
||||||
|
key = raw.strip().lower()
|
||||||
|
return _STRATEGY_ALIASES.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
def load_strategy_metrics() -> List[Dict[str, Any]]:
|
def load_strategy_metrics() -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Build per-strategy metrics from the performance database if available.
|
Build per-strategy metrics from the performance database if available.
|
||||||
Falls back to statistics.json when performance database is missing.
|
Falls back to statistics.json when performance database is missing.
|
||||||
|
|
||||||
|
Normalizes strategy names so variants like 'Momentum', 'momentum',
|
||||||
|
and 'Momentum/Hype' all merge into a single bucket. Counts ALL
|
||||||
|
recommendations per strategy; win rate and avg return are computed
|
||||||
|
from the subset that has 7-day return data.
|
||||||
"""
|
"""
|
||||||
recs = load_performance_database()
|
recs = load_performance_database()
|
||||||
if recs:
|
if recs:
|
||||||
metrics: Dict[str, Dict[str, float]] = {}
|
metrics: Dict[str, Dict[str, float]] = {}
|
||||||
for rec in recs:
|
for rec in recs:
|
||||||
strategy = rec.get("strategy_match", "unknown")
|
strategy = normalize_strategy(rec.get("strategy_match", "unknown"))
|
||||||
if strategy not in metrics:
|
if strategy not in metrics:
|
||||||
metrics[strategy] = {
|
metrics[strategy] = {
|
||||||
"count": 0,
|
"total": 0,
|
||||||
|
"evaluated": 0,
|
||||||
"wins": 0,
|
"wins": 0,
|
||||||
"sum_return": 0.0,
|
"sum_return": 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if "return_7d" in rec:
|
metrics[strategy]["total"] += 1
|
||||||
metrics[strategy]["count"] += 1
|
|
||||||
|
if "return_7d" in rec and rec["return_7d"] is not None:
|
||||||
|
metrics[strategy]["evaluated"] += 1
|
||||||
metrics[strategy]["sum_return"] += float(rec.get("return_7d", 0.0) or 0.0)
|
metrics[strategy]["sum_return"] += float(rec.get("return_7d", 0.0) or 0.0)
|
||||||
if rec.get("win_7d"):
|
if rec.get("win_7d"):
|
||||||
metrics[strategy]["wins"] += 1
|
metrics[strategy]["wins"] += 1
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for strategy, data in metrics.items():
|
for strategy, data in metrics.items():
|
||||||
count = int(data["count"])
|
total = int(data["total"])
|
||||||
if count == 0:
|
evaluated = int(data["evaluated"])
|
||||||
continue
|
win_rate = round((data["wins"] / evaluated) * 100, 1) if evaluated > 0 else None
|
||||||
win_rate = round((data["wins"] / count) * 100, 1)
|
avg_return = round(data["sum_return"] / evaluated, 2) if evaluated > 0 else None
|
||||||
avg_return = round(data["sum_return"] / count, 2)
|
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"Strategy": strategy,
|
"Strategy": strategy,
|
||||||
"Win Rate": win_rate,
|
"Win Rate": win_rate,
|
||||||
"Avg Return": avg_return,
|
"Avg Return": avg_return,
|
||||||
"Count": count,
|
"Count": total,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
@ -242,10 +273,10 @@ def load_strategy_metrics() -> List[Dict[str, Any]]:
|
||||||
for strategy, data in by_strategy.items():
|
for strategy, data in by_strategy.items():
|
||||||
win_rate = data.get("win_rate_7d") or data.get("win_rate", 0)
|
win_rate = data.get("win_rate_7d") or data.get("win_rate", 0)
|
||||||
avg_return = data.get("avg_return_7d", 0)
|
avg_return = data.get("avg_return_7d", 0)
|
||||||
count = data.get("wins_7d", 0) + data.get("losses_7d", 0)
|
count = data.get("count", data.get("wins_7d", 0) + data.get("losses_7d", 0))
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"Strategy": strategy,
|
"Strategy": normalize_strategy(strategy),
|
||||||
"Win Rate": win_rate,
|
"Win Rate": win_rate,
|
||||||
"Avg Return": avg_return,
|
"Avg Return": avg_return,
|
||||||
"Count": count,
|
"Count": count,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue