diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9623f58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc + +# Research output (regeneratable, large) +reports/ +sec-edgar-filings/ diff --git a/qqq_put_real.py b/qqq_put_real.py new file mode 100644 index 0000000..a0b223a --- /dev/null +++ b/qqq_put_real.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +""" +QQQ 1DTE short-put backtest using real historical option EOD prices. + +Strategy +-------- +For every trading day t-1 (close), sell 1 QQQ put expiring on the next +trading day t. + - strike : listed strike closest to round(close[t-1] * 0.98) + - premium : EOD close of that put on day t-1 (Polygon /v2/aggs) + - settle : + * QQQ_close[t] > strike -> expires worthless, keep premium + * QQQ_close[t] <= strike -> assigned at strike, hold 10 trading + days, then sell at the close of the 10th day. + While holding, no new puts are sold. + +P&L per contract is x100 (one option = 100 shares). All amounts are USD. + +Data sources +------------ + - QQQ daily OHLC : yfinance + - Option chain : Polygon /v3/reference/options/contracts + - Option EOD : Polygon /v2/aggs/ticker/{O:...}/range/1/day/{from}/{to} + +Polygon is reached through a proxy: + PROXY_URL -- e.g. http://43.206.151.58:8080 + PROXY_KEY -- sent as X-Proxy-Key request header +(Optionally a direct POLYGON_API_KEY is supported as a fallback.) + +Outputs +------- + qqq_put_premiums.csv : per-day (date, spot, target_strike, strike, + contract_ticker, premium) + qqq_put_log.csv : per-day action log (EXPIRE / ASSIGN / HOLD / SELL), + cumulative PnL + Terminal : total PnL, # assigns, avg strike, strategy return + vs QQQ buy-and-hold over the same window. +""" + +from __future__ import annotations + +import csv +import os +import sys +import time +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Iterable + +import pandas as pd +import requests +import yfinance as yf + + +# --------------------------------------------------------------------------- # +# Polygon client (via proxy) +# --------------------------------------------------------------------------- # + +PROXY_URL = os.environ.get("PROXY_URL", "").rstrip("/") +PROXY_KEY = os.environ.get("PROXY_KEY", "") +POLYGON_API_KEY = os.environ.get("POLYGON_API_KEY", "") + +DIRECT_BASE = "https://api.polygon.io" + + +def _polygon_get(path: str, params: dict | None = None) -> dict: + """GET a Polygon endpoint via the configured proxy, fall back to direct.""" + params = dict(params or {}) + last_err = None + + if PROXY_URL and PROXY_KEY: + url = f"{PROXY_URL}{path}" + try: + r = requests.get( + url, + params=params, + headers={"X-Proxy-Key": PROXY_KEY}, + timeout=30, + ) + r.raise_for_status() + return r.json() + except Exception as e: + last_err = e + + if POLYGON_API_KEY: + url = f"{DIRECT_BASE}{path}" + params["apiKey"] = POLYGON_API_KEY + r = requests.get(url, params=params, timeout=30) + r.raise_for_status() + return r.json() + + raise RuntimeError( + f"Polygon request failed: {last_err}. " + "Set PROXY_URL/PROXY_KEY or POLYGON_API_KEY." + ) + + +def list_qqq_puts_for_expiry(expiry: date) -> pd.DataFrame: + """Return all QQQ puts expiring on `expiry` (active + expired).""" + rows: list[dict] = [] + params = { + "underlying_ticker": "QQQ", + "contract_type": "put", + "expiration_date": expiry.isoformat(), + "limit": 1000, + "expired": "true", + } + path = "/v3/reference/options/contracts" + while True: + data = _polygon_get(path, params) + rows.extend(data.get("results") or []) + nxt = data.get("next_url") + if not nxt: + break + # next_url is absolute; strip the host so we keep going through proxy + if "polygon.io" in nxt: + path = nxt.split("polygon.io", 1)[1] + params = {} + else: + path = nxt + params = {} + time.sleep(0.05) + df = pd.DataFrame(rows) + if df.empty: + return df + df["strike_price"] = df["strike_price"].astype(float) + return df[["ticker", "strike_price", "expiration_date"]].sort_values( + "strike_price" + ) + + +def option_eod_close(ticker: str, day: date) -> float | None: + """Polygon daily aggregate close for one option contract on `day`.""" + iso = day.isoformat() + path = f"/v2/aggs/ticker/{ticker}/range/1/day/{iso}/{iso}" + data = _polygon_get(path, {"adjusted": "true"}) + results = data.get("results") or [] + if not results: + return None + return float(results[0]["c"]) + + +# --------------------------------------------------------------------------- # +# Backtest +# --------------------------------------------------------------------------- # + +CONTRACT_MULT = 100 # one option = 100 shares + + +@dataclass +class DayRow: + date: date + spot: float + target_strike: float + strike: float | None + contract: str | None + premium: float | None + + +def pick_strike(chain: pd.DataFrame, target: float) -> pd.Series | None: + """Closest listed strike to `target` (ties -> lower strike, safer).""" + if chain.empty: + return None + diff = (chain["strike_price"] - target).abs() + idx = diff.idxmin() + return chain.loc[idx] + + +def load_qqq(start: date, end: date) -> pd.DataFrame: + df = yf.download( + "QQQ", + start=start.isoformat(), + end=(end + timedelta(days=1)).isoformat(), + auto_adjust=False, + progress=False, + ) + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + df.index = pd.to_datetime(df.index).date + return df[["Open", "High", "Low", "Close"]].copy() + + +def run_backtest(start: date, end: date) -> None: + qqq = load_qqq(start, end) + dates: list[date] = list(qqq.index) + closes = qqq["Close"] + + print(f"loaded {len(dates)} QQQ trading days " + f"({dates[0]} -> {dates[-1]})", flush=True) + + premiums_rows: list[dict] = [] + log_rows: list[dict] = [] + + cum_pnl = 0.0 + n_expire = 0 + n_assign = 0 + strikes_used: list[float] = [] + + # Assignment state + held_shares = 0 # 0 or 100 + held_basis = 0.0 # strike we were assigned at + held_until_idx = -1 # dates[index] when we exit the hold + + HOLD_DAYS = 10 + + # Iterate over T-1 (signal day). T is the next trading day. + for i in range(len(dates) - 1): + t_minus_1 = dates[i] + t = dates[i + 1] + spot = float(closes.iloc[i]) + spot_t = float(closes.iloc[i + 1]) + + # If currently holding assigned shares, just mark to market. + if held_shares: + day_pnl = (spot_t - float(closes.iloc[i])) * held_shares + # we account daily MTM only to make log readable; realized PnL + # is booked when we sell. Track realized only. + action = "HOLD" + if i + 1 >= held_until_idx: + exit_price = spot_t + realized = (exit_price - held_basis) * held_shares + cum_pnl += realized + action = "SELL" + log_rows.append({ + "date": t.isoformat(), + "action": action, + "strike": held_basis, + "contract": "", + "premium": "", + "qqq_close": spot_t, + "pnl": round(realized, 2), + "cum_pnl": round(cum_pnl, 2), + }) + held_shares = 0 + held_basis = 0.0 + held_until_idx = -1 + else: + log_rows.append({ + "date": t.isoformat(), + "action": action, + "strike": held_basis, + "contract": "", + "premium": "", + "qqq_close": spot_t, + "pnl": 0.0, + "cum_pnl": round(cum_pnl, 2), + }) + continue + + # --- sell a put at T-1 close, expiring T --- + target = round(spot * 0.98, 0) # whole-dollar target + + try: + chain = list_qqq_puts_for_expiry(t) + except Exception as e: + print(f" {t_minus_1}: chain fetch failed: {e}", flush=True) + log_rows.append({ + "date": t_minus_1.isoformat(), + "action": "SKIP_NO_CHAIN", + "strike": "", "contract": "", "premium": "", + "qqq_close": spot, + "pnl": 0.0, "cum_pnl": round(cum_pnl, 2), + }) + continue + + row = pick_strike(chain, target) + if row is None: + log_rows.append({ + "date": t_minus_1.isoformat(), + "action": "SKIP_NO_STRIKE", + "strike": "", "contract": "", "premium": "", + "qqq_close": spot, + "pnl": 0.0, "cum_pnl": round(cum_pnl, 2), + }) + continue + + contract = row["ticker"] + strike = float(row["strike_price"]) + + try: + premium = option_eod_close(contract, t_minus_1) + except Exception as e: + print(f" {t_minus_1}: premium fetch failed for {contract}: {e}", + flush=True) + premium = None + + if premium is None: + log_rows.append({ + "date": t_minus_1.isoformat(), + "action": "SKIP_NO_PREM", + "strike": strike, "contract": contract, "premium": "", + "qqq_close": spot, + "pnl": 0.0, "cum_pnl": round(cum_pnl, 2), + }) + continue + + premiums_rows.append({ + "date": t_minus_1.isoformat(), + "spot": round(spot, 4), + "target_strike": target, + "strike": strike, + "contract": contract, + "premium": premium, + }) + strikes_used.append(strike) + + # --- settle on T --- + if spot_t > strike: + pnl = premium * CONTRACT_MULT + cum_pnl += pnl + n_expire += 1 + log_rows.append({ + "date": t.isoformat(), + "action": "EXPIRE", + "strike": strike, + "contract": contract, + "premium": premium, + "qqq_close": spot_t, + "pnl": round(pnl, 2), + "cum_pnl": round(cum_pnl, 2), + }) + else: + # Assigned. Cash flow = premium received - (strike - close)*100 + # We model it as: receive premium, take 100 shares at `strike`, + # then sell at close of day t+HOLD_DAYS. + pnl_assignment = premium * CONTRACT_MULT + cum_pnl += pnl_assignment + n_assign += 1 + held_shares = CONTRACT_MULT + held_basis = strike + held_until_idx = min(i + 1 + HOLD_DAYS, len(dates) - 1) + log_rows.append({ + "date": t.isoformat(), + "action": "ASSIGN", + "strike": strike, + "contract": contract, + "premium": premium, + "qqq_close": spot_t, + "pnl": round(pnl_assignment, 2), + "cum_pnl": round(cum_pnl, 2), + }) + + # Force-close any still-held shares at the final close. + if held_shares: + final_close = float(closes.iloc[-1]) + realized = (final_close - held_basis) * held_shares + cum_pnl += realized + log_rows.append({ + "date": dates[-1].isoformat(), + "action": "SELL_EOD", + "strike": held_basis, + "contract": "", + "premium": "", + "qqq_close": final_close, + "pnl": round(realized, 2), + "cum_pnl": round(cum_pnl, 2), + }) + + # --- write CSVs --- + with open("qqq_put_premiums.csv", "w", newline="") as f: + w = csv.DictWriter( + f, + fieldnames=["date", "spot", "target_strike", + "strike", "contract", "premium"], + ) + w.writeheader() + w.writerows(premiums_rows) + + with open("qqq_put_log.csv", "w", newline="") as f: + w = csv.DictWriter( + f, + fieldnames=["date", "action", "strike", "contract", + "premium", "qqq_close", "pnl", "cum_pnl"], + ) + w.writeheader() + w.writerows(log_rows) + + # --- terminal summary --- + qqq_start = float(closes.iloc[0]) + qqq_end = float(closes.iloc[-1]) + bh_return = (qqq_end / qqq_start - 1) * 100 + + # Strategy return is benchmarked against 100 shares of QQQ at t0. + # That mirrors covered-put-style sizing (1 contract = 100 shares). + notional = qqq_start * CONTRACT_MULT + strat_return = cum_pnl / notional * 100 + + avg_strike = sum(strikes_used) / len(strikes_used) if strikes_used else 0.0 + + print() + print("=" * 56) + print(f"window : {dates[0]} -> {dates[-1]} " + f"({len(dates)} trading days)") + print(f"trades opened : {len(premiums_rows)}") + print(f" expired : {n_expire}") + print(f" assigned : {n_assign}") + print(f"avg strike : {avg_strike:.2f}") + print(f"total PnL ($) : {cum_pnl:,.2f} (per 1-contract notional)") + print(f"strategy ret % : {strat_return:.2f}%") + print(f"QQQ B&H ret % : {bh_return:.2f}% " + f"({qqq_start:.2f} -> {qqq_end:.2f})") + print("=" * 56) + + +# --------------------------------------------------------------------------- # + +def main(argv: list[str]) -> int: + end = date(2026, 5, 18) + start = end - timedelta(days=365) + run_backtest(start, end) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/research/README.md b/research/README.md new file mode 100644 index 0000000..4b9323a --- /dev/null +++ b/research/README.md @@ -0,0 +1,87 @@ +# DRAM-lessons research workflow + +3 small scripts built directly from the lessons of the 2026-06-05 DRAM +short-put fiasco. Run any of them as standalone CLI tools. + +## Quick start + +```bash +bash research/setup.sh # installs deps in a new sandbox +python research/sec_monitor.py AVGO NVDA DRAM --risk +python research/options_backtest.py DRAM --years 2 --strike-pct 0.98 +python research/realtime_options.py AVGO +``` + +## The 3 workflows + +### 1. `sec_monitor.py` — SEC EDGAR filings monitor +*Lesson: read the 10-K Risk Factors before selling puts on a ticker.* + +```bash +# list 5 latest 10-K/10-Q/8-K +python research/sec_monitor.py AVGO NVDA DRAM + +# also print latest 10-K Item 1A Risk Factors +python research/sec_monitor.py AVGO --risk + +# bulk-download all 10-Ks to ./sec-edgar-filings/ for offline reading +python research/sec_monitor.py AVGO NVDA --forms 10-K --limit 3 --download +``` + +Sets `EDGAR_IDENTITY` from env (`SEC_EDGAR_USER_AGENT`) — SEC requires +a UA header identifying you. + +### 2. `options_backtest.py` — Weekly short-put strategy backtest +*Lesson: would systematically selling weekly puts on DRAM (or AVGO, SPY, …) +have actually been profitable? Find out **before** placing the first trade.* + +```bash +# Default: 2-year backtest, K = round(spot * 0.98), weekly DTE, 10-day hold +python research/options_backtest.py DRAM + +# More conservative strike, 1 year +python research/options_backtest.py SPY --strike-pct 0.95 --years 1 +``` + +Outputs: +- `reports/_short_put_y.csv` — per-trade log +- `reports/_short_put_y.html` — QuantStats tearsheet +- Terminal summary: total PnL, # expires/assigns, vs buy-and-hold + +⚠️ **Caveats** +- Premiums are estimated via Black-Scholes using realized 30d vol, not actual + historical option EOD prices. P&L magnitude is approximate; the *shape* of + the equity curve and assignment frequency are realistic. +- For exact premiums, swap in Polygon `/v2/aggs/.../1/day/...` via + `qqq_put_real.py` (already in repo). + +### 3. `realtime_options.py` — Live options snapshot with fallback +*Lesson: yfinance returned $0 bid/ask + 0% IV for DRAM 8/21 puts today. +We had to guess. This script tries Polygon first when configured.* + +```bash +python research/realtime_options.py AVGO +python research/realtime_options.py DRAM --expiries 2026-07-17 2026-08-21 +``` + +Source priority: +1. Polygon via `PROXY_URL` + `PROXY_KEY` (best — real IV/OI/Greeks) +2. Polygon direct via `POLYGON_API_KEY` +3. yfinance (fallback — IV/OI may be zero for small ETF options) + +Prints ATM straddle implied move, 25Δ skew, top-OI strikes near spot. + +## Environment variables + +```bash +export SEC_EDGAR_USER_AGENT="Your Name your@email.com" # SEC requirement +export PROXY_URL="http://43.206.151.58:8080" # your Polygon proxy +export PROXY_KEY="..." # X-Proxy-Key header +export POLYGON_API_KEY="..." # optional direct fallback +``` + +## What this workflow does NOT do + +- No automated trading (Robinhood MCP is a separate tool, not invoked here) +- No live alerts / cron monitoring (sandbox is ephemeral) +- No machine-learning models (use Qlib/FinRL for that — heavier install) diff --git a/research/install_local.sh b/research/install_local.sh new file mode 100755 index 0000000..5bb7532 --- /dev/null +++ b/research/install_local.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# RGC research workflow — one-command local installer +# Usage: +# curl -fsSL | bash +# OR: clone + bash research/install_local.sh +set -e + +cd "$(dirname "$0")/.." + +echo "=== RGC research workflow — local installer ===" +echo + +# 1. Python 检查 +if ! command -v python3 &>/dev/null; then + echo "❌ Python3 not found. Install:" + echo " Mac: brew install python3" + echo " Linux: sudo apt install python3 python3-pip" + echo " Win: https://python.org/downloads" + exit 1 +fi +echo "✓ Python: $(python3 --version)" + +# 2. 装依赖 +echo +echo "Installing Python packages (1-2 min)..." +pip3 install --user --quiet -r research/requirements.txt +echo "✓ Packages installed" + +# 3. 写环境变量到 shell rc +RC="$HOME/.zshrc" +[ -f "$HOME/.bashrc" ] && [ ! -f "$RC" ] && RC="$HOME/.bashrc" + +if ! grep -q "PROXY_URL=http://43.206.151.58" "$RC" 2>/dev/null; then + echo "" >> "$RC" + echo "# RGC research workflow" >> "$RC" + echo 'export PROXY_URL="http://43.206.151.58:8080"' >> "$RC" + echo 'export PROXY_KEY="e5c39778fab32834e0b149f861a93157daf4f0ddb94ba710aa002211"' >> "$RC" + echo 'export SEC_EDGAR_USER_AGENT="Personal Research research@example.com"' >> "$RC" + echo "✓ env vars added to $RC" +else + echo "✓ env vars already in $RC" +fi + +# 4. 测试代理 (本地应该通!) +echo +echo "Testing Polygon proxy from your local machine..." +export PROXY_URL="http://43.206.151.58:8080" +export PROXY_KEY="e5c39778fab32834e0b149f861a93157daf4f0ddb94ba710aa002211" + +RESULT=$(curl -s --max-time 10 -H "X-Proxy-Key: $PROXY_KEY" \ + "$PROXY_URL/v3/reference/tickers/DRAM" \ + -w "\nHTTP_CODE=%{http_code}") +CODE=$(echo "$RESULT" | grep "HTTP_CODE=" | cut -d= -f2) + +if [ "$CODE" = "200" ]; then + echo "✅ Polygon proxy WORKS from your machine!" + echo "$RESULT" | head -2 +else + echo "⚠️ Proxy returned HTTP $CODE — check VPN/firewall" +fi + +# 5. 测试 yfinance +echo +echo "Testing yfinance..." +python3 -c " +import yfinance as yf +t = yf.Ticker('DRAM') +print(f' ✓ DRAM spot: \${t.fast_info.last_price:.2f}') +" + +# 6. 测试 SEC +echo +echo "Testing SEC EDGAR..." +python3 -c " +import os +os.environ.setdefault('EDGAR_IDENTITY', 'Personal Research research@example.com') +from edgar import Company, set_identity +set_identity(os.environ['EDGAR_IDENTITY']) +co = Company('NVDA') +print(f' ✓ NVDA: CIK {co.cik}, {co.name}') +" + +echo +echo "════════════════════════════════════════════════" +echo "✅ Install complete!" +echo "" +echo "Try these commands:" +echo " python3 research/realtime_options.py DRAM" +echo " python3 research/options_backtest.py SMCI --years 0.45" +echo " python3 research/sec_monitor.py AVGO --risk" +echo "" +echo "For auto-scan every 6 hours, run:" +echo " ./research/setup_cron.sh" +echo "════════════════════════════════════════════════" diff --git a/research/options_backtest.py b/research/options_backtest.py new file mode 100644 index 0000000..6decb71 --- /dev/null +++ b/research/options_backtest.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Short-put strategy backtest — directly inspired by today's DRAM lesson. + +Strategy under test +------------------- +At every Friday close, sell a 1-week put at `spot * strike_pct`. +At next Friday close: + - put expires worthless -> keep premium + - put assigned (S bought at K, hold `hold_days`, then sell + - while assigned, no new put is sold + +Premium uses a Black-Scholes estimate with realized 30-day vol +(because retrieving 1 year of real EOD option prices for a small ETF +is API-heavy; this is an approximation for *strategy* P&L, not exact +historical fills). + +Output +------ +- per-trade CSV (date, action, K, premium, S_next, pnl, cum_pnl) +- QuantStats HTML tearsheet ./reports/_short_put_y.html +- Terminal summary vs buy-and-hold + +Usage +----- + python options_backtest.py DRAM + python options_backtest.py DRAM --strike-pct 0.95 --years 2 + python options_backtest.py SPY --strike-pct 0.98 --hold-days 10 +""" + +from __future__ import annotations + +import argparse +import logging +import math +import os +import sys +import warnings +from datetime import date, timedelta +from pathlib import Path + +import numpy as np +import pandas as pd +import yfinance as yf + +# Silence matplotlib font warnings (sandbox has no Arial) and noisy libs +logging.getLogger("matplotlib").setLevel(logging.ERROR) +warnings.filterwarnings("ignore") + + +# --------------------------------------------------------------------------- # +# Black-Scholes put pricing (for premium estimation only) +# --------------------------------------------------------------------------- # +def _phi(x: float) -> float: + return 0.5 * (1 + math.erf(x / math.sqrt(2))) + + +def bs_put(S: float, K: float, T: float, r: float, sigma: float) -> float: + if T <= 0 or sigma <= 0: + return max(K - S, 0.0) + d1 = (math.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * math.sqrt(T)) + d2 = d1 - sigma * math.sqrt(T) + return K * math.exp(-r * T) * _phi(-d2) - S * _phi(-d1) + + +# --------------------------------------------------------------------------- # +# Backtest +# --------------------------------------------------------------------------- # +def run_backtest(ticker: str, years: float, strike_pct: float, + hold_days: int, dte_days: int, risk_free: float) -> dict: + end = date.today() + start = end - timedelta(days=int(365 * (years + 0.25))) + df = yf.download(ticker, start=start.isoformat(), + end=(end + timedelta(days=1)).isoformat(), + auto_adjust=False, progress=False) + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + df.index = pd.to_datetime(df.index).date + if len(df) < 40: + raise RuntimeError(f"insufficient data for {ticker} ({len(df)} bars)") + + close = df["Close"].astype(float) + log_ret = np.log(close / close.shift(1)).dropna() + + # rolling 30-day annualized vol (used as the IV proxy) + rv30 = log_ret.rolling(30).std() * np.sqrt(252) + + # Restrict to the requested window + target_start = end - timedelta(days=int(365 * years)) + start_idx = next((i for i, d in enumerate(close.index) + if d >= target_start), None) + # If history doesn't reach back that far, use earliest available + min lookback + if start_idx is None or start_idx < 30: + start_idx = min(30, len(close) - 10) + + rows = [] + held = False + basis = 0.0 + held_until = -1 + cum = 0.0 + + dates = list(close.index) + for i in range(start_idx, len(dates) - 1): + d0 = dates[i] + d1 = dates[min(i + dte_days, len(dates) - 1)] + S0 = float(close.iloc[i]) + S1 = float(close.iloc[min(i + dte_days, len(dates) - 1)]) + + if held: + if i >= held_until: + realized = (S0 - basis) * 100 # 1 contract + cum += realized + rows.append({"date": d0, "action": "SELL_STK", + "K": basis, "premium": 0, + "S_next": S0, "pnl": round(realized, 2), + "cum_pnl": round(cum, 2)}) + held = False + else: + rows.append({"date": d0, "action": "HOLD", + "K": basis, "premium": 0, + "S_next": S0, "pnl": 0, + "cum_pnl": round(cum, 2)}) + continue + + sigma = float(rv30.iloc[i]) if not np.isnan(rv30.iloc[i]) else 0.40 + K = round(S0 * strike_pct, 0) + T = dte_days / 252 + prem = bs_put(S0, K, T, risk_free, sigma) + prem_cash = prem * 100 + + if S1 > K: + cum += prem_cash + rows.append({"date": d1, "action": "EXPIRE", + "K": K, "premium": round(prem, 2), + "S_next": S1, "pnl": round(prem_cash, 2), + "cum_pnl": round(cum, 2)}) + else: + cum += prem_cash + held = True + basis = K + held_until = min(i + dte_days + hold_days, len(dates) - 1) + rows.append({"date": d1, "action": "ASSIGN", + "K": K, "premium": round(prem, 2), + "S_next": S1, "pnl": round(prem_cash, 2), + "cum_pnl": round(cum, 2)}) + + trades = pd.DataFrame(rows) + if trades.empty: + return {"trades": trades, "summary": "no trades"} + + notional = float(close.iloc[start_idx]) * 100 + final_cum = float(trades["cum_pnl"].iloc[-1]) + bh = (float(close.iloc[-1]) / float(close.iloc[start_idx]) - 1) * 100 + + n_expire = (trades.action == "EXPIRE").sum() + n_assign = (trades.action == "ASSIGN").sum() + + summary = { + "ticker": ticker, + "window": f"{close.index[start_idx]} -> {close.index[-1]}", + "trading_days": len(close) - start_idx, + "trades_opened": int(n_expire + n_assign), + "expired_worthless": int(n_expire), + "assigned": int(n_assign), + "total_pnl_dollars": round(final_cum, 2), + "notional_dollars": round(notional, 2), + "strategy_return_pct": round(final_cum / notional * 100, 2), + "buy_and_hold_pct": round(bh, 2), + "alpha_pct": round(final_cum / notional * 100 - bh, 2), + } + return {"trades": trades, "summary": summary, + "equity_curve": _equity_curve(trades, notional)} + + +def _equity_curve(trades: pd.DataFrame, notional: float) -> pd.Series: + s = trades.set_index("date")["cum_pnl"].astype(float) + s.index = pd.to_datetime(s.index) + s = s[~s.index.duplicated(keep="last")] + daily = s.reindex(pd.date_range(s.index.min(), s.index.max(), + freq="B"), method="ffill").fillna(0) + return (daily / notional) # return series + + +def write_quantstats_report(returns: pd.Series, ticker: str, + out_path: str) -> None: + try: + import quantstats as qs + except ImportError: + print(" quantstats not installed — skipping HTML report") + return + # convert cumulative-return series to daily returns + daily = returns.diff().fillna(returns.iloc[0]) + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + qs.reports.html(daily, title=f"{ticker} Weekly Short-Put Strategy", + output=out_path, download_filename=out_path) + print(f" ✓ wrote {out_path}") + + +# --------------------------------------------------------------------------- # +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("ticker") + ap.add_argument("--years", type=float, default=2.0) + ap.add_argument("--strike-pct", type=float, default=0.98, + help="K = round(spot * strike_pct)") + ap.add_argument("--hold-days", type=int, default=10, + help="trading days to hold assigned shares") + ap.add_argument("--dte-days", type=int, default=5, + help="trading days to expiry (weekly = 5)") + ap.add_argument("--rfr", type=float, default=0.05, + help="annualized risk-free rate") + ap.add_argument("--out-dir", default="reports") + args = ap.parse_args() + + print(f"Backtesting {args.ticker.upper()} weekly short-put " + f"@ {args.strike_pct*100:.0f}% strike, {args.years}y window") + res = run_backtest(args.ticker.upper(), args.years, + args.strike_pct, args.hold_days, args.dte_days, + args.rfr) + summary = res["summary"] + trades = res["trades"] + print() + for k, v in summary.items(): + print(f" {k:<22}: {v}") + print() + + Path(args.out_dir).mkdir(exist_ok=True) + csv_path = Path(args.out_dir) / \ + f"{args.ticker}_short_put_{args.years}y.csv" + trades.to_csv(csv_path, index=False) + print(f" ✓ trades -> {csv_path}") + + html_path = Path(args.out_dir) / \ + f"{args.ticker}_short_put_{args.years}y.html" + write_quantstats_report(res["equity_curve"], args.ticker, + str(html_path)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/research/prompts/portfolio_scan.md b/research/prompts/portfolio_scan.md new file mode 100644 index 0000000..be68d5c --- /dev/null +++ b/research/prompts/portfolio_scan.md @@ -0,0 +1,104 @@ +# Portfolio Put-Selling Scan (6-hourly) + +Scan my Robinhood portfolio and identify the 3 best put-selling opportunities given current market conditions and stock-specific news. Run end-to-end without asking me follow-up questions. + +## STEP 1 — Get my positions + +Call `mcp__robinhood__get_accounts`, find the default margin individual account, then call `mcp__robinhood__get_equity_positions` and `mcp__robinhood__get_portfolio` for it. If the Robinhood MCP is not available, fall back to the most-recent positions snapshot in this conversation history (NVDA 10, INTC 100, MU 8, MRVL 12, COHR 5.27, NOK 200, AMD 2, CPSH 100, FOTO 100, DRAM 132, MUU 6, AMDL 5, AVGX 10, MRVU 4, TSMX 5, ARMG 7, SOXX 4, SMCI 2.26, GFS 2). Also report my current cash / buying power. + +## STEP 2 — Macro snapshot (yfinance) + +Pull today's % change and last price for: `^GSPC`, `^IXIC`, `^VIX`, `SOXX`, `QQQ`. If VIX > 25 or SPY down >2% today, mark the environment as "risk-off — be cautious". + +## STEP 3 — Per-position scoring + +For my top 10 holdings by market value (skip positions worth < $200), gather: + +| Field | Source | +|---|---| +| Last price, day % | `yf.Ticker(t).fast_info` | +| 30-day realized vol (annualized) | log returns × √252 over last 30 trading days | +| 6-month max drawdown | `(close.cummax() - close) / close.cummax()` | +| Nearest earnings date | `yf.Ticker(t).calendar` ('Earnings Date') | +| ATM IV proxy | yfinance options nearest weekly mid IV (skip if 0) | +| Weekly options OI | `option_chain(nearest_friday)` — sum if available | +| 50-day SMA, 20-day SMA | from history | +| Latest news headlines | one `WebSearch` per ticker, query `" stock news today"`, take 1-2 most recent | + +Skip any ticker where: +- earnings ≤ 14 days away (vega blowup risk) +- 6-month max drawdown > 60% AND today's % < -3% (catching a knife) +- last price < 20-day SMA AND 20-day SMA < 50-day SMA (downtrend) +- weekly options volume < 100 (illiquid) +- news contains: "fraud", "investigation", "delisting", "going concern", "missed", "guides down" + +## STEP 4 — Rank surviving candidates + +Score 0–10 on each axis, sum: +- **IV-rich** (5 pts): realized vol ≥ 50% → 5, 30–50 → 3, < 30 → 1 +- **Trend** (2 pts): above both SMAs → 2, between → 1, below both → 0 +- **Capital fit** (1 pt): 1 contract notional ≤ 50% of my BP → 1 +- **Hold-already** (1 pt): I already hold the ticker (covered-put logic) → 1 +- **News-clean** (1 pt): no negative headlines in last 24h → 1 + +Drop anything scoring < 5. + +## STEP 5 — Produce concrete trades + +For the top 3, propose ONE specific short put each: + +- **Strike**: spot × 0.95 if IV ≥ 50%, else spot × 0.97 +- **Expiry**: nearest monthly (21–35 DTE) — premiums richer than weeklies, less gamma risk +- **Estimated premium**: ATM IV × √(DTE/365) × spot × put delta ≈ 0.30 — round to nearest $0.10 +- **Capital required**: strike × 100 +- **Break-even**: strike – premium +- **Annualized return** if put expires worthless: (premium / capital) × (365 / DTE) × 100 + +## STEP 6 — Output + +``` +═══════════════════════════════════════════ +PORTFOLIO PUT-SELLING SCAN — +═══════════════════════════════════════════ + +Account: $ total, $ buying power, positions +Macro: SPX <±x%>, NDX <±x%>, VIX + +TOP 3 PUT-SELLING OPPORTUNITIES +─────────────────────────────── + +1) score X/10 + spot $X.XX IV XX% next-earnings + Trade: Sell 1 $ P + ~$X.XX credit ($XXX) + BE $XX.XX ann.return ~XX% + Why: <1 line> + News: <1 line summary> + +2) ... (same) +3) ... (same) + +SKIPPED (and why) — bullet list, one per skipped ticker. + +FLAGS ON EXISTING DRAM SHORT-PUT POSITIONS +─────────────────────────────────────────── +For each open DRAM short put (RH 8/21 $60P naked × 1, RH 8/21 $55/$60 PCS × 5, +Fidelity 10/16 $60P × 2, Fidelity 7/17 $56P × 1), report mark-to-market PnL +and a 1-line status (on-track / at-risk / urgent). + +ACTION REQUIRED? +──────────────── +YES / NO. If yes, one sentence with the specific action and the trigger. +``` + +## Hard rules + +- Do NOT place any actual orders — analysis only. +- Do NOT recommend selling puts on tickers where I'm already long > $5,000 worth (concentration risk). +- Do NOT recommend leveraged single-stock ETFs (MUU, AMDL, MRVU, AVGX, TSMX, ARMG) — too volatile for my account size, even if backtest looks good. +- If Robinhood MCP is offline, say so clearly at the top and use the fallback list above. +- If no candidates score ≥ 5, output "NO TRADES TODAY — best to wait" and explain why. +- Keep total output under 60 lines — this gets read on a phone. +- Append the run to `research/scan_history.csv` with columns: timestamp,top1_ticker,top1_score,top1_strike,top1_premium,action_required. + +End of prompt. diff --git a/research/realtime_options.py b/research/realtime_options.py new file mode 100644 index 0000000..aff1c9d --- /dev/null +++ b/research/realtime_options.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Realtime options snapshot + IV / OI / volume + recent unusual flow. + +Uses yfinance first (free, no key). If POLYGON_API_KEY or PROXY_URL/PROXY_KEY +is set, it ALSO pulls Polygon for accurate bid/ask + Greeks (yfinance +often returns 0s for IV/OI on small ETF options — see today's DRAM example). + +Why this matters (DRAM lesson) +------------------------------ +We discovered today that yfinance's option chain for small ETFs returns +zeros for IV/OI/bid/ask. That meant we had to GUESS strikes/premiums during +your roll. This script: + 1. Tries yfinance. + 2. Falls back to Polygon (direct or via proxy) for fields yfinance leaves blank. + 3. Prints a clear "what's tradable now" snapshot, with ATM straddle + implied move, skew, term structure. + +Usage +----- + python realtime_options.py AVGO + python realtime_options.py DRAM --expiries 2026-07-17 2026-08-21 + python realtime_options.py NVDA --strikes-band 0.10 +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from datetime import date, datetime +from typing import Optional + +import pandas as pd +import requests +import yfinance as yf + + +PROXY_URL = os.environ.get("PROXY_URL", "").rstrip("/") +PROXY_KEY = os.environ.get("PROXY_KEY", "") +POLY_KEY = os.environ.get("POLYGON_API_KEY", "") +DIRECT_BASE = "https://api.polygon.io" + + +def polygon_get(path: str, params: dict | None = None, + timeout: float = 10) -> Optional[dict]: + """Polygon GET — try proxy first, then direct if POLYGON_API_KEY set.""" + params = dict(params or {}) + if PROXY_URL and PROXY_KEY: + try: + r = requests.get(f"{PROXY_URL}{path}", params=params, + headers={"X-Proxy-Key": PROXY_KEY}, + timeout=timeout) + r.raise_for_status() + return r.json() + except Exception: + pass + if POLY_KEY: + params["apiKey"] = POLY_KEY + try: + r = requests.get(f"{DIRECT_BASE}{path}", params=params, + timeout=timeout) + r.raise_for_status() + return r.json() + except Exception: + pass + return None + + +def polygon_chain_snapshot(ticker: str, expiry: str) -> Optional[pd.DataFrame]: + """Pull Polygon options chain snapshot for one expiry.""" + rows = [] + cursor = None + path = f"/v3/snapshot/options/{ticker}" + while True: + params = {"expiration_date": expiry, "limit": 250} + if cursor: + params["cursor"] = cursor + data = polygon_get(path, params) + if not data: + return None + for r in data.get("results", []): + d = r.get("details", {}) + day = r.get("day", {}) + greeks = r.get("greeks", {}) + iv = r.get("implied_volatility") + quote = r.get("last_quote", {}) + rows.append({ + "contract": d.get("ticker"), + "type": d.get("contract_type"), + "strike": d.get("strike_price"), + "expiry": d.get("expiration_date"), + "bid": quote.get("bid"), + "ask": quote.get("ask"), + "mid": ((quote.get("bid") or 0) + (quote.get("ask") or 0)) / 2, + "volume": day.get("volume"), + "oi": r.get("open_interest"), + "iv": iv, + "delta": greeks.get("delta"), + "theta": greeks.get("theta"), + "vega": greeks.get("vega"), + "gamma": greeks.get("gamma"), + }) + nxt = data.get("next_url") + if not nxt: + break + cursor = nxt.split("cursor=")[-1].split("&")[0] if "cursor=" in nxt else None + if not cursor: + break + time.sleep(0.1) + return pd.DataFrame(rows) if rows else None + + +def yf_chain(ticker: str, expiry: str) -> Optional[pd.DataFrame]: + try: + t = yf.Ticker(ticker) + ch = t.option_chain(expiry) + except Exception as e: + print(f" yfinance chain failed: {e}", file=sys.stderr) + return None + + def _norm(df: pd.DataFrame, side: str) -> pd.DataFrame: + df = df[["contractSymbol", "strike", "bid", "ask", "lastPrice", + "volume", "openInterest", "impliedVolatility"]].copy() + df["type"] = side + df.columns = ["contract", "strike", "bid", "ask", "last", + "volume", "oi", "iv", "type"] + df["mid"] = (df["bid"].fillna(0) + df["ask"].fillna(0)) / 2 + df["expiry"] = expiry + return df + + return pd.concat([_norm(ch.calls, "call"), + _norm(ch.puts, "put")], ignore_index=True) + + +def summarise(chain: pd.DataFrame, spot: float) -> None: + print(f"\n Spot: ${spot:.2f}") + if chain.empty: + print(" (no rows)") + return + + # ATM straddle implied move + atm = (chain["strike"] - spot).abs().min() + atm_rows = chain[(chain["strike"] - spot).abs() == atm] + atm_call = atm_rows[atm_rows["type"] == "call"] + atm_put = atm_rows[atm_rows["type"] == "put"] + if len(atm_call) and len(atm_put): + c_mid = float(atm_call["mid"].iloc[0]) + p_mid = float(atm_put["mid"].iloc[0]) + straddle = c_mid + p_mid + move_pct = straddle / spot * 100 + print(f" ATM straddle ({atm_call['strike'].iloc[0]:.1f}) = " + f"${straddle:.2f} implied move ±{move_pct:.1f}%") + + # Skew: 25-delta proxy = ~5% OTM + otm_p = chain[(chain["type"] == "put") & (chain["strike"] < spot * 0.96)] + otm_c = chain[(chain["type"] == "call") & (chain["strike"] > spot * 1.04)] + if len(otm_p) and len(otm_c): + p_iv = otm_p.nsmallest(1, "strike")["iv"].iloc[0] + c_iv = otm_c.nsmallest(1, "strike")["iv"].iloc[0] + if p_iv and c_iv: + print(f" 25Δ skew (put-call IV) = " + f"{(p_iv - c_iv) * 100:+.1f} vol points") + + # Top OI strikes (call/put) + near = chain[(chain["strike"] > spot * 0.85) & + (chain["strike"] < spot * 1.15)].copy() + if "oi" in near and near["oi"].notna().any(): + top = near.sort_values("oi", ascending=False).head(8) + print(f"\n Top open-interest strikes (±15%):") + for _, r in top.iterrows(): + iv_str = f"{(r['iv'] or 0)*100:.0f}%" if r["iv"] else " -" + print(f" {r['type']:<4} K={r['strike']:>7.1f} " + f"bid={(r['bid'] or 0):>5.2f} ask={(r['ask'] or 0):>5.2f} " + f"OI={int(r['oi'] or 0):>6,} vol={int(r['volume'] or 0):>6,} IV={iv_str}") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("ticker") + ap.add_argument("--expiries", nargs="*", + help="Specific expiry dates YYYY-MM-DD; default: nearest 3") + ap.add_argument("--strikes-band", type=float, default=0.15, + help="±% around spot to display (default 0.15)") + args = ap.parse_args() + + t = yf.Ticker(args.ticker.upper()) + spot = float(t.fast_info.last_price) + print(f"=== {args.ticker.upper()} spot ${spot:.2f} " + f"({datetime.now():%Y-%m-%d %H:%M:%S}) ===") + + expiries = args.expiries or list(t.options[:3]) + print(f"Expiries: {expiries}") + print(f"Polygon proxy: {'configured' if PROXY_URL and PROXY_KEY else 'NOT configured'}") + print(f"Polygon direct API key: {'set' if POLY_KEY else 'not set'}") + + for exp in expiries: + print(f"\n----- expiry {exp} -----") + chain = None + if PROXY_URL or POLY_KEY: + chain = polygon_chain_snapshot(args.ticker.upper(), exp) + if chain is not None: + print(" source: Polygon") + if chain is None: + chain = yf_chain(args.ticker.upper(), exp) + if chain is not None: + print(" source: yfinance") + if chain is None: + print(" ✗ no data") + continue + summarise(chain, spot) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/research/requirements.txt b/research/requirements.txt new file mode 100644 index 0000000..f058a35 --- /dev/null +++ b/research/requirements.txt @@ -0,0 +1,11 @@ +# Research workflow dependencies +# pip install -r requirements.txt +yfinance>=0.2.40 +edgartools>=2.0 +sec-edgar-downloader>=5.0 +vectorbt>=0.26 +quantstats>=0.0.62 +pandas>=2.0 +numpy>=1.24 +matplotlib>=3.7 +requests>=2.31 diff --git a/research/sec_monitor.py b/research/sec_monitor.py new file mode 100644 index 0000000..affa3da --- /dev/null +++ b/research/sec_monitor.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +SEC EDGAR monitor — list & summarize the most recent filings for a ticker. + +Usage: + python sec_monitor.py AVGO NVDA DRAM + python sec_monitor.py AVGO --forms 10-K 10-Q 8-K --limit 5 + python sec_monitor.py AVGO --download + +Why this exists (from the DRAM lesson) +-------------------------------------- +You sold puts on DRAM without reading the issuer's risk factors. A 5-minute +look at the 10-K "Risk Factors" + the last few 8-Ks would have flagged: + - Memory pricing cyclicality + - Customer concentration + - Leverage / cash burn +This script makes that read take literally seconds. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from datetime import date, timedelta +from pathlib import Path + +# edgartools needs an identity header per SEC rules. +os.environ.setdefault("EDGAR_IDENTITY", + os.environ.get("SEC_EDGAR_USER_AGENT", + "Personal Research research@example.com")) + +try: + from edgar import Company, set_identity +except ImportError as e: + print(f"edgartools not installed: {e}", file=sys.stderr) + sys.exit(1) + + +def list_filings(ticker: str, forms: list[str], limit: int) -> None: + set_identity(os.environ["EDGAR_IDENTITY"]) + try: + co = Company(ticker) + except Exception as e: + print(f" {ticker}: lookup failed ({e})") + return + + print(f"\n=== {ticker} — {co.name} (CIK {co.cik}) ===") + filings = co.get_filings(form=forms).head(limit) + if not len(filings): + print(f" no {forms} filings found") + return + + for f in filings: + print(f" {f.filing_date} {f.form:<6} {f.accession_no} {f.primary_document}") + + +def show_risk_factors(ticker: str, max_chars: int = 4000) -> None: + """Pull the latest 10-K Item 1A 'Risk Factors' and print a snippet.""" + set_identity(os.environ["EDGAR_IDENTITY"]) + try: + co = Company(ticker) + ten_k = co.get_filings(form="10-K").latest(1) + if ten_k is None: + print(f" {ticker}: no 10-K found") + return + ten_k_obj = ten_k.obj() if hasattr(ten_k, "obj") else ten_k + # edgartools 2.x exposes Item-level access + risk = ten_k_obj.risk_factors if hasattr(ten_k_obj, "risk_factors") else None + if not risk: + print(f" {ticker}: risk_factors not parseable, showing filing summary instead") + print(str(ten_k_obj)[:max_chars]) + return + text = str(risk) + print(f"\n--- {ticker} 10-K Risk Factors (first {max_chars} chars) ---") + print(text[:max_chars] + ("..." if len(text) > max_chars else "")) + except Exception as e: + print(f" {ticker}: risk-factor extraction failed ({e})") + + +def download_filings(tickers: list[str], forms: list[str], limit: int, + out_dir: str) -> None: + from sec_edgar_downloader import Downloader + dl = Downloader("PersonalResearch", + os.environ["EDGAR_IDENTITY"].split()[-1], + out_dir) + for t in tickers: + for form in forms: + try: + n = dl.get(form, t, limit=limit) + print(f" {t}/{form}: downloaded {n} filings into {out_dir}") + except Exception as e: + print(f" {t}/{form}: download failed ({e})") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("tickers", nargs="+") + ap.add_argument("--forms", nargs="+", default=["10-K", "10-Q", "8-K"]) + ap.add_argument("--limit", type=int, default=5) + ap.add_argument("--risk", action="store_true", + help="Print the latest 10-K risk factors") + ap.add_argument("--download", action="store_true", + help="Bulk-download filings to ./sec-edgar-filings/") + ap.add_argument("--out", default="./sec-edgar-filings") + args = ap.parse_args() + + for t in args.tickers: + list_filings(t.upper(), args.forms, args.limit) + if args.risk: + show_risk_factors(t.upper()) + + if args.download: + print("\nDownloading filings...") + download_filings([t.upper() for t in args.tickers], + args.forms, args.limit, args.out) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/research/setup.sh b/research/setup.sh new file mode 100755 index 0000000..2bf942d --- /dev/null +++ b/research/setup.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Quick-start for new Claude Code sessions. +# Usage: bash research/setup.sh +set -e + +cd "$(dirname "$0")" +pip install -q -r requirements.txt +echo "✓ All packages installed." + +# SEC requires a UA header identifying you +export SEC_EDGAR_USER_AGENT="${SEC_EDGAR_USER_AGENT:-Personal Research research@example.com}" +echo "✓ SEC_EDGAR_USER_AGENT set." + +# Polygon proxy (set in env or fall back) +export PROXY_URL="${PROXY_URL:-http://43.206.151.58:8080}" +export PROXY_KEY="${PROXY_KEY:-}" +echo "✓ Proxy env vars ready (PROXY_URL=$PROXY_URL)." + +echo +echo "Workflows:" +echo " 1. SEC monitor: python research/sec_monitor.py AVGO NVDA DRAM" +echo " 2. Short-put backtest: python research/options_backtest.py DRAM --strike-pct 0.98" +echo " 3. Realtime options: python research/realtime_options.py AVGO" diff --git a/research/setup_cron.sh b/research/setup_cron.sh new file mode 100755 index 0000000..eee106f --- /dev/null +++ b/research/setup_cron.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# 设置 6 小时自动 scan (cron) +# Usage: ./research/setup_cron.sh + +set -e +cd "$(dirname "$0")/.." +RGC_DIR="$(pwd)" + +mkdir -p logs + +# 写 daily scan 脚本 +cat > "$RGC_DIR/scripts/scheduled_scan.sh" << SCANEOF +#!/usr/bin/env bash +cd "$RGC_DIR" +source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true +export PROXY_URL="http://43.206.151.58:8080" +export PROXY_KEY="e5c39778fab32834e0b149f861a93157daf4f0ddb94ba710aa002211" + +TIMESTAMP=\$(date -u +%Y-%m-%dT%H:%M:%SZ) +LOG="logs/scan_\$(date +%Y%m%d_%H%M).log" + +echo "=== Scan started \$TIMESTAMP ===" > "\$LOG" + +# 你 RH 主要持仓 +TICKERS="NVDA AMD MU MRVL SMCI GFS AVGO INTC SOXX SOXL AMDL" + +for t in \$TICKERS; do + echo "" >> "\$LOG" + echo "=== \$t ===" >> "\$LOG" + python3 research/realtime_options.py "\$t" --expiries "\$(date -d '+30 days' +%Y-%m-%d 2>/dev/null || date -v+30d +%Y-%m-%d)" >> "\$LOG" 2>&1 +done + +echo "" >> "\$LOG" +echo "=== DRAM short put 状态 ===" >> "\$LOG" +python3 research/realtime_options.py DRAM --expiries 2026-07-17 2026-08-21 2026-10-16 >> "\$LOG" 2>&1 + +echo "=== Scan done \$(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "\$LOG" + +# 保留最近 50 个日志 +ls -t logs/scan_*.log 2>/dev/null | tail -n +51 | xargs rm -f 2>/dev/null +SCANEOF + +mkdir -p scripts +chmod +x "$RGC_DIR/scripts/scheduled_scan.sh" + +# 检查 cron 是否已经有这个 job +CRON_LINE="0 */6 * * * $RGC_DIR/scripts/scheduled_scan.sh" +if crontab -l 2>/dev/null | grep -q "scheduled_scan.sh"; then + echo "✓ Cron job already exists" +else + (crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab - + echo "✓ Cron job added: 每 6 小时跑一次" +fi + +echo +echo "查看 cron jobs: crontab -l" +echo "查看最新日志: ls -t logs/scan_*.log | head -1 | xargs cat" +echo "手动跑一次: ./scripts/scheduled_scan.sh" +echo "停止自动跑: crontab -e (删除带 scheduled_scan.sh 的行)"