File size: 7,733 Bytes
99eb109
 
b0cb774
 
99eb109
 
 
 
 
c06617d
 
b0cb774
 
99eb109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0cb774
 
99eb109
 
 
 
 
b0cb774
99eb109
 
 
9d00337
 
 
 
 
 
 
 
 
 
 
 
 
 
99eb109
b0cb774
 
9d00337
99eb109
 
 
 
b0cb774
 
 
 
99eb109
 
 
 
 
b0cb774
 
 
 
 
 
99eb109
 
 
 
 
 
 
 
 
 
 
e211908
 
 
 
 
 
 
 
 
 
 
99eb109
b0cb774
 
 
 
99eb109
 
b0cb774
99eb109
 
e211908
99eb109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0cb774
 
 
 
 
 
99eb109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0cb774
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
184
185
186
187
188
189
190
"""Split/dividend adjustment for a user's raw 1-minute OHLCV bars.

Reproduces the convention the model was trained under (ported from the authors' training
pipeline):

* factors are cumulative and backward-looking, anchored at 1.0 on the latest bar date;
* a split with ex-date D multiplies price by denominator/numerator and volume by
  numerator/denominator on every bar dated strictly before D (Eastern time);
* a cash dividend with ex-date D multiplies price by (close_prior - amount) / close_prior
  on every bar dated strictly before D, where close_prior is the close of the last bar stamped
  at or before 16:00 ET on the last bar date before D (or that day's last bar if none); volume is unchanged;
* dividends with amount >= close_prior are skipped (price left under-adjusted);
* two actions on the same ex-date are combined: amounts summed, split ratios multiplied.

Only pandas and numpy are required.
"""

from __future__ import annotations

import datetime as dt

import numpy as np
import pandas as pd

EASTERN = "America/New_York"
REGULAR_CLOSE = dt.time(16, 0)


def clean_bars(bars: pd.DataFrame) -> pd.DataFrame:
    """Apply the training pipeline's raw-bar quality checks.

    Drops duplicate timestamps (keeps first), bars with high < low, bars whose close lies
    outside [low, high], and bars with a non-positive or missing price; negative or
    missing volume is set to 0.
    """
    df = bars.drop_duplicates(subset="timestamp", keep="first")
    ok = (df["high"] >= df["low"]) & (df["close"] <= df["high"]) & (df["close"] >= df["low"])
    ok &= (df[["open", "high", "low", "close"]] > 0).all(axis=1)
    df = df[ok.fillna(False)].copy()
    df["volume"] = df["volume"].fillna(0).clip(lower=0)
    return df.sort_values("timestamp").reset_index(drop=True)


def _parse_timestamps(ts: pd.Series) -> pd.Series:
    """Parse strings; mixed UTC offsets (e.g. across a DST change) are parsed as UTC.

    pandas 3 raises on mixed offsets, pandas 2 returns object dtype; both fall back.
    """
    try:
        parsed = pd.to_datetime(ts)
    except ValueError:
        return pd.to_datetime(ts, utc=True)
    if not pd.api.types.is_datetime64_any_dtype(parsed):
        return pd.to_datetime(ts, utc=True)
    return parsed


def _eastern(ts: pd.Series) -> pd.Series:
    """Parse if needed, then localize naive as Eastern or convert aware to Eastern."""
    if not pd.api.types.is_datetime64_any_dtype(ts):
        ts = _parse_timestamps(ts)
    return ts.dt.tz_localize(EASTERN) if ts.dt.tz is None else ts.dt.tz_convert(EASTERN)


def _split_events(splits: pd.DataFrame | None) -> dict[dt.date, tuple[float, float]]:
    """{ex_date: (numerator, denominator)}; accepts `ratio` (10.0 = 10-for-1, 0.5 = 1-for-2).

    Same-date splits are multiplied together.
    """
    if splits is None or len(splits) == 0:
        return {}
    s = splits.copy()
    if "numerator" not in s:
        s["numerator"], s["denominator"] = s["ratio"].astype(float), 1.0
    events: dict[dt.date, tuple[float, float]] = {}
    for d, n, m in zip(s["date"], s["numerator"], s["denominator"], strict=True):
        ex_date = pd.Timestamp(d).date()
        n0, m0 = events.get(ex_date, (1.0, 1.0))
        events[ex_date] = (n0 * float(n), m0 * float(m))
    return events


