Skip to content

recall_guard.portfolio.prices

recall_guard.portfolio.prices

FMP-backed EOD price fetcher for the cmmd-backtest universe.

Pulls end-of-day close prices for SWDA.L, XLK, IAU, and BIL from FMP's historical-price-eod/light endpoint, aligns them with an inner-join on date, and returns a single pandas.DataFrame with one column per ticker. Covers Reqs 5.1, 5.7, 7.2, and 9.2.

Public surface:

  • PriceFetchError is raised on HTTP failure or when the inner-join has fewer than 30 aligned trading days.
  • fetch_universe_prices is the only entry point.

This is the only place in the portfolio layer that performs HTTP I/O; tests mock requests.get. The retry / API-key resolution pattern is the same one used by recall_guard.dataset.fmp_corpora.fetch_articles, duplicated locally because the sentrux portfolio ↔ dataset boundary forbids the import.

PriceFetchError

Bases: RuntimeError

Raised when an FMP price fetch fails or returns insufficient data.

Carries either the offending ticker plus HTTP status code (transport failure) or the offending ticker plus aligned-day count (overlap failure). The orchestrator script presents this directly to stderr.

Source code in recall_guard/portfolio/prices.py
35
36
37
38
39
40
41
class PriceFetchError(RuntimeError):
    """Raised when an FMP price fetch fails or returns insufficient data.

    Carries either the offending ticker plus HTTP status code (transport
    failure) or the offending ticker plus aligned-day count (overlap
    failure). The orchestrator script presents this directly to stderr.
    """

fetch_universe_prices

fetch_universe_prices(tickers, start, end, api_key=None)

Return aligned (date × ticker) close-price matrix for the universe.

Fetches each ticker's EOD close series from FMP's historical-price-eod/light endpoint, filters each series to the [start, end] window, and inner-joins on date so any day where any ticker is missing (e.g. LSE holiday vs NYSE) is dropped uniformly.

Args: tickers: Ordered list of FMP symbols. Output column order matches this list. start: Inclusive lower bound for retained dates. end: Inclusive upper bound for retained dates. api_key: Explicit FMP key; falls back to the FMP_API_KEY environment variable.

Returns: A DataFrame with a monotonic DatetimeIndex, one column per ticker (in input order), and no NaN cells.

Raises: RuntimeError: FMP_API_KEY is not set and no api_key was passed. PriceFetchError: any individual ticker request fails, or the inner-joined frame has fewer than 30 aligned trading days. ValueError: tickers is empty or start > end.

Source code in recall_guard/portfolio/prices.py
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
def fetch_universe_prices(
    tickers: list[str],
    start: date,
    end: date,
    api_key: str | None = None,
) -> pd.DataFrame:
    """Return aligned (date × ticker) close-price matrix for the universe.

    Fetches each ticker's EOD close series from FMP's
    ``historical-price-eod/light`` endpoint, filters each series to the
    ``[start, end]`` window, and inner-joins on date so any day where any
    ticker is missing (e.g. LSE holiday vs NYSE) is dropped uniformly.

    Args:
        tickers: Ordered list of FMP symbols. Output column order
            matches this list.
        start: Inclusive lower bound for retained dates.
        end: Inclusive upper bound for retained dates.
        api_key: Explicit FMP key; falls back to the ``FMP_API_KEY``
            environment variable.

    Returns:
        A ``DataFrame`` with a monotonic ``DatetimeIndex``, one column
        per ticker (in input order), and no NaN cells.

    Raises:
        RuntimeError: ``FMP_API_KEY`` is not set and no ``api_key`` was
            passed.
        PriceFetchError: any individual ticker request fails, or the
            inner-joined frame has fewer than 30 aligned trading days.
        ValueError: ``tickers`` is empty or ``start > end``.
    """
    if not tickers:
        raise ValueError("tickers must be a non-empty list of FMP symbols.")
    if start > end:
        raise ValueError(
            f"start ({start.isoformat()}) must be <= end ({end.isoformat()})."
        )

    api_key = _resolve_api_key(api_key)

    series_by_ticker: dict[str, pd.Series] = {}
    for ticker in tickers:
        series_by_ticker[ticker] = _fetch_one_ticker(ticker, api_key, start, end)

    # Inner-join across tickers: any date missing from any ticker is
    # dropped from the combined frame.
    joined = pd.concat(
        [series_by_ticker[t] for t in tickers],
        axis=1,
        join="inner",
    )
    # Re-assert input column order. ``concat`` already preserves it,
    # but reassigning is cheap insurance against an empty series.
    joined.columns = list(tickers)

    # Drop residual NaN rows. ``concat(..., join='inner')`` only
    # filters by the index, so a date present everywhere but with one
    # NaN value would still survive without this.
    joined = joined.dropna(how="any")

    if len(joined) < _MIN_OVERLAP_DAYS:
        # Name the ticker that contributed the fewest raw rows so the
        # caller knows where to look.
        per_ticker_counts = {
            t: len(series_by_ticker[t]) for t in tickers
        }
        worst_ticker = min(per_ticker_counts, key=per_ticker_counts.get)
        worst_n = per_ticker_counts[worst_ticker]
        raise PriceFetchError(
            f"ticker {worst_ticker!r} has only {worst_n} raw rows "
            f"({len(joined)} aligned trading days after inner-join) "
            f"in window {start.isoformat()}..{end.isoformat()} "
            f"(need >= {_MIN_OVERLAP_DAYS})"
        )

    return joined