From 47465828d839c460a6af12894451539908d76c26 Mon Sep 17 00:00:00 2001 From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:11:59 +0900 Subject: feat: add Claude-powered daily stock screener for MOC strategy --- scripts/stock_screener.py | 253 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100755 scripts/stock_screener.py (limited to 'scripts') diff --git a/scripts/stock_screener.py b/scripts/stock_screener.py new file mode 100755 index 0000000..387bfea --- /dev/null +++ b/scripts/stock_screener.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Claude-powered daily stock screener for MOC strategy. + +Analyzes market conditions and recommends stocks to buy at close. +Uses Anthropic Claude API for fundamental/news analysis. + +Usage: + python scripts/stock_screener.py + python scripts/stock_screener.py --top 5 + +Requires: ANTHROPIC_API_KEY environment variable +""" +import argparse +import asyncio +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "shared" / "src")) + +from shared.alpaca import AlpacaClient # noqa: E402 + + +async def get_market_data(alpaca: AlpacaClient, symbols: list[str]) -> list[dict]: + """Fetch latest market data for screening.""" + results = [] + for symbol in symbols: + try: + bars = await alpaca.get_bars(symbol, timeframe="1Day", limit=5) + if not bars: + continue + + latest = bars[-1] + prev = bars[-2] if len(bars) > 1 else latest + + # Calculate basic metrics + close = float(latest["c"]) + prev_close = float(prev["c"]) + change_pct = (close - prev_close) / prev_close * 100 if prev_close else 0 + volume = int(latest["v"]) + + # 5-day average volume + avg_vol = sum(int(b["v"]) for b in bars) / len(bars) + vol_ratio = volume / avg_vol if avg_vol > 0 else 1.0 + + # 5-day price range + highs = [float(b["h"]) for b in bars] + lows = [float(b["l"]) for b in bars] + range_pct = (max(highs) - min(lows)) / close * 100 if close > 0 else 0 + + results.append({ + "symbol": symbol, + "close": close, + "change_pct": round(change_pct, 2), + "volume": volume, + "vol_ratio": round(vol_ratio, 2), + "range_5d_pct": round(range_pct, 2), + "is_bullish": close > float(latest["o"]), # Today bullish? + }) + except Exception as exc: + print(f" Warning: Failed to fetch {symbol}: {exc}") + + return results + + +def build_claude_prompt(market_data: list[dict]) -> str: + """Build a prompt for Claude to analyze stocks.""" + data_str = json.dumps(market_data, indent=2) + + return f"""You are a stock analyst. Analyze these US stocks for a Market-on-Close (MOC) overnight trading strategy. + +The strategy buys stocks at market close (3:50-4:00 PM ET) and sells at market open next day (9:30-10:00 AM ET). + +We want stocks that are likely to gap UP overnight. Good candidates have: +1. Bullish daily candle (closed above open) +2. Volume above average (vol_ratio > 1.0) +3. Positive momentum (change_pct > 0) +4. Not overextended (change_pct < 5% — avoid chasing) +5. Moderate volatility (range_5d_pct between 3-15%) + +Market data: +{data_str} + +Analyze each stock and return a JSON array of your top recommendations, ranked by confidence. +Format: +```json +[ + {{ + "symbol": "AAPL", + "score": 85, + "reason": "Bullish candle on above-average volume, moderate momentum" + }} +] +``` + +Only include stocks with score >= 60. If no stocks qualify, return an empty array. +Return ONLY the JSON array, no other text.""" + + +async def analyze_with_claude(prompt: str, api_key: str) -> list[dict]: + """Send prompt to Claude and parse response.""" + try: + import anthropic + + client = anthropic.Anthropic(api_key=api_key) + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=1024, + messages=[{"role": "user", "content": prompt}], + ) + + response_text = message.content[0].text.strip() + + # Extract JSON from response (handle markdown code blocks) + if "```json" in response_text: + response_text = response_text.split("```json")[1].split("```")[0].strip() + elif "```" in response_text: + response_text = response_text.split("```")[1].split("```")[0].strip() + + return json.loads(response_text) + + except ImportError: + print("Error: anthropic package not installed. Run: pip install anthropic") + return [] + except json.JSONDecodeError as e: + print(f"Error: Failed to parse Claude response: {e}") + print(f"Response was: {response_text[:500]}") + return [] + except Exception as e: + print(f"Error: Claude API call failed: {e}") + return [] + + +# Default universe of liquid US stocks +DEFAULT_UNIVERSE = [ + # Tech + "AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA", "AMD", "INTC", "CRM", + # Finance + "JPM", "BAC", "GS", "MS", "V", "MA", + # Healthcare + "JNJ", "UNH", "PFE", "ABBV", + # Consumer + "WMT", "KO", "PEP", "MCD", "NKE", + # Energy + "XOM", "CVX", + # Industrial + "CAT", "BA", "GE", + # ETFs + "SPY", "QQQ", "IWM", +] + + +async def main_async(top_n: int = 5, universe: list[str] | None = None): + api_key = os.environ.get("ANTHROPIC_API_KEY", "") + alpaca_key = os.environ.get("ALPACA_API_KEY", "") + alpaca_secret = os.environ.get("ALPACA_API_SECRET", "") + + if not alpaca_key: + print("Error: ALPACA_API_KEY not set") + sys.exit(1) + + symbols = universe or DEFAULT_UNIVERSE + + print("=" * 60) + print("Daily Stock Screener — MOC Strategy") + print(f"Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") + print(f"Universe: {len(symbols)} stocks") + print("=" * 60) + + # Fetch market data + print("\nFetching market data from Alpaca...") + alpaca = AlpacaClient(api_key=alpaca_key, api_secret=alpaca_secret, paper=True) + + try: + market_data = await get_market_data(alpaca, symbols) + finally: + await alpaca.close() + + if not market_data: + print("No market data available. Market may be closed.") + return + + print(f"Got data for {len(market_data)} stocks\n") + + # Pre-filter obvious rejects + candidates = [ + s for s in market_data + if s["is_bullish"] and s["vol_ratio"] >= 0.8 and s["change_pct"] > -2 + ] + print(f"Pre-filter: {len(candidates)} candidates (bullish, decent volume)\n") + + if not candidates: + print("No candidates passed pre-filter.") + return + + # Claude analysis + if api_key: + print("Analyzing with Claude...") + prompt = build_claude_prompt(candidates) + recommendations = await analyze_with_claude(prompt, api_key) + + if recommendations: + print(f"\nClaude's Top {min(top_n, len(recommendations))} Picks:") + print("-" * 60) + for i, rec in enumerate(recommendations[:top_n], 1): + print(f"\n#{i} {rec['symbol']} (Score: {rec['score']})") + print(f" {rec['reason']}") + # Find market data for this symbol + md = next((m for m in market_data if m["symbol"] == rec["symbol"]), None) + if md: + print(f" Close: ${md['close']:.2f} | Change: {md['change_pct']:+.2f}% | Vol Ratio: {md['vol_ratio']:.1f}x") + else: + print("Claude found no qualifying stocks today.") + else: + print("ANTHROPIC_API_KEY not set — showing technical screening only\n") + # Fallback: simple technical screening + scored = [] + for s in candidates: + score = 0 + if s["change_pct"] > 0: + score += 30 + if s["vol_ratio"] > 1.2: + score += 25 + if s["is_bullish"]: + score += 20 + if 3 < s["range_5d_pct"] < 15: + score += 15 + if s["change_pct"] < 5: + score += 10 + scored.append((s, score)) + + scored.sort(key=lambda x: x[1], reverse=True) + print(f"Technical Screening Top {top_n}:") + print("-" * 60) + for i, (s, score) in enumerate(scored[:top_n], 1): + print(f"#{i} {s['symbol']} (Score: {score})") + print(f" Close: ${s['close']:.2f} | Change: {s['change_pct']:+.2f}% | Vol: {s['vol_ratio']:.1f}x") + + print("\n" + "=" * 60) + + +def main(): + parser = argparse.ArgumentParser(description="Daily stock screener for MOC strategy") + parser.add_argument("--top", type=int, default=5, help="Number of top picks") + args = parser.parse_args() + asyncio.run(main_async(top_n=args.top)) + + +if __name__ == "__main__": + main() -- cgit v1.2.3