research(equity): screener short 'fond/news neg ma prezzo su' (forward, edge non provato)

Goal chiarito: short su titoli con fondamentali/notizie negativi ma prezzo in salita.

Gate dati (v2.0.0): NON backtestabile -> fondamentali da rete = snapshot correnti,
applicarli a prezzi passati = look-ahead. Come la term-structure: solo screener forward.

Costruito screener da dati di rete tokenless: fondamentali strutturati Yahoo
(quoteSummary via crumb: recommendationMean/surprise/revGrowth/sell-skew) + sentiment
headline + momentum 1m/3m. SHORT cand = (fond/news neg) AND prezzo su. Run live oggi:
nessun candidato (mercato in flessione ampia -> gamba 'prezzo su' non scatta).

Intuizione chiave: shortare la FORZA combatte momentum + PEAD; il rialzo 'malgrado'
brutte notizie spesso prezza info che i fondamentali trailing non hanno -> e' la
versione contrarian/rischiosa dell'anomalia. La versione pulita = short su fondamentali
deboli quando il prezzo CONFERMA (scende), non quando diverge.

Eseguibilita': borrow/squeeze/perdita illimitata/PDT 5k/IB instabile/00 -> non deployabile.

- scripts/research/eq_fundnews_short.py (+ forward log data/raw/fundnews_short_screen.parquet)
- tests/test_eq_fundnews_short.py (scoring puro offline)
- docs/diary/2026-06-26-equity-fundnews-short.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-26 19:51:22 +00:00
parent e28176efef
commit a158d0e2ae
3 changed files with 292 additions and 0 deletions
@@ -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).
+194
View File
@@ -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&quotesCount=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()
+40
View File
@@ -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