Files
quant/research/single_stock_swing.py
Gahow Wang 0d983edfc0 research: individual stock swing, new frameworks, literature alpha, DCA
Four research directions beyond V7+VT36:

1. single_stock_swing: 20 famous stocks (Mag 7 + others), per-stock
   optimized swing trading. High-vol growth stocks (AMD Sharpe 1.55,
   TSLA 1.54) work best, but overfitting risk is extreme — universal
   params only TSLA is viable. Not competitive with V7.

2. v7_literature_alpha: 9 academic directions (VIX overlay, Kelly
   sizing, multi-MA, cross-asset, momentum acceleration, VIX mean-
   reversion, vol-adaptive PT, combined). V3's regime engine already
   implicitly captures most literature signals. MA130 marginally
   better than MA150 (+0.02 Sharpe, within noise).

3. new_frameworks_eval: volatility trading (SVXY risk-off) and
   calendar effects (turn-of-month). SVXY and V7 regime structurally
   conflict — SVXY crashes exactly when V7 goes risk-off.
   Turn-of-month has decent Sharpe (1.30) but only 28% annual.
   Nothing beats V7.

4. smart_dca_eval: fixed/VIX-scaled/MA-deviation/value-averaging/RSI
   DCA into SPY/QQQ/TQQQ/UPRO + V7 hybrids. Smart DCA barely beats
   fixed DCA. Any DCA hybrid dilutes V7's alpha. DCA only useful for
   new monthly contributions that can't lump-sum into V7.

Conclusion: V7+VT36 remains SOTA across all tested frameworks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 00:45:44 +08:00

277 lines
9.8 KiB
Python

