File size: 2,323 Bytes
6b66ac0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Unusual options activity factor.

Methodology:

  For each (kind, moneyness_bucket) over the last ``LOOKBACK_DAYS``:
    vol_oi  = volume / open_interest
    z_vol_oi = (today's vol_oi - mean) / std  (over lookback window)

  Final score is the vol-weighted sum of z-scores, with far-OTM calls
  weighted heaviest (because they're the "lottery ticket" signal most
  associated with informed buying):

    OPT = + z_call_otm * 1.0
        + z_call_atm * 0.6
        + z_call_itm * 0.3
        - z_put_otm  * 1.0
        - z_put_atm  * 0.6
        - z_put_itm  * 0.3

Output is a single float.
"""

from __future__ import annotations

from typing import Optional

import numpy as np
import pandas as pd

from .factor_sources import get_data_source


LOOKBACK_DAYS = 20

WEIGHTS = {
    ("call", "otm"): +1.0,
    ("call", "atm"): +0.6,
    ("call", "itm"): +0.3,
    ("put", "otm"): -1.0,
    ("put", "atm"): -0.6,
    ("put", "itm"): -0.3,
}


def _zscore(series: pd.Series) -> float:
    """Z-score of the last value against the rest.  Returns 0 if degenerate."""
    if len(series) < 3:
        return 0.0
    s = series.astype(float)
    if s.std() == 0 or not np.isfinite(s.std()):
        return 0.0
    return float((s.iloc[-1] - s.mean()) / s.std())


def compute_options_factor(
    ticker: str,
    source=None,
    lookback_days: int = LOOKBACK_DAYS,
) -> float:
    """Compute the unusual options activity factor for ``ticker``."""
    if source is None:
        source = get_data_source()
    df = source.get_options_history(ticker, lookback_days=lookback_days)
    if df is None or df.empty:
        return 0.0

    # vol_oi per (date, kind, moneyness)
    df = df.copy()
    df["vol_oi"] = df["volume"] / df["oi"].replace(0, np.nan)
    df = df.dropna(subset=["vol_oi"])

    score = 0.0
    for (kind, bucket), w in WEIGHTS.items():
        sub = df[(df["kind"] == kind) & (df["moneyness"] == bucket)]
        if sub.empty:
            continue
        sub = sub.sort_values("date")
        z = _zscore(sub["vol_oi"])
        score += w * z

    return float(np.clip(score, -5.0, 5.0))


def compute_options_factors(
    tickers: list[str],
    source=None,
) -> dict[str, float]:
    if source is None:
        source = get_data_source()
    return {t: compute_options_factor(t, source) for t in tickers}