research(equity): scalping 'sottoquotate' su IB + check dati dalla rete
Goal: comprare azioni IB quando sottoquotate, con verifica incrociata dei dati. 1) CHECK DATI DALLA RETE (il pezzo richiesto): IB certificato (eq_*, ADJUSTED) vs Yahoo adjclose (sorgente rete indipendente, tokenless). Tutti CONCORDE <=1.2bps sui rendimenti. Teaching: un confronto naif (adjusted vs grezzo) falso-allarmava 4/6 ticker a 30-52bps = TUTTO stacco dividendo -> ogni divergenza va SPIEGATA prima di gridare 'feed sporco' (v2.0.0). Strumento riutilizzabile (validatore feed / pre-trade price-check). 2) Scalping 'sottoquotate': NON testabile (no dati intraday) ne' eseguibile (PDT rule 5k blocca il day-trading; IB instabile). Versione testabile = swing MR (Connors RSI2<10 + MA200, exit MA5): Sharpe modesto (SPY 0.75/0.70) ma NON batte il buy&hold (B&H hold 0.81), expo 13%, CAGR 2-5%, fee-sensitive. Niente edge schierabile. - scripts/research/eq_meanrev_ib.py - tests/test_eq_meanrev.py (incl. network check) - docs/diary/2026-06-26-equity-meanrev-network-check.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
# 2026-06-26 — Scalping azioni IB "quando sottoquotate" + check dati dalla rete
|
||||||
|
|
||||||
|
**Goal utente:** su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con
|
||||||
|
verifica incrociata dei dati dalla rete. Branch `research/equity-meanrev-ib`,
|
||||||
|
script `scripts/research/eq_meanrev_ib.py`. Due pezzi.
|
||||||
|
|
||||||
|
## 1) CHECK DATI DALLA RETE — il pezzo richiesto (e perfettamente in-metodo)
|
||||||
|
|
||||||
|
Confronto i dati certificati IB (`data/raw/eq_*_1d.parquet`, ADJUSTED_LAST) con una **sorgente di
|
||||||
|
rete indipendente** (Yahoo Finance chart API, tokenless). È la lezione fondante del progetto: non
|
||||||
|
fidarti di un feed solo (il disastro v2.0.0 era un feed contaminato).
|
||||||
|
|
||||||
|
**Risultato (180g, BTC… no: 6 ETF):** dopo l'allineamento corretto **tutti CONCORDE**, deviazione
|
||||||
|
sui rendimenti **≤1.2bps**, ultimo close identico (0.0bps). Il feed IB è validato.
|
||||||
|
|
||||||
|
**La lezione (errore che ho fatto e corretto):** un primo confronto naïf (nostro close *adjusted*
|
||||||
|
vs `close` *grezzo* di Yahoo) ha **falso-allarmato 4/6 ticker** a 30-52bps (SPY 30, TLT 40, HYG 52).
|
||||||
|
Le divergenze erano **tutte su singoli giorni = stacco dividendo**: confrontavo dividend-adjusted vs
|
||||||
|
non-adjusted. Usando l'`adjclose` di Yahoo (apples-to-apples) → ~0bps. **Regola: ogni divergenza va
|
||||||
|
SPIEGATA prima di gridare "feed sporco" — e prima di tradarci sopra.** Lo strumento resta come
|
||||||
|
validatore di feed riutilizzabile / template del pre-trade price-check live.
|
||||||
|
|
||||||
|
## 2) "SCALPING quando sottoquotata" — non testabile/eseguibile; testato lo swing MR
|
||||||
|
|
||||||
|
Lo **scalping intraday non è valutabile**: (a) non abbiamo dati intraday (solo `eq_*_1d`), (b) non
|
||||||
|
è eseguibile — **PDT rule**: il day-trading di azioni US sotto **$25.000** è limitato a 3
|
||||||
|
day-trade/5gg → lo scalping è *regolatoriamente bloccato* al capitale del progetto (l'analogo equity
|
||||||
|
del muro STAT-MODE a $600 sul crypto), (c) IB Gateway è instabile qui (timeout ordini diagnosticato).
|
||||||
|
|
||||||
|
Versione onesta e testabile = **swing mean-reversion** "compra quando sottoquotata": Connors RSI2<10
|
||||||
|
+ filtro trend MA200, exit a MA5. Causale (segnale ≤ close[i], entry a close[i]), netto fee, hold-out 2015+:
|
||||||
|
|
||||||
|
| ETF | FULL Sh | DD | CAGR | expo | HOLD Sh | **B&H HOLD Sh** |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| SPY | +0.75 | 15% | +5% | 13% | +0.70 | **+0.81** |
|
||||||
|
| QQQ | +0.50 | 16% | +4% | 14% | +0.56 | **+0.92** |
|
||||||
|
| IWM | +0.32 | 22% | +2% | 13% | +0.15 | +0.53 |
|
||||||
|
| DIA | +0.38 | 13% | +2% | 14% | +0.55 | +0.74 |
|
||||||
|
| EEM | +0.41 | 20% | +3% | 13% | +0.36 | +0.46 |
|
||||||
|
|
||||||
|
Fee sweep SPY: Sh 0.82 (0bps) → 0.75 (3) → 0.70 (5) → 0.58 (10bps RT).
|
||||||
|
|
||||||
|
**Lettura onesta:** la mean-reversion "buy the dip" sugli indici equity ha un edge **reale ma
|
||||||
|
modesto** (Sharpe ~0.5-0.75, investito solo ~13% dei giorni) **che NON batte il buy&hold
|
||||||
|
risk-adjusted** (B&H hold-out 0.81 su SPY vs 0.70 della MR), con CAGR piccolo (2-5%) e sensibile ai
|
||||||
|
costi. È capital-efficient in teoria (cash 87% del tempo) ma il valore aggiunto vs "tieni l'indice"
|
||||||
|
è negativo o nullo, e la frequenza *scalping* che potrebbe aggiungere valore è bloccata dalla PDT.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
- **Check dati dalla rete: FATTO e utile** — feed IB validato (≤1.2bps vs Yahoo); strumento
|
||||||
|
riutilizzabile. Unico vero keeper della sessione equity.
|
||||||
|
- **Scalping "sottoquotate": NON deployabile** — non testabile (no intraday), non eseguibile (PDT
|
||||||
|
$25k, IB instabile). Lo swing MR daily testabile **non batte il buy&hold** → niente edge schierabile.
|
||||||
|
|
||||||
|
Conferma il pattern del progetto: l'idea promettente sopravvive solo finché non incontra
|
||||||
|
eseguibilità + benchmark onesto. Nessun impatto sul book live crypto (branch separato).
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""EQ-MR — "scalping azioni IB quando sottoquotate" + CHECK DATI DALLA RETE. Analisi onesta.
|
||||||
|
|
||||||
|
L'utente chiede: su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con
|
||||||
|
verifica incrociata dei dati dalla rete. Due pezzi, entrambi in-metodo (la lezione fondante del
|
||||||
|
progetto e' "non fidarti di un feed solo" — il disastro v2.0.0):
|
||||||
|
|
||||||
|
1) CHECK DATI DALLA RETE — confronta i nostri dati certificati IB (data/raw/eq_*_1d.parquet,
|
||||||
|
ADJUSTED_LAST) con una SORGENTE INDIPENDENTE di rete (Yahoo Finance chart API, tokenless).
|
||||||
|
Confronto sui RENDIMENTI giornalieri (invarianti all'aggiustamento) + ultimo close. Verdetto:
|
||||||
|
feed CONCORDE (bps piccoli) o DIVERGENTE. E' il template del pre-trade price-check live.
|
||||||
|
|
||||||
|
2) MEAN-REVERSION "SOTTOQUOTATA" sul daily — lo *scalping* intraday NON e' backtestabile (non
|
||||||
|
abbiamo dati intraday, solo eq_*_1d) ne' eseguibile (vedi sotto), quindi si testa la versione
|
||||||
|
onesta: swing mean-reversion su ETF indice (RSI2 oversold + filtro trend MA200, exit a MA5 =
|
||||||
|
Connors). Causale (segnale <= close[i], entry a close[i]), netto fee, hold-out OOS, per-anno.
|
||||||
|
|
||||||
|
ESEGUIBILITA' (muro, da dire subito):
|
||||||
|
- PDT RULE: il day-trading di azioni US sotto $25.000 e' limitato a 3 day-trade/5gg -> lo scalping
|
||||||
|
e' regolatoriamente BLOCCATO al capitale del progetto. (l'analogo equity del muro STAT-MODE a $600).
|
||||||
|
- Niente dati INTRADAY -> scalping non backtestabile.
|
||||||
|
- IB Gateway in questo ambiente e' instabile (timeout ordini diagnosticato) -> niente HFT affidabile.
|
||||||
|
|
||||||
|
uv run python scripts/research/eq_meanrev_ib.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from eqlib import load_eq # type: ignore
|
||||||
|
|
||||||
|
SQ = np.sqrt(252)
|
||||||
|
H = {"User-Agent": "Mozilla/5.0"}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- 1) CHECK DATI DALLA RETE -----------------------------
|
||||||
|
def yahoo_daily(sym: str, rng: str = "1y") -> pd.DataFrame:
|
||||||
|
u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range={rng}&interval=1d"
|
||||||
|
j = requests.get(u, headers=H, timeout=30).json()["chart"]["result"][0]
|
||||||
|
ts = pd.to_datetime(j["timestamp"], unit="s", utc=True).normalize()
|
||||||
|
q = j["indicators"]["quote"][0]
|
||||||
|
adj = j["indicators"].get("adjclose", [{}])[0].get("adjclose", q["close"])
|
||||||
|
return pd.DataFrame({"close": q["close"], "adjclose": adj}, index=ts).dropna()
|
||||||
|
|
||||||
|
|
||||||
|
def cross_check(syms):
|
||||||
|
print("=" * 90)
|
||||||
|
print(" 1) CHECK DATI DALLA RETE — IB certificato (eq_*, ADJUSTED) vs Yahoo adjclose (rete indip.)")
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" {'sym':5} {'n com':>6} {'ret maxΔ':>9} {'ret medΔ':>9} {'last IB':>10} {'last YHOO':>10} {'Δbps':>7} esito")
|
||||||
|
for s in syms:
|
||||||
|
try:
|
||||||
|
ib = load_eq(s)["close"].astype(float)
|
||||||
|
ib.index = ib.index.normalize()
|
||||||
|
yh = yahoo_daily(s)["adjclose"] # adjusted vs adjusted (apples-to-apples)
|
||||||
|
J = pd.concat({"ib": ib, "yh": yh}, axis=1, join="inner").dropna().tail(180)
|
||||||
|
if len(J) < 20:
|
||||||
|
print(f" {s:5} overlap insufficiente"); continue
|
||||||
|
R = pd.concat({"a": J["ib"].pct_change(), "b": J["yh"].pct_change()},
|
||||||
|
axis=1, join="inner").dropna()
|
||||||
|
d = (R["a"] - R["b"]).abs()
|
||||||
|
last_bps = abs(J["ib"].iloc[-1] / J["yh"].iloc[-1] - 1) * 1e4
|
||||||
|
ok = d.max() < 0.001 and last_bps < 20 # ret entro 10bps, last entro 20bps
|
||||||
|
print(f" {s:5} {len(J):>6} {d.max()*1e4:>8.1f}b {d.median()*1e4:>8.1f}b "
|
||||||
|
f"{J['ib'].iloc[-1]:>10.2f} {J['yh'].iloc[-1]:>10.2f} {last_bps:>6.1f}b "
|
||||||
|
f"{'CONCORDE' if ok else 'DIVERGENTE -> INDAGARE'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {s:5} ERRORE: {repr(e)[:80]}")
|
||||||
|
print(" -> confronto sui RENDIMENTI adjusted-vs-adjusted: devono combaciare a pochi bps. Una")
|
||||||
|
print(" divergenza non spiegata = feed sospetto, NON tradare prima di averla capita (v2.0.0).")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- 2) MEAN-REVERSION "SOTTOQUOTATA" -----------------------------
|
||||||
|
def rsi(close: np.ndarray, n: int) -> np.ndarray:
|
||||||
|
d = np.diff(close, prepend=close[0])
|
||||||
|
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values
|
||||||
|
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values
|
||||||
|
rs = np.divide(up, dn, out=np.full_like(up, np.inf), where=dn > 0)
|
||||||
|
return 100 - 100 / (1 + rs)
|
||||||
|
|
||||||
|
|
||||||
|
def mr_target(close: np.ndarray, entry=10.0, exit_ma=5, trend=200) -> np.ndarray:
|
||||||
|
"""Posizione 0/1 DECISA a close[i] (Connors RSI2): entra se sottoquotata (RSI2<entry) in uptrend
|
||||||
|
(close>MA{trend}); esci quando close>MA{exit_ma}. Causale: usa solo dati <= close[i]."""
|
||||||
|
r2 = rsi(close, 2)
|
||||||
|
ma_t = pd.Series(close).rolling(trend).mean().values
|
||||||
|
ma_x = pd.Series(close).rolling(exit_ma).mean().values
|
||||||
|
pos = np.zeros(len(close)); inpos = False
|
||||||
|
for i in range(len(close)):
|
||||||
|
if not np.isfinite(ma_t[i]):
|
||||||
|
continue
|
||||||
|
if not inpos and close[i] > ma_t[i] and r2[i] < entry:
|
||||||
|
inpos = True
|
||||||
|
elif inpos and close[i] > ma_x[i]:
|
||||||
|
inpos = False
|
||||||
|
pos[i] = 1.0 if inpos else 0.0
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
def backtest(sym: str, fee_bps=3.0, **kw) -> pd.Series:
|
||||||
|
df = load_eq(sym); close = df["close"].astype(float).values
|
||||||
|
idx = df.index
|
||||||
|
tgt = mr_target(close, **kw)
|
||||||
|
r = np.zeros(len(close)); r[1:] = close[1:] / close[:-1] - 1.0
|
||||||
|
held = np.zeros(len(tgt)); held[1:] = tgt[:-1] # decisa a close[i-1], tenuta in i
|
||||||
|
turn = np.abs(np.diff(held, prepend=0.0))
|
||||||
|
net = held * r - (fee_bps / 1e4) * turn
|
||||||
|
return pd.Series(net, index=idx)
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(daily: pd.Series, lo=None):
|
||||||
|
if lo is not None:
|
||||||
|
daily = daily[daily.index >= lo]
|
||||||
|
rr = daily.values
|
||||||
|
sh = float(np.mean(rr) / np.std(rr) * SQ) if np.std(rr) > 0 else 0.0
|
||||||
|
eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq)
|
||||||
|
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||||
|
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||||
|
expo = float((daily != 0).mean())
|
||||||
|
return dict(sharpe=sh, dd=dd, cagr=cagr, expo=expo)
|
||||||
|
|
||||||
|
|
||||||
|
def buyhold(sym, lo=None):
|
||||||
|
df = load_eq(sym); c = df["close"].astype(float)
|
||||||
|
r = c.pct_change().fillna(0.0)
|
||||||
|
return metrics(r, lo=lo)
|
||||||
|
|
||||||
|
|
||||||
|
def run_meanrev(syms, holdout="2015-01-01"):
|
||||||
|
HO = pd.Timestamp(holdout, tz="UTC")
|
||||||
|
print("\n" + "=" * 90)
|
||||||
|
print(f" 2) MEAN-REVERSION 'SOTTOQUOTATA' (RSI2<10 + filtro MA200, exit MA5). Hold-out {holdout}.")
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" {'sym':5} | {'FULL Sh':>7} {'DD':>6} {'CAGR':>6} {'expo':>5} | "
|
||||||
|
f"{'HOLD Sh':>7} {'HOLD CAGR':>9} | {'B&H Sh':>6} {'B&H HOLD':>8}")
|
||||||
|
for s in syms:
|
||||||
|
net = backtest(s)
|
||||||
|
f = metrics(net); h = metrics(net, lo=HO)
|
||||||
|
bh = buyhold(s); bhh = buyhold(s, lo=HO)
|
||||||
|
print(f" {s:5} | {f['sharpe']:>+7.2f} {f['dd']*100:>5.0f}% {f['cagr']*100:>+5.0f}% "
|
||||||
|
f"{f['expo']*100:>4.0f}% | {h['sharpe']:>+7.2f} {h['cagr']*100:>+8.0f}% | "
|
||||||
|
f"{bh['sharpe']:>+6.2f} {bhh['sharpe']:>+7.2f}")
|
||||||
|
print(" expo = % giorni investito. B&H = buy&hold sullo stesso ETF (il benchmark onesto).")
|
||||||
|
|
||||||
|
print("\n Sweep fee (SPY) — quanto regge ai costi reali:")
|
||||||
|
for fb in (0.0, 3.0, 5.0, 10.0):
|
||||||
|
f = metrics(backtest("SPY", fee_bps=fb)); h = metrics(backtest("SPY", fee_bps=fb), lo=HO)
|
||||||
|
print(f" fee {fb:>4.1f}bps RT: FULL Sh {f['sharpe']:+.2f} HOLD Sh {h['sharpe']:+.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cross_check(["SPY", "QQQ", "IWM", "GLD", "TLT", "HYG"])
|
||||||
|
run_meanrev(["SPY", "QQQ", "IWM", "DIA", "EEM"])
|
||||||
|
print("\n" + "=" * 90)
|
||||||
|
print(" 3) ESEGUIBILITA' (il muro)")
|
||||||
|
print("=" * 90)
|
||||||
|
print(" - PDT RULE: day-trading azioni US < $25k = max 3 day-trade/5gg -> SCALPING BLOCCATO al")
|
||||||
|
print(" capitale del progetto. E' l'analogo equity del muro STAT-MODE a $600 sul crypto.")
|
||||||
|
print(" - Dati INTRADAY assenti (solo eq_*_1d) -> lo scalping non e' backtestabile, solo lo swing MR.")
|
||||||
|
print(" - IB Gateway instabile in questo ambiente (timeout ordini) -> niente HFT affidabile.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Lock 2026-06-26 (equity MR + check rete): la mean-reversion "sottoquotata" daily NON batte il
|
||||||
|
buy&hold, e il check dati dalla rete (IB vs Yahoo) deve concordare adjusted-vs-adjusted.
|
||||||
|
Diario docs/diary/2026-06-26-equity-meanrev-network-check.md."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
|
||||||
|
from eq_meanrev_ib import rsi, mr_target, backtest, metrics, buyhold, yahoo_daily # type: ignore
|
||||||
|
from eqlib import load_eq # type: ignore
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def test_rsi_in_bounds():
|
||||||
|
c = np.cumprod(1 + np.linspace(-0.02, 0.02, 300))
|
||||||
|
r = rsi(c, 2)
|
||||||
|
assert np.nanmin(r) >= 0.0 and np.nanmax(r) <= 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mr_target_binary_and_has_trades():
|
||||||
|
"""La posizione è 0/1 ed entra solo da sottoquotata (genera scambi, exposure < 50%)."""
|
||||||
|
c = load_eq("SPY")["close"].astype(float).values
|
||||||
|
pos = mr_target(c)
|
||||||
|
assert set(np.unique(pos)).issubset({0.0, 1.0})
|
||||||
|
expo = pos.mean()
|
||||||
|
assert 0.02 < expo < 0.5 # mean-reversion = poco investito
|
||||||
|
|
||||||
|
|
||||||
|
def test_meanrev_does_not_beat_buyhold_holdout():
|
||||||
|
"""Verdetto onesto: la MR daily NON batte il buy&hold risk-adjusted nel hold-out su SPY."""
|
||||||
|
HO = pd.Timestamp("2015-01-01", tz="UTC")
|
||||||
|
mr = metrics(backtest("SPY"), lo=HO)["sharpe"]
|
||||||
|
bh = buyhold("SPY", lo=HO)["sharpe"]
|
||||||
|
assert mr <= bh + 0.05 # non supera il benchmark (entro rumore)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.network
|
||||||
|
def test_ib_feed_concords_with_network_source():
|
||||||
|
"""Check dati dalla rete: i rendimenti IB (adjusted) concordano con Yahoo adjclose a pochi bps."""
|
||||||
|
ib = load_eq("SPY")["close"].astype(float); ib.index = ib.index.normalize()
|
||||||
|
yh = yahoo_daily("SPY")["adjclose"]
|
||||||
|
J = pd.concat({"a": ib, "b": yh}, axis=1, join="inner").dropna().tail(120)
|
||||||
|
d = (J["a"].pct_change() - J["b"].pct_change()).abs().dropna()
|
||||||
|
assert d.max() < 0.001 # <10bps adjusted-vs-adjusted
|
||||||
|
assert abs(J["a"].iloc[-1] / J["b"].iloc[-1] - 1) < 0.002
|
||||||
Reference in New Issue
Block a user