File size: 10,013 Bytes
2d2e42a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""Multi-asset price panels.

A :class:`Panel` is a set of aligned ``T x N`` frames -- one per OHLCV field,
one column per symbol. That is the shape cross-sectional work actually needs,
and it is what the portfolio engine consumes.

The important design choice here is that **missing data stays missing**. It is
tempting to forward-fill a symbol through the days it did not trade, but that
invents liquidity that never existed and quietly lets a strategy hold a
delisted stock forever. Instead the panel tracks exactly when each symbol was
tradable, which is also what makes survivorship measurable rather than assumed.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Mapping, Optional, Sequence

import numpy as np
import pandas as pd

from .data import load_ohlcv
from .types import OHLCV_COLUMNS

__all__ = ["Panel", "load_panel", "SurvivorshipReport"]


@dataclass(frozen=True)
class SurvivorshipReport:
    """How much of this universe is made of winners we already know survived."""

    n_symbols: int
    n_alive_at_end: int
    n_delisted: int
    delisted_symbols: List[str]
    late_starters: List[str]
    survival_rate: float
    biased: bool
    note: str

    def as_flag(self) -> Optional[str]:
        return self.note if self.biased else None


@dataclass(frozen=True)
class Panel:
    """Aligned multi-asset OHLCV."""

    fields: Mapping[str, pd.DataFrame]
    sources: Mapping[str, str] = field(default_factory=dict)
    interval: str = "1d"
    note: str = ""

    def __post_init__(self) -> None:
        missing = [c for c in OHLCV_COLUMNS if c not in self.fields]
        if missing:
            raise ValueError(f"Panel is missing field(s): {', '.join(missing)}")
        reference = self.fields["close"]
        for name, frame in self.fields.items():
            if not frame.index.equals(reference.index) or list(frame.columns) != list(reference.columns):
                raise ValueError(f"Panel field '{name}' is not aligned with 'close'")

    # -- accessors ---------------------------------------------------------
    @property
    def close(self) -> pd.DataFrame:
        return self.fields["close"]

    @property
    def open(self) -> pd.DataFrame:
        return self.fields["open"]

    @property
    def high(self) -> pd.DataFrame:
        return self.fields["high"]

    @property
    def low(self) -> pd.DataFrame:
        return self.fields["low"]

    @property
    def volume(self) -> pd.DataFrame:
        return self.fields["volume"]

    @property
    def symbols(self) -> List[str]:
        return list(self.close.columns)

    @property
    def index(self) -> pd.DatetimeIndex:
        return self.close.index

    @property
    def is_real(self) -> bool:
        return all(s in ("yfinance", "bundled") for s in self.sources.values())

    def __len__(self) -> int:
        return len(self.close)

    @property
    def shape(self) -> tuple:
        return self.close.shape

    # -- derived -----------------------------------------------------------
    def returns(self) -> pd.DataFrame:
        """Per-asset close-to-close returns, NaN where the asset was untradable."""
        rets = self.close.pct_change()
        return rets.where(self.tradable())

    def tradable(self) -> pd.DataFrame:
        """True where the asset had a price on this bar *and* the one before.

        A position can only be held over a bar whose return is defined, so this
        is the mask the engine uses to zero out impossible weights.
        """
        listed = self.close.notna()
        return listed & listed.shift(1, fill_value=False)

    def dollar_volume(self) -> pd.DataFrame:
        return (self.close * self.volume).where(self.close.notna())

    def first_valid(self) -> pd.Series:
        return self.close.apply(lambda col: col.first_valid_index())

    def last_valid(self) -> pd.Series:
        return self.close.apply(lambda col: col.last_valid_index())

    # -- survivorship ------------------------------------------------------
    def survivorship(self, tolerance_bars: int = 5) -> SurvivorshipReport:
        """Measure how many names survived to the end of the sample.

        A universe picked today and backfilled contains only survivors, and
        every backtest run on it is flattered by the companies that failed and
        were quietly excluded. We cannot fix that here, but we can refuse to
        hide it: if every single name is still trading at the end of a long
        sample, that is itself the evidence.
        """
        if not len(self):
            return SurvivorshipReport(0, 0, 0, [], [], 1.0, False, "Empty panel.")

        last = self.last_valid()
        first = self.first_valid()
        end = self.index[-1]
        start = self.index[0]
        cutoff = self.index[max(0, len(self) - 1 - tolerance_bars)]
        entry_cutoff = self.index[min(len(self) - 1, tolerance_bars)]

        delisted = sorted(str(s) for s in last.index[last < cutoff])
        late = sorted(str(s) for s in first.index[first > entry_cutoff])
        n = len(self.symbols)
        alive = n - len(delisted)
        rate = alive / n if n else 1.0

        years = len(self) / 252.0
        biased = rate >= 1.0 and years >= 3 and n >= 5
        if biased:
            note = (
                f"All {n} symbols were still trading at the end of a {years:.1f}-year sample. "
                "A universe with no failures in it was almost certainly chosen after the fact, "
                "which means these results exclude every name that went to zero. Treat the "
                "returns below as an upper bound."
            )
        elif n == 0:
            note = "Empty panel."
        else:
            note = (
                f"{len(delisted)} of {n} symbols stopped trading before the end of the sample "
                f"({rate:.0%} survived), so the universe is not made purely of winners."
            )

        return SurvivorshipReport(
            n_symbols=n,
            n_alive_at_end=alive,
            n_delisted=len(delisted),
            delisted_symbols=delisted[:25],
            late_starters=late[:25],
            survival_rate=float(rate),
            biased=bool(biased),
            note=note,
        )

    # -- construction ------------------------------------------------------
    @classmethod
    def from_frames(
        cls,
        frames: Mapping[str, pd.DataFrame],
        sources: Optional[Mapping[str, str]] = None,
        interval: str = "1d",
        note: str = "",
        min_bars: int = 2,
    ) -> "Panel":
        """Build a panel from ``{symbol: ohlcv_frame}``, aligning on the union index."""
        usable = {
            str(symbol): frame
            for symbol, frame in frames.items()
            if frame is not None and len(frame) >= min_bars
        }
        if not usable:
            raise ValueError("No symbol had enough data to build a panel")

        index = pd.DatetimeIndex([])
        for frame in usable.values():
            index = index.union(pd.DatetimeIndex(frame.index))
        index = index.sort_values()

        fields: Dict[str, pd.DataFrame] = {}
        for column in OHLCV_COLUMNS:
            fields[column] = pd.DataFrame(
                {
                    symbol: pd.to_numeric(frame[column], errors="coerce").reindex(index)
                    for symbol, frame in usable.items()
                },
                index=index,
            )

        return cls(
            fields=fields,
            sources=dict(sources or {s: "unknown" for s in usable}),
            interval=interval,
            note=note,
        )

    def select(self, symbols: Sequence[str]) -> "Panel":
        keep = [s for s in symbols if s in self.close.columns]
        if not keep:
            raise ValueError("None of the requested symbols are in this panel")
        return Panel(
            fields={name: frame.loc[:, keep] for name, frame in self.fields.items()},
            sources={s: self.sources.get(s, "unknown") for s in keep},
            interval=self.interval,
            note=self.note,
        )

    def slice(self, start=None, end=None) -> "Panel":
        return Panel(
            fields={name: frame.loc[start:end] for name, frame in self.fields.items()},
            sources=dict(self.sources),
            interval=self.interval,
            note=self.note,
        )