def _close_prior(df: pd.DataFrame, ex_date: dt.date) -> float | None:
    before = df[df["_date"] < ex_date]
    if before.empty:
        return None
    last_day = before[before["_date"] == before["_date"].iloc[-1]]
    regular = last_day[last_day["timestamp_eastern"].dt.time <= REGULAR_CLOSE]
    return float((regular if not regular.empty else last_day)["close"].iloc[-1])


def _add_dividend(events: dict, df: pd.DataFrame, ex_date: dt.date, amount: float, close_prior) -> None:
    """Insert one dividend; a repeat ex-date adds to the amount already recorded."""
    if ex_date in events:
        events[ex_date] = (events[ex_date][0] + amount, events[ex_date][1])
        return
    if pd.isna(close_prior):
        close_prior = _close_prior(df, ex_date)
    if close_prior is not None and close_prior > 0:
        events[ex_date] = (amount, float(close_prior))


def _dividend_events(df: pd.DataFrame, dividends: pd.DataFrame | None) -> dict[dt.date, tuple[float, float]]:
    """{ex_date: (amount, close_prior)}; close_prior is taken from the bars unless given.

    Same-date dividends are summed against one close_prior.
    """
    if dividends is None or len(dividends) == 0:
        return {}
    events: dict[dt.date, tuple[float, float]] = {}
    given = dividends["close_prior"] if "close_prior" in dividends else [None] * len(dividends)
    for d, amount, cp in zip(dividends["date"], dividends["amount"], given, strict=True):
        _add_dividend(events, df, pd.Timestamp(d).date(), float(amount), cp)
    return events


def _apply_event(price: float, volume: float, ev: dt.date, splits: dict, divs: dict) -> tuple[float, float]:
    """Fold one ex-date's split and/or dividend into the running factors."""
    if ev in splits:
        num, den = splits[ev]
        price *= den / num
        volume *= num / den
    if ev in divs:
        amount, close_prior = divs[ev]
        if close_prior > amount:
            price *= (close_prior - amount) / close_prior
    return price, volume


def _factors_by_date(dates: list[dt.date], splits: dict, divs: dict) -> tuple[dict, dict]:
    """Cumulative backward-looking factors per bar date, anchored at 1.0 on the latest date.

    Walking backwards, every event with `current < ex-date <= previous date` scales all
    bars dated `current` and earlier.
    """
    event_dates = sorted(set(splits) | set(divs))
    price, volume = 1.0, 1.0
    price_by, volume_by = {}, {}
    prev = None
    for current in reversed(dates):
        for ev in event_dates:
            if prev is not None and current < ev <= prev:
                price, volume = _apply_event(price, volume, ev, splits, divs)
        price_by[current], volume_by[current] = price, volume
        prev = current
    return price_by, volume_by


def adjust_ohlcv(
    bars: pd.DataFrame,
    splits: pd.DataFrame | None = None,
    dividends: pd.DataFrame | None = None,
) -> pd.DataFrame:
    """Return cleaned bars with adjusted prices ready for `make_features`.

    Args:
        bars: one security; columns `timestamp` (bar start; tz-aware, or naive taken as
            Eastern), `open`, `high`, `low`, `close`, `volume`, all unadjusted.
        splits: columns `date` (ex-date) and either `ratio` or `numerator`+`denominator`
            (numerator = shares after the split).
        dividends: columns `date` (ex-date), `amount` (cash per share as declared),
            optional `close_prior`.

    Returns:
        DataFrame with `timestamp_eastern`, `open_adj`, `high_adj`, `low_adj`, `close_adj`,
        `volume_adj`, `adj_factor_price`, `adj_factor_volume`, sorted by time.
    """
    df = clean_bars(bars)
    df["timestamp_eastern"] = _eastern(df["timestamp"])
    df["_date"] = df["timestamp_eastern"].dt.date
    price_by, volume_by = _factors_by_date(
        sorted(df["_date"].unique()), _split_events(splits), _dividend_events(df, dividends)
    )
    df["adj_factor_price"] = df["_date"].map(price_by).astype(np.float64)
    df["adj_factor_volume"] = df["_date"].map(volume_by).astype(np.float64)
    for col in ("open", "high", "low", "close"):
        df[f"{col}_adj"] = df[col] * df["adj_factor_price"]
    df["volume_adj"] = df["volume"] * df["adj_factor_volume"]
    keep = [
        "timestamp_eastern",
        "open_adj",
        "high_adj",
        "low_adj",
        "close_adj",
        "volume_adj",
        "adj_factor_price",
        "adj_factor_volume",
    ]
    return df[keep]