"""Single-stock swing trading: adapt V3/V7 concepts to 20 famous stocks.
Strategy: long the stock when trending, cash when not.
- Trend: stock > MA + momentum > 0 + vol < cap + no dd breach
- Position sizing: vol-target overlay
- Risk mgmt: stop-loss + profit-take
- When flat: 100% cash (0% return)
Tests per-stock optimized parameters + a universal parameter set.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from itertools import product
import data_manager
import metrics
YEARS = 5
CAPITAL = 100_000
TX_COST = 0.002 # 2bp for individual stocks (wider spreads)
STOCKS = [
"AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", # Mag 7
"JPM", "V", "MA", # Financials
"JNJ", "UNH", "HD", # Healthcare / Consumer
"PG", "KO", "DIS", # Consumer staples / media
"NFLX", "AMD", "CRM", # Tech / growth
"COST", # Retail
]
def swing_backtest(
prices: pd.Series,
ma_window: int = 50,
mom_window: int = 21,
vol_window: int = 20,
vol_cap: float = 0.40,
dd_window: int = 20,
dd_stop: float = 0.08,
confirm_days: int = 2,
min_hold: int = 5,
stop_loss: float = 0.08,
profit_take: float = 0.0,
target_vol: float = 0.25,
min_scale: float = 0.3,
tx_cost: float = TX_COST,
) -> tuple[pd.Series, dict]:
"""Backtest a single-stock swing strategy. Returns (equity, stats)."""
arr = prices.to_numpy().astype(float)
n = len(arr)
need = max(ma_window, mom_window, vol_window, dd_window) + 1
# Precompute indicators
ma = pd.Series(arr, index=prices.index).rolling(ma_window).mean().to_numpy()
rets = np.diff(arr, prepend=arr[0]) / np.maximum(np.roll(arr, 1), 1e-12)
rets[0] = 0.0
vol = pd.Series(rets).rolling(vol_window, min_periods=10).std().to_numpy() * np.sqrt(252)
# State machine
in_position = False
entry_price = 0.0
bars_held = 0
pending_entry = 0
equity = np.ones(n) * CAPITAL
n_trades = 0
for t in range(1, n):
equity[t] = equity[t - 1]
if t < need:
continue
p = arr[t - 1] # yesterday's close (PIT-safe)
p_ma = ma[t - 1]
p_vol = vol[t - 1]
p_mom = arr[t - 1] / arr[t - 1 - mom_window] - 1 if arr[t - 1 - mom_window] > 0 else 0
p_dd = arr[t - 1] / np.max(arr[max(0, t - 1 - dd_window):t]) - 1
if np.isnan(p) or np.isnan(p_ma):
continue
# --- Trend signal ---
trend_bull = (p > p_ma and p_mom > 0 and
(np.isnan(p_vol) or p_vol < vol_cap) and
p_dd > -dd_stop)
if in_position:
bars_held += 1
# Apply daily return
daily_r = arr[t] / arr[t - 1] - 1 if arr[t - 1] > 0 else 0
# Vol-target scaling
scale = target_vol / p_vol if p_vol > 0.01 else 1.0
scale = np.clip(scale, min_scale, 1.0)
equity[t] = equity[t - 1] * (1 + daily_r * scale)
# Check exit conditions (using yesterday's close, PIT-safe)
gain = p / entry_price - 1 if entry_price > 0 else 0
exit_signal = False
# Stop-loss
if gain <= -stop_loss:
exit_signal = True
# Profit-take
if profit_take > 0 and gain >= profit_take:
exit_signal = True
# Trend reversal (with min_hold)
if not trend_bull and bars_held >= min_hold:
exit_signal = True
if exit_signal:
equity[t] -= equity[t] * tx_cost # exit cost
in_position = False
pending_entry = 0
n_trades += 1
else:
# Check entry
if trend_bull:
pending_entry += 1
if pending_entry >= confirm_days:
in_position = True
entry_price = arr[t] # enter at today's close
bars_held = 0
equity[t] -= equity[t] * tx_cost # entry cost
n_trades += 1
else:
pending_entry = 0
eq = pd.Series(equity, index=prices.index)
total_ret = eq.iloc[-1] / eq.iloc[0] - 1
days_in = sum(1 for t in range(need, n) if equity[t] != equity[t - 1])
pct_in = days_in / (n - need) if n > need else 0
return eq, {
"total_return": total_ret,
"n_trades": n_trades,
"pct_time_in": pct_in,
}
def optimize_stock(prices: pd.Series, stock: str) -> tuple[dict, dict, pd.Series]:
"""Grid search for best parameters on a single stock."""
param_grid = {
"ma_window": [20, 50, 100, 150],
"mom_window": [10, 21, 42],
"vol_cap": [0.30, 0.45, 0.60, 999],
"dd_stop": [0.05, 0.08, 0.12],
"stop_loss": [0.05, 0.08, 0.12],
"profit_take": [0.0, 0.15, 0.25],
"target_vol": [0.20, 0.30, 0.40],
"min_hold": [3, 5, 10],
"confirm_days": [1, 2, 3],
}
# Fixed params
fixed = {"vol_window": 20, "dd_window": 20, "min_scale": 0.3}
best_sharpe = -np.inf
best_params = {}
best_eq = None
keys = list(param_grid.keys())
values = list(param_grid.values())
for combo in product(*values):
params = dict(zip(keys, combo))
params.update(fixed)
try:
eq, stats = swing_backtest(prices, **params)
m = metrics.raw_summary(eq)
if m["sharpeRatio"] > best_sharpe and stats["n_trades"] >= 5:
best_sharpe = m["sharpeRatio"]
best_params = params.copy()
best_eq = eq
except Exception:
continue
return best_params, metrics.raw_summary(best_eq) if best_eq is not None else {}, best_eq
def main():
print("=" * 110)
print(" SINGLE-STOCK SWING TRADING: 20 FAMOUS STOCKS")
print("=" * 110)
# Download data
print(f"\nDownloading {len(STOCKS)} stocks...")
data = data_manager.update("swing", STOCKS, with_open=False)
if isinstance(data, tuple):
data = data[0]
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
data = data[data.index >= cutoff]
print(f"Period: {data.index[0].date()}{data.index[-1].date()}")
# Buy-and-hold benchmarks
print(f"\n--- Buy & Hold Returns ({YEARS}y) ---")
bh_returns = {}
for stock in STOCKS:
if stock not in data.columns:
continue
s = data[stock].dropna()
if len(s) < 100:
continue
r = s.iloc[-1] / s.iloc[0] - 1
ann = (1 + r) ** (252 / len(s)) - 1
bh_returns[stock] = ann
print(f" {stock:<6}: {ann*100:>+6.1f}% ann")
# Universal parameter set (sensible defaults)
print(f"\n--- Universal Parameters (no per-stock optimization) ---")
print(f"{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9}")
print("-" * 75)
universal = dict(ma_window=50, mom_window=21, vol_window=20, vol_cap=0.45,
dd_window=20, dd_stop=0.08, confirm_days=2, min_hold=5,
stop_loss=0.08, profit_take=0.0, target_vol=0.25, min_scale=0.3)
for stock in STOCKS:
if stock not in data.columns:
continue
prices = data[stock].dropna()
if len(prices) < 200:
continue
eq, stats = swing_backtest(prices, **universal)
m = metrics.raw_summary(eq)
bh = bh_returns.get(stock, 0)
print(f" {stock:<6} {m['annualizedReturn']*100:>+6.1f}% {m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['maxDrawdown']*100:>6.1f}% "
f"{stats['n_trades']:>7} {stats['pct_time_in']*100:>5.1f}% {bh*100:>+8.1f}%")
# Per-stock optimized
print(f"\n--- Per-Stock Optimized (grid search on Sharpe, min 5 trades) ---")
print(f"{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9} Best params")
print("-" * 130)
opt_results = []
for stock in STOCKS:
if stock not in data.columns:
continue
prices = data[stock].dropna()
if len(prices) < 200:
continue
print(f" Optimizing {stock}...", end=" ", flush=True)
params, m, eq = optimize_stock(prices, stock)
if not m:
print("FAILED")
continue
_, stats = swing_backtest(prices, **params)
bh = bh_returns.get(stock, 0)
key_params = f"MA{params.get('ma_window')}/mom{params.get('mom_window')}/SL{int(params.get('stop_loss',0)*100)}%/PT{int(params.get('profit_take',0)*100)}%/VT{int(params.get('target_vol',0)*100)}%"
print(f"{m['annualizedReturn']*100:>+5.1f}% Sharpe={m['sharpeRatio']:.2f} [{key_params}]")
opt_results.append((stock, m, stats, params, bh))
print(f"\n{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9} Params")
print("-" * 130)
opt_results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
for stock, m, stats, params, bh in opt_results:
key_params = f"MA{params.get('ma_window')}/mom{params.get('mom_window')}/SL{int(params.get('stop_loss',0)*100)}%/PT{int(params.get('profit_take',0)*100)}%/VT{int(params.get('target_vol',0)*100)}%"
beat = "" if m["annualizedReturn"] > bh else ""
print(f" {stock:<6} {m['annualizedReturn']*100:>+6.1f}% {m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['maxDrawdown']*100:>6.1f}% "
f"{stats['n_trades']:>7} {stats['pct_time_in']*100:>5.1f}% {bh*100:>+8.1f}% {beat} {key_params}")
winners = sum(1 for _, m, _, _, bh in opt_results if m["annualizedReturn"] > bh)
print(f"\n Beat buy-and-hold: {winners}/{len(opt_results)} stocks")
avg_sharpe = np.mean([m["sharpeRatio"] for _, m, _, _, _ in opt_results])
print(f" Average Sharpe: {avg_sharpe:.2f}")
if __name__ == "__main__":
main()