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:
@@ -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()
|
||||
Reference in New Issue
Block a user