diff --git a/docs/diary/2026-06-26-equity-fundnews-short.md b/docs/diary/2026-06-26-equity-fundnews-short.md new file mode 100644 index 0000000..3a9a068 --- /dev/null +++ b/docs/diary/2026-06-26-equity-fundnews-short.md @@ -0,0 +1,58 @@ +# 2026-06-26 — "Short su fondamentali/notizie NEG ma prezzo SU": screener forward, edge non provato + +**Goal utente (chiarito):** se i dati finanziari/notizie di un'azienda sono negativi ma la +quotazione è positiva (sale), andare SHORT — scommessa che il prezzo scenda a riallinearsi ai +fondamentali. Branch `research/equity-meanrev-ib`, script `scripts/research/eq_fundnews_short.py`. + +## Gate dati (lezione v2.0.0) — NON backtestabile + +I fondamentali e le notizie scaricabili dalla rete (Yahoo) sono **snapshot CORRENTI**, non +point-in-time storici. Applicarli a prezzi passati = **look-ahead** (restatement + survivorship): +esattamente la classe di errore che ha prodotto la libreria fasulla v2.0.0. Un backtest onesto +richiede un DB point-in-time (Compustat PIT, news storiche con timestamp) — **assente**. Quindi, +come per la vol term-structure: **niente backtest**, l'unica via onesta è uno **screener forward**. + +## Cosa è scaricabile (feasibility, fatto) + +- **Fondamentali strutturati** via Yahoo quoteSummary (flusso cookie+**crumb**, tokenless): + `recommendationMean` (1=buy..5=sell), `revenueGrowth`, `earningsHistory` surprise%, + `recommendationTrend` (skew buy/sell). ✅ +- **Notizie**: Yahoo news search (headline + publisher), sentiment lessicale crudo. ✅ (proxy debole) +- **Momentum**: chart API (return 1m/3m). ✅ + +## Screener costruito + eseguito dal vivo + +`fund_neg` ∈ [0,1] (media di: recMean→sell, surprise negative, ricavi in calo, analisti a sell) + +`news_neg` lessicale + momentum. **SHORT candidate = (fond/news neg) AND prezzo in salita** (la +divergenza). Idempotente, logga forward su `data/raw/fundnews_short_screen.parquet`. + +**Run di oggi (2026-06-26): NESSUN candidato.** Quasi tutto l'universo ha momentum 1m **negativo** +(mercato in flessione ampia, coerente col bear) → la gamba "prezzo che sale" non scatta. I peggiori +per fondamentali (TSLA 0.48, BA 0.39, NKE 0.38) stanno comunque *scendendo*. Output sensato. + +## L'intuizione analitica chiave (il valore vero di questa analisi) + +La premessa ha una **tensione interna** che la rende la versione *rischiosa* dell'anomalia: +- **Shortare un prezzo che SALE combatte il momentum** — una delle anomalie più robuste e persistenti + (i winner continuano a vincere su 3-12 mesi). Si scommette contro di essa. +- **Il PEAD** (post-earnings drift) dice che dopo cattive notizie il prezzo continua a **scendere**; + ma qui il prezzo *sale malgrado* le cattive notizie → spesso significa che il mercato sta + **prezzando informazione** che i fondamentali *trailing* non contengono (turnaround, M&A, squeeze). +- Quindi: la versione *pulita* dell'edge "short su fondamentali deboli" funziona meglio quando il + prezzo **conferma** (fondamentali deboli + prezzo che SCENDE = momentum e fondamentali allineati), + **non** quando diverge. La richiesta "shorta il titolo che sale su brutte notizie" è il caso + contrarian, più difficile e storicamente più punito (squeeze, perdita illimitata). + +## Eseguibilità (muro) + +Short = **borrow** (locate + fee; hard-to-borrow caro/assente), **perdita illimitata**, **squeeze**, +**PDT $25k** per i day-trade, IB instabile qui, $600, universo single-stock (non i nostri ETF). NON +deployabile. + +## Verdetto + +**Non un edge schierabile, né dimostrabile ora.** Deliverable onesto = lo **screener forward** +(funzionante, da dati di rete) + il log che accumula i candidati per una validazione futura, con +la chiara avvertenza che l'edge è **non provato** e che la *direzione contrarian* della scommessa +(short sulla forza) è la parte fragile. Strumento utile (fond+news+momentum da rete in un colpo), +tesi da maneggiare con scetticismo. Nessun impatto sul book live (branch separato). diff --git a/scripts/research/eq_fundnews_short.py b/scripts/research/eq_fundnews_short.py new file mode 100644 index 0000000..662d3c1 --- /dev/null +++ b/scripts/research/eq_fundnews_short.py @@ -0,0 +1,194 @@ +"""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() diff --git a/tests/test_eq_fundnews_short.py b/tests/test_eq_fundnews_short.py new file mode 100644 index 0000000..5d9c83e --- /dev/null +++ b/tests/test_eq_fundnews_short.py @@ -0,0 +1,40 @@ +"""Lock 2026-06-26 (fond/news short screener): scoring puro testabile offline. La strategia NON e' +backtestabile (fondamentali snapshot = look-ahead) -> deliverable = screener forward + verdetto. +Diario docs/diary/2026-06-26-equity-fundnews-short.md.""" +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from eq_fundnews_short import fund_neg_score, headline_sentiment, UNIVERSE # type: ignore + + +def test_fund_neg_high_for_bad_company(): + """recMean verso sell + surprise negative + ricavi in calo + analisti a sell -> score alto.""" + s = fund_neg_score(rec_mean=4.5, surp=[-0.10, -0.05], rev_g=-0.08, sell_skew=0.5) + assert s > 0.7 + + +def test_fund_neg_low_for_healthy_company(): + """recMean buy + surprise positive + ricavi su + nessun sell -> score basso.""" + s = fund_neg_score(rec_mean=1.5, surp=[0.10, 0.06], rev_g=0.20, sell_skew=0.0) + assert s < 0.2 + + +def test_fund_neg_none_when_no_data(): + assert fund_neg_score(None, None, None, None) is None + + +def test_headline_sentiment_directions(): + neg, n_, p_ = headline_sentiment(["Stock plunges as company warns and cuts guidance"]) + pos, _, _ = headline_sentiment(["Shares surge as earnings beat, analysts upgrade"]) + neutral, _, _ = headline_sentiment(["Company holds annual shareholder meeting"]) + assert neg > 0.6 and pos < 0.4 and neutral == 0.0 + + +def test_universe_nonempty(): + assert len(UNIVERSE) >= 10