Download algotrader/panel.py from ParallelLLC/algorithmic_trading: direct link, hf CLI and curl.
- Browser
- Download file 10 kB
-
https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/panel.py
- Command line
-
hf download hf://ParallelLLC/algorithmic_trading/algotrader/panel.py
-
curl -L -o panel.py https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/panel.py
10 kB
| """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"] | |
| 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 | |
| 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 --------------------------------------------------------- | |
| def close(self) -> pd.DataFrame: | |
| return self.fields["close"] | |
| def open(self) -> pd.DataFrame: | |
| return self.fields["open"] | |
| def high(self) -> pd.DataFrame: | |
| return self.fields["high"] | |
| def low(self) -> pd.DataFrame: | |
| return self.fields["low"] | |
| def volume(self) -> pd.DataFrame: | |
| return self.fields["volume"] | |
| def symbols(self) -> List[str]: | |
| return list(self.close.columns) | |
| def index(self) -> pd.DatetimeIndex: | |
| return self.close.index | |
| 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) | |
| 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 ------------------------------------------------------ | |
| 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()) | |