tests/test_fx_window.py
Rama main · réplica de lectura del repositorio original.
"""The FX leg asks only for days that have finished.
A live pod logged this on every startup:
ERROR yfinance: $USDAUD=X: possibly delisted; no price data found
(1d 2026-09-24 -> 2026-09-25)
Nothing was delisted and nothing was missing — the stored series was current to
the 23rd, and the feed was asking for the 24th, a day that had not closed. Yahoo
has no answer for an unfinished day, says so in the only vocabulary it has, and
the app logged it at ERROR.
The price leg has never done this: it stops at `settled_through(exchange)`, the
last date that cannot still move. The FX leg ran to `today` instead, so the
first run of any day asked for that day. Same bug for the same reason on every
Monday, too, where the day before today is a Sunday with no rate at all.
Why it matters more than one noisy line: "possibly delisted" at ERROR is
indistinguishable, at a glance, from a pair the app has genuinely lost. A log
that cries wolf daily is one nobody reads on the day it is right.
"""
from __future__ import annotations
import datetime as dt
from decimal import Decimal
from types import SimpleNamespace
import pytest
from freezegun import freeze_time
from sqlalchemy import func, select
from test_pricefeed import stub_yf
import factories as fac
from app import pricefeed, tenancy
from app.models import FxRate
from app.settings import PriceFeedSettings
BACKFILL = dt.date(2026, 7, 1)
SETTINGS = SimpleNamespace(price_feed=PriceFeedSettings(backfill_start=BACKFILL))
@pytest.fixture
def feed_session(session_factory):
"""An unscoped session — what the feed runs on.
Declared here rather than imported from `test_pricefeed`: importing a
fixture shadows the parameter of the same name, which ruff reports as a
redefinition and pytest resolves by luck.
"""
with tenancy.unscoped_session(session_factory) as session:
yield session
# --------------------------------------------------------------------------- #
# What counts as settled for a rate
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"today, expected, why",
[
("2026-09-24", "2026-09-23", "a Thursday: yesterday"),
("2026-09-21", "2026-09-18", "a Monday: back to Friday, not Sunday"),
("2026-09-20", "2026-09-18", "a Sunday: back to Friday"),
("2026-09-19", "2026-09-18", "a Saturday: Friday"),
],
)
def test_the_last_settled_rate_date(today, expected, why):
"""Yesterday, rolled back over a weekend.
The weekend half is the part `settled_through`'s fallback does not do: it
answers `today - 1` for a market with no known bell, which on a Monday is a
Sunday. FX has no Sunday rate, so asking for one produces the same
"possibly delisted" error this is meant to stop.
"""
with freeze_time(today):
assert pricefeed.fx_settled_through() == dt.date.fromisoformat(expected), why
# --------------------------------------------------------------------------- #
# What the feed asks for
# --------------------------------------------------------------------------- #
@freeze_time("2026-09-24 08:00:00")
def test_a_current_series_is_not_asked_for_today(feed_session, monkeypatch):
"""The reported case. Rates stored through yesterday, so there is nothing
settled left to fetch and the provider is not called at all."""
fac.make_instrument(feed_session, "NOVA", exchange="NASDAQ", currency="USD",
name="Nova")
fac.add_fx_series(feed_session, "USDAUD", [
(d.isoformat(), "1.50") for d in _weekdays("2026-07-01", "2026-09-23")
])
feed_session.commit()
asked: list[tuple] = []
def _closes(symbol, start, end):
asked.append((symbol, start, end))
return []
monkeypatch.setattr(pricefeed, "_closes", _closes)
stub_yf(monkeypatch)
pricefeed.run_feed(feed_session, SETTINGS)
assert [a for a in asked if a[0] == "USDAUD=X"] == []
@freeze_time("2026-09-24 08:00:00")
def test_a_real_gap_is_fetched_but_only_up_to_the_settled_day(
feed_session, monkeypatch
):
"""The other side: a genuine hole still gets filled, and the window stops
at the last finished day rather than running into an open one."""
fac.make_instrument(feed_session, "NOVA", exchange="NASDAQ", currency="USD",
name="Nova")
fac.add_fx_series(feed_session, "USDAUD", [
(d.isoformat(), "1.50") for d in _weekdays("2026-07-01", "2026-09-18")
])
feed_session.commit()
asked: list[tuple] = []
def _closes(symbol, start, end):
asked.append((symbol, start, end))
return [(dt.date(2026, 9, 21), Decimal("1.55"))]
monkeypatch.setattr(pricefeed, "_closes", _closes)
stub_yf(monkeypatch)
pricefeed.run_feed(feed_session, SETTINGS)
(_symbol, start, end), = [a for a in asked if a[0] == "USDAUD=X"]
assert start == dt.date(2026, 9, 19) # the day after what is stored
assert end == dt.date(2026, 9, 23) # settled, NOT today
assert feed_session.scalar(
select(func.count()).select_from(FxRate).where(FxRate.date == dt.date(2026, 9, 21))
) == 1
@freeze_time("2026-09-21 08:00:00") # a Monday
def test_on_a_monday_the_weekend_is_not_requested(feed_session, monkeypatch):
"""Stored through Friday, so a Monday has nothing settled to ask for. The
naive `today - 1` would ask for Saturday to Sunday and get the error this
whole file is about."""
fac.make_instrument(feed_session, "NOVA", exchange="NASDAQ", currency="USD",
name="Nova")
fac.add_fx_series(feed_session, "USDAUD", [
(d.isoformat(), "1.50") for d in _weekdays("2026-07-01", "2026-09-18")
])
feed_session.commit()
asked: list[tuple] = []
monkeypatch.setattr(pricefeed, "_closes",
lambda s, a, b: asked.append((s, a, b)) or [])
stub_yf(monkeypatch)
pricefeed.run_feed(feed_session, SETTINGS)
assert [a for a in asked if a[0] == "USDAUD=X"] == []
def _weekdays(start: str, end: str) -> list[dt.date]:
"""Every weekday in the range — a dense series, so `_fetch_start` resumes
incrementally rather than deciding it is sparse and refetching the lot."""
day, last = dt.date.fromisoformat(start), dt.date.fromisoformat(end)
out = []
while day <= last:
if day.weekday() < 5:
out.append(day)
day += dt.timedelta(days=1)
return out