"""EQ-FUNDNEWS-SHORT — "fondamentali/notizie NEGATIVI ma prezzo SU -> short". Screener FORWARD. Idea utente: se i dati finanziari/notizie di un'azienda sono negativi ma la quotazione e' positiva (sale), andare SHORT (scommessa che il prezzo scenda a riallinearsi ai fondamentali). GATE DATI (lezione v2.0.0, PRIMA della strategia): NON backtestabile su dati certi. - I fondamentali da rete (Yahoo) sono SNAPSHOT CORRENTI, non point-in-time storici. Applicarli a prezzi passati = LOOK-AHEAD (restatement/survivorship). E' la trappola che ha creato la libreria fasulla v2.0.0. -> nessun backtest. Serve un DB point-in-time (Compustat PIT / news storiche), assente. - Quindi: come per la vol term-structure, l'unica via onesta e' uno SCREENER FORWARD che genera i candidati short OGGI da dati di rete e li LOGGA in avanti. L'edge resta NON PROVATO finche' non accumulato e validato forward. Questo script NON afferma un edge: produce candidati + li registra. SEGNALE (tutto da rete, tokenless): - "dati finanziari negativi" = score strutturato da Yahoo quoteSummary (crumb flow): recommendationMean alto (->sell), earnings surprise recenti negative, revenueGrowth<0, recommendationTrend sbilanciato a sell. - "notizie negative" = sentiment lessicale crudo sulle headline (Yahoo news search). - "quotazione positiva" = momentum 1m/3m > 0 (chart API). -> SHORT candidate = (fond_neg alto OPPURE news_neg alto) AND momentum positivo (la DIVERGENZA). ESEGUIBILITA' (muro): shortare richiede BORROW (locate+fee, hard-to-borrow caro/assente), perdita illimitata, squeeze; PDT $25k per i day-trade; IB instabile qui; $600 di capitale; universo single-stock (non i nostri ETF). Shortare la FORZA combatte il momentum (anomalia forte) -> premessa rischiosa. uv run python scripts/research/eq_fundnews_short.py """ from __future__ import annotations import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) import numpy as np import pandas as pd import requests RAW = ROOT / "data" / "raw" H = {"User-Agent": "Mozilla/5.0"} # universo dimostrativo: large/mid cap liquide, vari settori UNIVERSE = ["AAPL", "MSFT", "NVDA", "TSLA", "META", "AMZN", "INTC", "F", "GM", "BA", "DIS", "PYPL", "NKE", "PFE", "T", "WBA", "XOM", "KO", "CVX", "AMD"] NEG_WORDS = {"downgrade", "miss", "missed", "cut", "cuts", "lawsuit", "probe", "fraud", "plunge", "plunges", "warn", "warns", "warning", "slump", "loss", "losses", "weak", "weakness", "decline", "declines", "fall", "falls", "drop", "sinks", "slashes", "recall", "halt", "bankruptcy", "default", "layoff", "layoffs", "sell-off", "bearish", "underperform"} POS_WORDS = {"beat", "beats", "upgrade", "surge", "surges", "record", "strong", "raise", "raises", "soar", "soars", "rally", "jumps", "tops", "bullish", "outperform", "growth"} def yahoo_session(): s = requests.Session(); s.headers.update(H) s.get("https://fc.yahoo.com/", timeout=20) crumb = s.get("https://query2.finance.yahoo.com/v1/test/getcrumb", timeout=20).text.strip() return s, crumb def fund_neg_score(rec_mean, surp, rev_g, sell_skew): """Pura: score di negativita' fondamentale [0..1] dai componenti disponibili (media).""" comp = [] if rec_mean is not None: comp.append(float(np.clip((rec_mean - 1) / 4, 0, 1))) # 1=buy ->0, 5=sell ->1 if surp: comp.append(1.0 if np.mean(surp[:2]) < 0 else 0.0) # ultime 2 surprise negative if rev_g is not None: comp.append(1.0 if rev_g < 0 else float(max(0.0, 1 - rev_g * 5))) if sell_skew is not None: comp.append(float(np.clip(sell_skew * 3, 0, 1))) return float(np.mean(comp)) if comp else None def headline_sentiment(titles): """Pura: frazione di sentiment negativo sulle headline (lessico crudo). None se nessun hit.""" neg = pos = 0 for t in titles: w = set(t.lower().replace(",", " ").replace(".", " ").split()) neg += len(w & NEG_WORDS); pos += len(w & POS_WORDS) tot = neg + pos return (neg / tot) if tot else 0.0, neg, pos def momentum(sym): u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range=3mo&interval=1d" j = requests.get(u, headers=H, timeout=25).json()["chart"]["result"][0] c = pd.Series(j["indicators"]["quote"][0]["close"]).dropna().values if len(c) < 25: return None m1 = c[-1] / c[-21] - 1.0 # ~1 mese m3 = c[-1] / c[0] - 1.0 # ~3 mesi return dict(last=float(c[-1]), mom_1m=float(m1), mom_3m=float(m3)) def fundamentals(s, crumb, sym): mods = "financialData,recommendationTrend,earningsHistory" u = f"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{sym}?modules={mods}&crumb={crumb}" res = s.get(u, timeout=25).json()["quoteSummary"]["result"][0] fd = res.get("financialData", {}) rec_mean = fd.get("recommendationMean", {}).get("raw") rev_g = fd.get("revenueGrowth", {}).get("raw") eh = res.get("earningsHistory", {}).get("history", []) surp = [h.get("surprisePercent", {}).get("raw") for h in eh if h.get("surprisePercent")] rt = res.get("recommendationTrend", {}).get("trend", []) sell_skew = None if rt: t = rt[0]; tot = sum(t.get(k, 0) for k in ("strongBuy", "buy", "hold", "sell", "strongSell")) sell_skew = (t.get("sell", 0) + t.get("strongSell", 0)) / tot if tot else None return dict(fund_neg=fund_neg_score(rec_mean, surp, rev_g, sell_skew), rec_mean=rec_mean, rev_growth=rev_g, last_surprise=surp[0] if surp else None, sell_skew=sell_skew) def news_sentiment(sym): u = f"https://query1.finance.yahoo.com/v1/finance/search?q={sym}&newsCount=8"esCount=0" news = requests.get(u, headers=H, timeout=25).json().get("news", []) if not news: return dict(news_neg=None, n=0) nn, neg, pos = headline_sentiment([n.get("title", "") for n in news]) return dict(news_neg=nn, n=len(news), neg_hits=neg, pos_hits=pos) def screen(universe): s, crumb = yahoo_session() rows = [] for sym in universe: try: mom = momentum(sym) if mom is None: continue fun = fundamentals(s, crumb, sym) nw = news_sentiment(sym) fneg = fun["fund_neg"] nneg = nw["news_neg"] rising = mom["mom_1m"] > 0.02 # quotazione positiva fund_bad = fneg is not None and fneg >= 0.5 news_bad = nneg is not None and nneg >= 0.5 short_cand = rising and (fund_bad or news_bad) rows.append(dict(sym=sym, mom_1m=mom["mom_1m"], mom_3m=mom["mom_3m"], fund_neg=fneg, news_neg=nneg, rec_mean=fun["rec_mean"], rev_growth=fun["rev_growth"], last_surprise=fun["last_surprise"], short_cand=short_cand)) time.sleep(0.3) except Exception as e: rows.append(dict(sym=sym, error=repr(e)[:60])) return pd.DataFrame(rows) def main(): print("=" * 100) print(" EQ-FUNDNEWS-SHORT — divergenza fondamentali/notizie NEG vs prezzo SU -> short candidate") print(" (SCREENER FORWARD da dati di rete — NON un backtest: edge non provato, vedi header)") print("=" * 100) df = screen(UNIVERSE) ok = df[df.get("error").isna()] if "error" in df else df ok = ok.sort_values("fund_neg", ascending=False, na_position="last") print(f" {'sym':5} {'mom1m':>7} {'mom3m':>7} {'fund_neg':>8} {'news_neg':>8} " f"{'recMean':>7} {'revGr':>7} {'surp':>7} SHORT?") for _, r in ok.iterrows(): fn = f"{r['fund_neg']:.2f}" if pd.notna(r['fund_neg']) else " n/a" nn = f"{r['news_neg']:.2f}" if pd.notna(r['news_neg']) else " n/a" rm = f"{r['rec_mean']:.2f}" if pd.notna(r['rec_mean']) else " n/a" rg = f"{r['rev_growth']*100:+.0f}%" if pd.notna(r['rev_growth']) else " n/a" sp = f"{r['last_surprise']*100:+.0f}%" if pd.notna(r['last_surprise']) else " n/a" flag = " <<< SHORT" if r["short_cand"] else "" print(f" {r['sym']:5} {r['mom_1m']*100:>+6.1f}% {r['mom_3m']*100:>+6.1f}% {fn:>8} {nn:>8} " f"{rm:>7} {rg:>7} {sp:>7}{flag}") cands = ok[ok["short_cand"]]["sym"].tolist() print(f"\n CANDIDATI SHORT oggi (fond/news neg + prezzo su): {cands or 'nessuno'}") # log forward (idempotente per giorno) today = pd.Timestamp.now("UTC").normalize() ok2 = ok.copy(); ok2.insert(0, "date", today) fp = RAW / "fundnews_short_screen.parquet" hist = pd.read_parquet(fp) if fp.exists() else pd.DataFrame() if len(hist): hist = hist[hist["date"] != today] pd.concat([hist, ok2], ignore_index=True).to_parquet(fp, index=False) print(f" -> snapshot loggato in {fp.name} (forward dataset; serve accumulo+validazione)") print("\n" + "=" * 100) print(" ONESTA' / ESEGUIBILITA'") print("=" * 100) print(" - NON backtestabile: fondamentali = snapshot correnti, non point-in-time -> look-ahead (v2.0.0).") print(" - Premessa RISCHIOSA: shortare un prezzo che SALE combatte il momentum (anomalia forte);") print(" il rialzo 'malgrado' notizie cattive spesso PREZZA info che i fondamentali trailing non hanno.") print(" - Eseguibilita': borrow (locate/fee, hard-to-borrow), perdita illimitata, squeeze, PDT $25k,") print(" IB instabile, $600. -> NON deployabile. Deliverable = screener forward + log, edge da provare.") if __name__ == "__main__": main()