File size: 7,156 Bytes
7a8d58d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
"""
LEAPS Scanner — S&P 100 tabanlı concurrent LEAPS anomali tarama.
Yakın zamanda yüksek miktarda LEAPS opsiyonu alınan hisseleri tespit eder.

Kaynak: yfinance (ücretsiz, ~15-20 dakika gecikmeli veri)
Strateji: S&P 100 ticker listesini concurrent tarayarak volume/OI > 2x LEAPS spike yakalar.
"""

import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional

# ── Constants ──────────────────────────────────────────────────────────

LEAPS_MIN_DAYS = 365          # Vade > 1 yıl = LEAPS
UNUSUAL_VOL_OI_MIN = 2.0      # volume/openInterest >= 2x = anormal
MAX_WORKERS = 10              # Concurrent thread sayısı
TIMEOUT_PER_TICKER = 15       # Ticker başına saniye timeout

# S&P 100 ticker listesi (OEX — 100 büyük hacimli hisse)
SP100_TICKERS = [
    "AAPL", "MSFT", "GOOGL", "GOOG", "AMZN", "NVDA", "TSLA", "META",
    "BRK-B", "JPM", "V", "JNJ", "WMT", "PG", "MA", "UNH", "HD", "DIS",
    "PYPL", "BAC", "XOM", "INTC", "VZ", "ADBE", "NFLX", "CRM", "CSCO",
    "PFE", "ABT", "ACN", "TMO", "AVGO", "DHR", "NKE", "TXN", "QCOM",
    "COST", "NEE", "LIN", "PM", "BMY", "HON", "UNP", "LOW", "IBM",
    "RTX", "SBUX", "AMD", "INTU", "GS", "CAT", "MDT", "AMGN", "BLK",
    "GILD", "ADI", "ISRG", "BKNG", "T", "PLTR", "NOW", "SPGI", "MO",
    "ELV", "SYK", "ZTS", "ADP", "MMC", "CB", "CI", "SO", "DUK",
    "BDX", "TMUS", "SCHW", "EOG", "REGN", "LRCX", "PGR", "APD", "CL",
    "SHW", "SLB", "ETN", "AON", "ITW", "BSX", "CME", "HUM", "MRNA",
    "ABNB", "ORLY", "ATVI", "MCO", "KLAC", "PANW", "SNPS", "CDNS",
    "FTNT", "NXPI", "ADM", "KDP", "MNST", "MCHP", "CTAS", "PAYX",
    "AEP", "EXC", "AZN", "LHX", "NOC", "STZ", "KHC", "MAR", "DXCM",
]


# ── Helpers ────────────────────────────────────────────────────────────

def _safe_ratio(num, den, default=0.0):
    if den == 0 or pd.isna(den):
        return default
    return float(num) / float(den)


def _is_leaps(expiry_date_str: str) -> bool:
    """Vade > 365 gün ise LEAPS."""
    try:
        dt = datetime.strptime(expiry_date_str, "%Y-%m-%d")
        return (dt - datetime.today()).days >= LEAPS_MIN_DAYS
    except ValueError:
        return False


def scan_ticker_leaps(sym: str) -> Optional[dict]:
    """
    Tek ticker tarama: LEAPS opsiyonlarda anormal hacim/OI spike ara.
    Sonuç bulunamazsa None döner.
    """
    try:
        stock = yf.Ticker(sym)
        expiry_dates = stock.options
        if not expiry_dates:
            return None

        # LEAPS vadelerini belirle
        leaps_expiries = [exp for exp in expiry_dates if _is_leaps(exp)]
        if not leaps_expiries:
            return None

        total_leaps_call_vol = 0
        total_leaps_put_vol = 0
        total_leaps_call_oi = 0
        total_leaps_put_oi = 0
        unusual_spikes = []

        for exp in leaps_expiries:
            try:
                chain = stock.option_chain(exp)
            except Exception:
                continue

            for is_call, df in [(True, chain.calls), (False, chain.puts)]:
                if df.empty:
                    continue

                active = df[df["volume"] > 0].copy()
                if active.empty:
                    continue

                # Volume/OI ratio hesapla
                active["vol_oi"] = active.apply(
                    lambda r: _safe_ratio(r.get("volume", 0), r.get("openInterest", 0)),
                    axis=1,
                )

                # Toplam hacim/OI
                vol_sum = int(active["volume"].sum())
                oi_sum = int(active["openInterest"].sum())
                if is_call:
                    total_leaps_call_vol += vol_sum
                    total_leaps_call_oi += oi_sum
                else:
                    total_leaps_put_vol += vol_sum
                    total_leaps_put_oi += oi_sum

                # Anormal spike'lar: vol/OI >= threshold
                spikes = active[active["vol_oi"] >= UNUSUAL_VOL_OI_MIN]
                for _, row in spikes.iterrows():
                    unusual_spikes.append({
                        "type": "LEAPS CALL" if is_call else "LEAPS PUT",
                        "expiry": exp,
                        "strike": float(row.get("strike", 0)),
                        "volume": int(row.get("volume", 0)),
                        "openInterest": int(row.get("openInterest", 0)),
                        "vol_oi_ratio": round(float(row.get("vol_oi", 0)), 1),
                        "lastPrice": float(row.get("lastPrice", 0)) if pd.notna(row.get("lastPrice")) else 0,
                        "impliedVolatility": round(float(row.get("impliedVolatility", 0)), 4) if pd.notna(row.get("impliedVolatility")) else 0,
                        "signal": "BULLISH" if is_call else "BEARISH",
                    })

        if not unusual_spikes:
            return None

        # Özet hesapla
        total_vol = total_leaps_call_vol + total_leaps_put_vol
        cp_ratio = _safe_ratio(total_leaps_call_vol, total_leaps_put_vol)

        # En büyük spike'ı bul
        top_spike = max(unusual_spikes, key=lambda x: x["volume"])

        return {
            "ticker": sym,
            "total_leaps_call_vol": total_leaps_call_vol,
            "total_leaps_put_vol": total_leaps_put_vol,
            "total_leaps_vol": total_vol,
            "call_put_ratio": round(cp_ratio, 2),
            "unusual_count": len(unusual_spikes),
            "top_spike": top_spike,
            "all_spikes": unusual_spikes[:10],  # Max 10 spike
            "leaps_expiries_count": len(leaps_expiries),
            "nearest_leaps_expiry": leaps_expiries[0] if leaps_expiries else None,
            "scan_time": datetime.utcnow().isoformat(),
        }

    except Exception:
        return None


def scan_all_sp100_leaps(max_tickers: int = 100) -> list[dict]:
    """
    S&P 100 ticker listesini concurrent tarayarak LEAPS anomali bulur.
    Sonuçları toplam LEAPS hacmine göre sıralı döndürür.

    Parametre:
        max_tickers: Taranacak maks ticker sayısı (varsayılan 100, S&P 100)

    Dönüş:
        list[dict]: LEAPS anomali bulunan ticker'lar, hacme göre sıralı
    """
    tickers = SP100_TICKERS[:max_tickers]
    results = []

    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
        future_to_sym = {
            executor.submit(scan_ticker_leaps, sym): sym
            for sym in tickers
        }
        for future in as_completed(future_to_sym, timeout=120):
            try:
                result = future.result(timeout=TIMEOUT_PER_TICKER)
                if result is not None:
                    results.append(result)
            except Exception:
                continue

    # Toplam LEAPS hacmine göre sırala
    results.sort(key=lambda x: x["total_leaps_vol"], reverse=True)
    return results