def load_panel(
    symbols: Iterable[str],
    start: str = "2015-01-01",
    end: Optional[str] = None,
    interval: str = "1d",
    source: str = "auto",
    min_bars: int = 120,
) -> Panel:
    """Load a panel for ``symbols``, skipping any that cannot supply enough history."""
    symbols = [str(s).strip().upper() for s in symbols if str(s).strip()]
    if not symbols:
        raise ValueError("No symbols requested")

    frames: Dict[str, pd.DataFrame] = {}
    sources: Dict[str, str] = {}
    skipped: List[str] = []

    for symbol in dict.fromkeys(symbols):  # de-duplicate, keep order
        market = load_ohlcv(symbol, start, end, interval, source)
        if len(market.df) < min_bars:
            skipped.append(symbol)
            continue
        frames[symbol] = market.df
        sources[symbol] = market.source

    if not frames:
        raise ValueError(
            f"None of {len(symbols)} symbols returned at least {min_bars} bars."
        )

    simulated = sorted(s for s, src in sources.items() if src == "synthetic")
    note = ""
    if simulated:
        note = (
            f"{len(simulated)} of {len(frames)} symbols fell back to the market simulator "
            f"({', '.join(simulated[:6])}{'...' if len(simulated) > 6 else ''}). "
            "The statistics are still valid; they are measured on a simulated market."
        )
    if skipped:
        note = (note + " " if note else "") + f"Skipped for insufficient history: {', '.join(skipped[:6])}."

    return Panel.from_frames(frames, sources, interval=interval, note=note.strip())