feat(universe): separate scanner membership from retained history
bootstrap_universe(prune_missing=True) ran DELETE FROM tickers, cascading OHLCV, setups, scores and S/R levels away for any symbol that had merely dropped out of the index. Migration 032's own comment predicted this. The survivorship audit measured what it costs: 2.09 Sharpe against 1.57 on universe composition alone. Migration 035 adds untracked_on, the sibling of delisted_on — delisted means the security stopped trading, untracked means a prune took it out of the scanner. Nothing is deleted either way. Migration 036 separates three more things that were still conflated: universe_memberships what the index actually contained, when tickers.untracked_on excluded from scanning by a prune tickers.scan_disabled_on the operator's own "stop scanning this" tickers.tracking_source who owns the row: a bootstrap, or an operator The operator flag is its own column because with one column serving both writers, a prune -> re-entry cycle would clear an explicit "off" — re-entry cannot tell who set it. Re-entry clears untracked_on and never touches scan_disabled_on. untracked_on is NOT current index membership. The scheduled sync runs prune_missing=False, so a symbol can have left the index and still be scanned; the two legitimately differ and nothing asserts they agree. Tracking ownership is sticky towards manual: prune touches index-owned rows only, and a bootstrap never converts manual to index — a hand-added symbol that joins the index would otherwise lose its protection the moment it left again, which is exactly when it is needed. An explicit add of an index-owned symbol claims it as manual without touching either off-switch. Membership is observed, never asserted. first_seen_on is the first live sighting; absent_since is the first observation that found the symbol GONE, so the interval is [first_seen_on, absent_since) — named for what it stores rather than "last_seen_on", which would have claimed the opposite. Intervals are append-only, so a symbol that leaves and returns has two and a point-in-time query for the gap correctly answers "not a member". members_on() implements the boundary rule once. A date before the first live sync returns empty: that means "we were not looking", not "the index was empty". A failed fetch drives nothing. The cache and seed fallbacks mean the live fetch failed, and the seed lists are ~44 symbols against a ~500-symbol universe — a regression test caught a seed fallback untracking 2 of 3 tickers. Recording, pruning and clearing prune flags all require a live source; only adding still runs, so a cold start can still bootstrap from seeds. Migration decisions: existing rows become manual (fail-safe — a wrong guess costs an un-pruned symbol, not one swept out of the scanner), and existing untracked_on values are copied into scan_disabled_on, since the original writer is ambiguous and re-entry clears the prune column. Backtest eligibility is unchanged. _load_backtest_symbols deliberately does not use active_only, so today's scanner state cannot erase yesterday's candidates, and untracking a symbol still cannot silence a coverage gap in the report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HAH7vVtn7qbcn1EVheecHQ
This commit is contained in:
1 parent
f2ef8e5438
commit
ad3e47dc50
17 files changed
+2027
-57
No files matched your search
@@ -0,0 +1,86 @@
|
||||
"""Migration 035 adds the untracked flag and stays reversible.
|
||||
|
||||
The downgrade matters more than usual here: dropping the column re-admits every
|
||||
untracked symbol to the live path, and the rows survive either way. A migration
|
||||
that could not roll back cleanly would make the prune change hard to undo on a
|
||||
deploy that went wrong.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
def _load_migration_module():
|
||||
path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "035_ticker_untracked.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("migration_035", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _tickers_table(connection: sa.Connection) -> None:
|
||||
connection.execute(sa.text(
|
||||
"""
|
||||
CREATE TABLE tickers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
symbol VARCHAR(10) NOT NULL,
|
||||
delisted_on DATE
|
||||
)
|
||||
"""
|
||||
))
|
||||
connection.execute(sa.text(
|
||||
"INSERT INTO tickers (id, symbol, delisted_on) VALUES (1, 'AAPL', NULL)"
|
||||
))
|
||||
|
||||
|
||||
def _columns(connection: sa.Connection) -> set[str]:
|
||||
return {row[1] for row in connection.execute(sa.text("PRAGMA table_info(tickers)"))}
|
||||
|
||||
|
||||
def test_upgrade_adds_a_nullable_untracked_column_and_keeps_existing_rows():
|
||||
migration = _load_migration_module()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
_tickers_table(connection)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
|
||||
assert "untracked_on" in _columns(connection)
|
||||
# Existing rows default to NULL, i.e. still in the scanner — an upgrade
|
||||
# must not silently drop the whole universe out of the live path.
|
||||
rows = connection.execute(
|
||||
sa.text("SELECT symbol, untracked_on FROM tickers")
|
||||
).all()
|
||||
assert rows == [("AAPL", None)]
|
||||
|
||||
|
||||
def test_downgrade_removes_the_column_without_touching_the_rows():
|
||||
migration = _load_migration_module()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
_tickers_table(connection)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
connection.execute(sa.text(
|
||||
"UPDATE tickers SET untracked_on = '2026-09-07' WHERE symbol = 'AAPL'"
|
||||
))
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.downgrade()
|
||||
|
||||
assert "untracked_on" not in _columns(connection)
|
||||
# The ticker itself survives the rollback with its identity intact.
|
||||
assert connection.execute(sa.text("SELECT symbol FROM tickers")).all() == [("AAPL",)]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Migration 036: provenance, the operator switch, and membership intervals.
|
||||
|
||||
The migration makes two irreversible-feeling judgement calls, and both need to
|
||||
be pinned:
|
||||
|
||||
1. **Existing rows become ``manual``.** Provenance cannot be recovered from
|
||||
``created_at``, and manual is the fail-safe guess — manual rows are never
|
||||
pruned, so a wrong guess costs an un-pruned symbol rather than one swept out
|
||||
of the scanner.
|
||||
2. **Existing "off" states are carried into ``scan_disabled_on``.** Whoever set
|
||||
``untracked_on`` is ambiguous, and re-entry clears the prune flag. Copying it
|
||||
is what stops an automated sync reactivating something switched off by hand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
def _load_migration_module():
|
||||
path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "036_universe_membership.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("migration_036", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _tickers_table(connection: sa.Connection) -> None:
|
||||
"""The schema as migration 035 leaves it."""
|
||||
connection.execute(sa.text(
|
||||
"""
|
||||
CREATE TABLE tickers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
symbol VARCHAR(10) NOT NULL,
|
||||
delisted_on DATE,
|
||||
untracked_on DATE
|
||||
)
|
||||
"""
|
||||
))
|
||||
connection.execute(sa.text(
|
||||
"INSERT INTO tickers (id, symbol, delisted_on, untracked_on) VALUES "
|
||||
"(1, 'AAPL', NULL, NULL), "
|
||||
"(2, 'OFF', NULL, '2026-08-01')"
|
||||
))
|
||||
|
||||
|
||||
def _columns(connection: sa.Connection, table: str) -> set[str]:
|
||||
return {row[1] for row in connection.execute(sa.text(f"PRAGMA table_info({table})"))}
|
||||
|
||||
|
||||
def test_upgrade_defaults_existing_rows_to_manual():
|
||||
migration = _load_migration_module()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
_tickers_table(connection)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
|
||||
rows = dict(connection.execute(
|
||||
sa.text("SELECT symbol, tracking_source FROM tickers")
|
||||
).all())
|
||||
assert rows == {"AAPL": "manual", "OFF": "manual"}
|
||||
|
||||
|
||||
def test_upgrade_carries_an_existing_off_state_into_the_operator_switch():
|
||||
migration = _load_migration_module()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
_tickers_table(connection)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
|
||||
rows = dict(connection.execute(
|
||||
sa.text("SELECT symbol, scan_disabled_on FROM tickers")
|
||||
).all())
|
||||
# The ambiguous existing "off" is preserved on the column no sync clears.
|
||||
assert rows["OFF"] == "2026-08-01"
|
||||
# A row that was never off stays on.
|
||||
assert rows["AAPL"] is None
|
||||
|
||||
|
||||
def test_upgrade_creates_the_membership_table_empty():
|
||||
migration = _load_migration_module()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
_tickers_table(connection)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
|
||||
assert {"ticker_id", "universe", "first_seen_on", "absent_since"} <= _columns(
|
||||
connection, "universe_memberships"
|
||||
)
|
||||
# History starts at first observation — the migration invents none.
|
||||
count = connection.execute(
|
||||
sa.text("SELECT COUNT(*) FROM universe_memberships")
|
||||
).scalar_one()
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_downgrade_removes_the_new_state_and_keeps_the_tickers():
|
||||
migration = _load_migration_module()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
|
||||
with engine.begin() as connection:
|
||||
_tickers_table(connection)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.downgrade()
|
||||
|
||||
columns = _columns(connection, "tickers")
|
||||
assert "tracking_source" not in columns
|
||||
assert "scan_disabled_on" not in columns
|
||||
# 035's column and the rows themselves are untouched.
|
||||
assert "untracked_on" in columns
|
||||
assert connection.execute(
|
||||
sa.text("SELECT symbol FROM tickers ORDER BY symbol")
|
||||
).all() == [("AAPL",), ("OFF",)]
|
||||
@@ -347,26 +347,37 @@ def test_common_stock_classification(description: str, expected: bool):
|
||||
assert _is_common_stock(description) is expected
|
||||
|
||||
|
||||
async def test_prune_keeps_delisted_rows(session: AsyncSession, monkeypatch):
|
||||
"""A prune must not destroy rows the delisting flow deliberately retained —
|
||||
their price history is the whole reason those rows still exist."""
|
||||
async def test_prune_keeps_every_row(session: AsyncSession, monkeypatch):
|
||||
"""A prune destroys nothing at all now — not the delisted row it always
|
||||
protected, and not the ex-constituent it used to delete outright."""
|
||||
from app.services import ticker_universe_service as tus
|
||||
|
||||
session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="EA"), Ticker(symbol="GONE")])
|
||||
session.add_all([
|
||||
Ticker(symbol=s, tracking_source=ticker_service.SOURCE_INDEX)
|
||||
for s in ("AAPL", "EA", "GONE")
|
||||
])
|
||||
await session.commit()
|
||||
await ticker_service.mark_delisted(session, "EA", delisted_on=date(2026, 8, 14))
|
||||
|
||||
async def fake_fetch(db, universe):
|
||||
return ["AAPL"], "test"
|
||||
# A live source label: pruning only acts on a successful fetch.
|
||||
return ["AAPL"], "wikipedia_sp500"
|
||||
|
||||
monkeypatch.setattr(tus, "fetch_universe_symbols", fake_fetch)
|
||||
|
||||
summary = await tus.bootstrap_universe(session, "sp500", prune_missing=True)
|
||||
|
||||
remaining = sorted(t.symbol for t in await ticker_service.list_tickers(session))
|
||||
assert remaining == ["AAPL", "EA"] # GONE pruned, EA protected
|
||||
assert summary["deleted"] == 1
|
||||
assert summary["kept_delisted"] == ["EA"]
|
||||
assert remaining == ["AAPL", "EA", "GONE"]
|
||||
assert summary["deleted"] == 0
|
||||
assert summary["untracked"] == 2
|
||||
|
||||
# Both leave the live path, by different routes: EA stopped trading, GONE
|
||||
# merely left the index.
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL"]
|
||||
|
||||
|
||||
async def test_a_future_effective_date_keeps_the_symbol_live(session: AsyncSession):
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
from app.database import Base
|
||||
from app.models.settings import SystemSetting
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import ticker_universe_service
|
||||
from app.services import ticker_service, ticker_universe_service
|
||||
from app.services.ticker_universe_service import _extract_wiki_symbols, _normalise_symbols
|
||||
|
||||
_engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
@@ -58,11 +58,17 @@ async def test_bootstrap_universe_adds_missing_symbols(session: AsyncSession, mo
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_universe_prunes_missing_symbols(session: AsyncSession, monkeypatch: pytest.MonkeyPatch):
|
||||
session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="MSFT"), Ticker(symbol="TSLA")])
|
||||
# Index-owned: a row defaults to manual, and manual rows are never pruned.
|
||||
session.add_all([
|
||||
Ticker(symbol=s, tracking_source=ticker_service.SOURCE_INDEX)
|
||||
for s in ("AAPL", "MSFT", "TSLA")
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
async def _fake_fetch(_db: AsyncSession, _universe: str) -> tuple[list[str], str]:
|
||||
return ["AAPL", "MSFT"], "test"
|
||||
# A live source label: a fallback result must not drive pruning, so a
|
||||
# made-up label would make this test vacuous.
|
||||
return ["AAPL", "MSFT"], "wikipedia_sp500"
|
||||
|
||||
monkeypatch.setattr(ticker_universe_service, "fetch_universe_symbols", _fake_fetch)
|
||||
|
||||
@@ -74,10 +80,18 @@ async def test_bootstrap_universe_prunes_missing_symbols(session: AsyncSession,
|
||||
|
||||
assert result["added"] == 0
|
||||
assert result["already_tracked"] == 2
|
||||
assert result["deleted"] == 1
|
||||
assert result["untracked"] == 1
|
||||
assert result["untracked_symbols"] == ["TSLA"]
|
||||
# Nothing is destroyed any more, and the key says so rather than vanishing.
|
||||
assert result["deleted"] == 0
|
||||
|
||||
# The row survives the prune; only its scanner membership changed.
|
||||
rows = await session.execute(select(Ticker.symbol).order_by(Ticker.symbol.asc()))
|
||||
assert list(rows.scalars().all()) == ["AAPL", "MSFT"]
|
||||
assert list(rows.scalars().all()) == ["AAPL", "MSFT", "TSLA"]
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol.asc()))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL", "MSFT"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Scanner membership and retained history are separate states.
|
||||
|
||||
`bootstrap_universe(prune_missing=True)` used to run `DELETE FROM tickers`,
|
||||
cascading OHLCV, setups and scores away for any symbol that had merely dropped
|
||||
out of the index. The survivorship audit measured the cost of that: restoring 85
|
||||
removed constituents moved the same window from 2.09 Sharpe / 51.6% CAGR to 1.57
|
||||
/ 38.1%.
|
||||
|
||||
Three properties have to hold together, and the third is the one nobody would
|
||||
think to check:
|
||||
|
||||
1. Pruning preserves the symbol's bars and its linked records.
|
||||
2. The pruned symbol leaves today's scanner and rankings.
|
||||
3. The backtest's universe query still returns it — today's membership flag must
|
||||
not silently erase yesterday's candidates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import ticker_service
|
||||
from app.services import ticker_universe_service as tus
|
||||
|
||||
# Own engine, like the sibling delisting tests: these exercise schema-level
|
||||
# behaviour (a new column, a cascade that must NOT fire) and want a clean slate.
|
||||
_engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _setup_tables() -> AsyncGenerator[None, None]:
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with _session_factory() as s:
|
||||
yield s
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
"""Two index-owned constituents, one about to leave with history.
|
||||
|
||||
``tracking_source`` is explicit: a row defaults to ``manual``, and manual
|
||||
rows are never pruned, so a fixture that omitted it would silently test
|
||||
nothing.
|
||||
"""
|
||||
session.add_all([
|
||||
Ticker(symbol="AAPL", tracking_source=ticker_service.SOURCE_INDEX),
|
||||
Ticker(symbol="LEAVER", tracking_source=ticker_service.SOURCE_INDEX),
|
||||
])
|
||||
await session.flush()
|
||||
|
||||
leaver = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "LEAVER"))
|
||||
).scalar_one()
|
||||
start = date(2026, 1, 5)
|
||||
for i in range(10):
|
||||
session.add(OHLCVRecord(
|
||||
ticker_id=leaver.id,
|
||||
date=start + timedelta(days=i),
|
||||
open=100.0, high=101.0, low=99.0, close=100.5, volume=1_000_000,
|
||||
))
|
||||
session.add(SRLevel(
|
||||
ticker_id=leaver.id, price_level=95.0, type="support", strength=70,
|
||||
detection_method="swing",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _prune_to(session: AsyncSession, monkeypatch, keep: list[str]) -> dict:
|
||||
async def fake_fetch(db, universe):
|
||||
# A LIVE source label: pruning and re-entry act only on a successful
|
||||
# fetch, so a made-up label would silently make these tests vacuous.
|
||||
return keep, "wikipedia_sp500"
|
||||
|
||||
monkeypatch.setattr(tus, "fetch_universe_symbols", fake_fetch)
|
||||
return await tus.bootstrap_universe(session, "sp500", prune_missing=True)
|
||||
|
||||
|
||||
async def test_pruning_preserves_bars_and_linked_records(session: AsyncSession, monkeypatch):
|
||||
await _seed(session)
|
||||
summary = await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
|
||||
assert summary["untracked"] == 1
|
||||
assert summary["untracked_symbols"] == ["LEAVER"]
|
||||
assert summary["deleted"] == 0
|
||||
|
||||
leaver = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "LEAVER"))
|
||||
).scalar_one_or_none()
|
||||
assert leaver is not None, "the row itself must survive the prune"
|
||||
# The database stamps this (see bootstrap_universe), and SQLite's
|
||||
# CURRENT_DATE is UTC while date.today() is local — so allow the one-day
|
||||
# window rather than making this fail around midnight.
|
||||
assert leaver.untracked_on is not None
|
||||
assert abs((leaver.untracked_on - date.today()).days) <= 1
|
||||
# Untracked is not delisted: the security still trades.
|
||||
assert leaver.delisted_on is None
|
||||
|
||||
bars = (await session.execute(
|
||||
select(OHLCVRecord).where(OHLCVRecord.ticker_id == leaver.id)
|
||||
)).scalars().all()
|
||||
assert len(bars) == 10, "the delete used to cascade these away"
|
||||
|
||||
levels = (await session.execute(
|
||||
select(SRLevel).where(SRLevel.ticker_id == leaver.id)
|
||||
)).scalars().all()
|
||||
assert len(levels) == 1, "every cascade relationship must survive, not just bars"
|
||||
|
||||
|
||||
async def test_a_pruned_symbol_leaves_the_live_path(session: AsyncSession, monkeypatch):
|
||||
await _seed(session)
|
||||
await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL"]
|
||||
|
||||
# ...while the registry still shows it, so the state is visible rather than
|
||||
# silently absent.
|
||||
everything = await session.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
assert list(everything.scalars().all()) == ["AAPL", "LEAVER"]
|
||||
|
||||
|
||||
async def test_todays_membership_does_not_erase_yesterdays_candidates(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
"""The backtest replays stored history, so it must see every symbol that has
|
||||
bars — including ones that have since left the index. This guards
|
||||
``backtest_service``'s bare universe query: "fixing the inconsistency" by
|
||||
adding ``active_only`` there is exactly how survivorship bias gets
|
||||
reintroduced.
|
||||
"""
|
||||
await _seed(session)
|
||||
await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
symbols = await bt._load_backtest_symbols(session)
|
||||
assert "LEAVER" in symbols, (
|
||||
"the backtest universe must include untracked symbols — their bars are "
|
||||
"exactly the history that keeps a replay survivorship-honest"
|
||||
)
|
||||
assert symbols == ["AAPL", "LEAVER"]
|
||||
|
||||
|
||||
async def test_a_symbol_returning_to_the_index_rejoins_the_scanner(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
"""Re-tracking is the failure this refactor could introduce: a returning
|
||||
symbol is already tracked, so it is neither added nor untracked, and without
|
||||
an explicit clear it would keep its old mark and stay invisible forever."""
|
||||
await _seed(session)
|
||||
await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
|
||||
summary = await _prune_to(session, monkeypatch, ["AAPL", "LEAVER"])
|
||||
assert summary["retracked"] == 1
|
||||
assert summary["added"] == 0, "the row was never deleted, so there is nothing to add"
|
||||
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL", "LEAVER"]
|
||||
|
||||
leaver = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "LEAVER"))
|
||||
).scalar_one()
|
||||
assert leaver.untracked_on is None
|
||||
bars = (await session.execute(
|
||||
select(OHLCVRecord).where(OHLCVRecord.ticker_id == leaver.id)
|
||||
)).scalars().all()
|
||||
assert len(bars) == 10, "it rejoins with its history, not as a blank new row"
|
||||
|
||||
|
||||
async def test_a_second_prune_does_not_restamp_the_date(session: AsyncSession, monkeypatch):
|
||||
"""The mark records when the symbol left, so a daily bootstrap must not keep
|
||||
moving it forward — that would erase the only record of when it happened."""
|
||||
await _seed(session)
|
||||
await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
|
||||
leaver = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "LEAVER"))
|
||||
).scalar_one()
|
||||
leaver.untracked_on = date(2026, 1, 15)
|
||||
await session.commit()
|
||||
|
||||
summary = await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
assert summary["untracked"] == 0, "already-marked rows are not re-marked"
|
||||
|
||||
await session.refresh(leaver)
|
||||
assert leaver.untracked_on == date(2026, 1, 15)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column", ["delisted_on", "untracked_on", "scan_disabled_on"])
|
||||
async def test_as_of_answers_what_was_live_then_for_both_columns(
|
||||
session: AsyncSession, column: str
|
||||
):
|
||||
"""``as_of`` exists so a historical query can ask what was live at a date.
|
||||
An untracked clause that ignored it would break that for the new column."""
|
||||
session.add(Ticker(symbol="AAPL"))
|
||||
session.add(Ticker(symbol="LEFT", **{column: date(2026, 6, 1)}))
|
||||
await session.commit()
|
||||
|
||||
before = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol), as_of=date(2026, 5, 1))
|
||||
)
|
||||
assert sorted(before.scalars().all()) == ["AAPL", "LEFT"]
|
||||
|
||||
after = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol), as_of=date(2026, 7, 1))
|
||||
)
|
||||
assert sorted(after.scalars().all()) == ["AAPL"]
|
||||
|
||||
|
||||
async def test_a_symbol_can_be_both_untracked_and_delisted(session: AsyncSession):
|
||||
"""The state a name reaches when it leaves the index and later stops
|
||||
trading. The two clauses are independent, so this must not double-count or
|
||||
resurrect it."""
|
||||
session.add(Ticker(
|
||||
symbol="BOTH", untracked_on=date(2026, 3, 1), delisted_on=date(2026, 6, 1)
|
||||
))
|
||||
session.add(Ticker(symbol="AAPL"))
|
||||
await session.commit()
|
||||
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol), as_of=date(2026, 7, 1))
|
||||
)
|
||||
assert sorted(live.scalars().all()) == ["AAPL"]
|
||||
|
||||
# Before either date it was still in the live path.
|
||||
early = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol), as_of=date(2026, 1, 1))
|
||||
)
|
||||
assert sorted(early.scalars().all()) == ["AAPL", "BOTH"]
|
||||
|
||||
# Between them: out of the scanner already, but not yet delisted.
|
||||
between = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol), as_of=date(2026, 4, 1))
|
||||
)
|
||||
assert sorted(between.scalars().all()) == ["AAPL"]
|
||||
|
||||
|
||||
def test_every_active_only_call_site_carries_both_predicates():
|
||||
"""``active_only`` grew from one ``.where()`` to two. Call sites that chain
|
||||
``group_by``/``order_by`` onto its result, or pass multi-column selects,
|
||||
must still get both clauses ANDed — a silently dropped predicate would put
|
||||
untracked symbols back in the live path with nothing to show for it.
|
||||
"""
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
|
||||
latest = func.max(OHLCVRecord.date)
|
||||
call_sites = {
|
||||
# scheduler.py _get_all_tickers
|
||||
"plain": select(Ticker.symbol).order_by(Ticker.symbol),
|
||||
# sec_universe.py — multi-column select
|
||||
"multi_column": select(Ticker.id, Ticker.symbol, Ticker.cik),
|
||||
# rr_scanner_service.py — whole-entity select
|
||||
"entity": select(Ticker).order_by(Ticker.symbol),
|
||||
}
|
||||
compiled = {
|
||||
name: " ".join(str(
|
||||
ticker_service.active_only(stmt).compile(dialect=postgresql.dialect())
|
||||
).split())
|
||||
for name, stmt in call_sites.items()
|
||||
}
|
||||
# scheduler.py _get_ohlcv_priority_tickers chains OUTSIDE the call.
|
||||
compiled["chained"] = " ".join(str(
|
||||
ticker_service.active_only(
|
||||
select(Ticker.symbol).outerjoin(OHLCVRecord, OHLCVRecord.ticker_id == Ticker.id)
|
||||
)
|
||||
.group_by(Ticker.id, Ticker.symbol)
|
||||
.order_by(case((latest.is_(None), 0), else_=1).asc(), Ticker.symbol.asc())
|
||||
.compile(dialect=postgresql.dialect())
|
||||
).split())
|
||||
|
||||
for name, sql in compiled.items():
|
||||
assert "tickers.delisted_on IS NULL" in sql, name
|
||||
assert "tickers.untracked_on IS NULL" in sql, name
|
||||
assert "tickers.scan_disabled_on IS NULL" in sql, name
|
||||
# ANDed together, not collapsed into one OR that would let either alone
|
||||
# re-admit the symbol.
|
||||
assert ") AND (" in sql[sql.index("WHERE"):], name
|
||||
|
||||
|
||||
async def test_a_symbol_can_be_untracked_individually(session: AsyncSession):
|
||||
"""The case RHM is: a manually added symbol no provider can serve. Without a
|
||||
per-symbol control the only way to untrack it is a whole-universe prune,
|
||||
which would sweep every other manual addition with it."""
|
||||
await _seed(session)
|
||||
|
||||
changed = await ticker_service.set_tracked(session, "LEAVER", tracked=False)
|
||||
assert changed is True
|
||||
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL"]
|
||||
|
||||
leaver = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "LEAVER"))
|
||||
).scalar_one()
|
||||
# The operator's switch, not the prune flag: it has to survive a
|
||||
# prune -> re-entry cycle, which clears untracked_on.
|
||||
assert leaver.scan_disabled_on is not None
|
||||
assert leaver.untracked_on is None
|
||||
# Switching off is not delisting; the security still trades.
|
||||
assert leaver.delisted_on is None
|
||||
bars = (await session.execute(
|
||||
select(OHLCVRecord).where(OHLCVRecord.ticker_id == leaver.id)
|
||||
)).scalars().all()
|
||||
assert len(bars) == 10
|
||||
|
||||
|
||||
async def test_setting_tracking_is_idempotent_and_reversible(session: AsyncSession):
|
||||
await _seed(session)
|
||||
|
||||
assert await ticker_service.set_tracked(session, "LEAVER", tracked=False) is True
|
||||
assert await ticker_service.set_tracked(session, "LEAVER", tracked=False) is False
|
||||
|
||||
assert await ticker_service.set_tracked(session, "LEAVER", tracked=True) is True
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL", "LEAVER"]
|
||||
|
||||
|
||||
async def test_untracking_an_unknown_symbol_raises(session: AsyncSession):
|
||||
from app.exceptions import NotFoundError
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await ticker_service.set_tracked(session, "NOPE", tracked=False)
|
||||
|
||||
|
||||
async def test_untracking_does_not_clear_a_data_quality_warning(session: AsyncSession):
|
||||
"""Untracking is not a way to silence the completeness check.
|
||||
|
||||
The backtest deliberately includes untracked symbols, so an untracked ticker
|
||||
with no bars still counts in ``no_data`` and still reports ``degraded``. If
|
||||
it did not, "untrack it" would become a way to make coverage gaps disappear
|
||||
from the report — which is the exact failure the ledger exists to prevent.
|
||||
"""
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="NOBARS")])
|
||||
await session.commit()
|
||||
await ticker_service.set_tracked(session, "NOBARS", tracked=False)
|
||||
|
||||
# Still in the backtest universe...
|
||||
assert await bt._load_backtest_symbols(session) == ["AAPL", "NOBARS"]
|
||||
# ...and still unreadable, so still a coverage gap.
|
||||
assert await bt._fetch_columns(session, "NOBARS") is None
|
||||
|
||||
quality = bt._DataQuality(expected=2, evaluated=1)
|
||||
quality.no_data_symbols.append("NOBARS")
|
||||
quality.record_benchmark({date(2026, 1, 5): 470.0}, None)
|
||||
report = quality.as_report()
|
||||
|
||||
assert report["status"] == "degraded"
|
||||
assert report["no_data_symbols"] == ["NOBARS"]
|
||||
assert "1 symbol had no stored bars (NOBARS)" in report["reasons"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracking ownership (item 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_manual_addition_survives_index_pruning(session: AsyncSession, monkeypatch):
|
||||
"""The RHM case. A hand-added symbol is by definition absent from the
|
||||
constituent list, so without the ownership filter every prune would sweep
|
||||
it."""
|
||||
await _seed(session)
|
||||
session.add(Ticker(symbol="MINE", tracking_source=ticker_service.SOURCE_MANUAL))
|
||||
await session.commit()
|
||||
|
||||
summary = await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
|
||||
assert summary["untracked_symbols"] == ["LEAVER"]
|
||||
assert summary["kept_manual"] == ["MINE"]
|
||||
assert summary["kept_manual_count"] == 1
|
||||
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL", "MINE"]
|
||||
|
||||
|
||||
async def test_a_bootstrap_never_converts_a_manual_row_to_index_owned(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
"""Ownership is sticky towards manual. If a bootstrap could claim a manual
|
||||
row just because the symbol joined the index, the protection would vanish
|
||||
the moment it left again — which is exactly when it is needed."""
|
||||
await _seed(session)
|
||||
session.add(Ticker(symbol="MINE", tracking_source=ticker_service.SOURCE_MANUAL))
|
||||
await session.commit()
|
||||
|
||||
# MINE joins the index.
|
||||
await _prune_to(session, monkeypatch, ["AAPL", "MINE"])
|
||||
mine = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "MINE"))
|
||||
).scalar_one()
|
||||
assert mine.tracking_source == ticker_service.SOURCE_MANUAL
|
||||
|
||||
# ...and later leaves it again. Still protected.
|
||||
summary = await _prune_to(session, monkeypatch, ["AAPL"])
|
||||
assert summary["kept_manual"] == ["MINE"]
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL", "MINE"]
|
||||
|
||||
|
||||
async def test_a_manual_add_claims_an_index_owned_symbol(session: AsyncSession):
|
||||
"""An explicit "I want this stock" has to stop it being prunable."""
|
||||
await _seed(session)
|
||||
|
||||
claimed = await ticker_service.add_ticker(session, "leaver")
|
||||
assert claimed.tracking_source == ticker_service.SOURCE_MANUAL
|
||||
|
||||
# Claiming ownership is not the same as asking for it to be scanned, so the
|
||||
# off-switches are left exactly as they were.
|
||||
assert claimed.scan_disabled_on is None
|
||||
assert claimed.untracked_on is None
|
||||
|
||||
|
||||
async def test_adding_an_already_manual_symbol_still_raises(session: AsyncSession):
|
||||
from app.exceptions import DuplicateError
|
||||
|
||||
await _seed(session)
|
||||
await ticker_service.add_ticker(session, "LEAVER")
|
||||
with pytest.raises(DuplicateError):
|
||||
await ticker_service.add_ticker(session, "LEAVER")
|
||||
|
||||
|
||||
async def test_re_entry_clears_the_prune_flag_but_never_the_operator_switch(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
"""The conflation bug the extra column exists to prevent: one column serving
|
||||
both writers means a prune -> re-entry cycle silently re-enables a symbol
|
||||
somebody deliberately switched off."""
|
||||
await _seed(session)
|
||||
await ticker_service.set_tracked(session, "LEAVER", tracked=False)
|
||||
|
||||
await _prune_to(session, monkeypatch, ["AAPL"]) # leaves the index
|
||||
summary = await _prune_to(session, monkeypatch, ["AAPL", "LEAVER"]) # returns
|
||||
|
||||
assert summary["retracked"] == 1
|
||||
leaver = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "LEAVER"))
|
||||
).scalar_one()
|
||||
assert leaver.untracked_on is None, "the prune flag is cleared by re-entry"
|
||||
assert leaver.scan_disabled_on is not None, "the operator's switch is not"
|
||||
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL"]
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Observed membership intervals.
|
||||
|
||||
The table answers "what could the strategy actually have selected on date D?"
|
||||
Three properties make that answer trustworthy:
|
||||
|
||||
1. History starts at the first *observation*. Nothing invents an index effective
|
||||
date for a symbol that was already tracked when recording began.
|
||||
2. Intervals are append-only. Leaving and returning produces two rows, so a
|
||||
point-in-time query for the gap correctly answers "not a member".
|
||||
3. A failed universe fetch records nothing. The seed fallback is ~44 symbols, so
|
||||
treating it as an observation would close ~460 memberships on a network blip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.universe_membership import UniverseMembership
|
||||
from app.services import ticker_service
|
||||
from app.services import ticker_universe_service as tus
|
||||
|
||||
_engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _setup_tables() -> AsyncGenerator[None, None]:
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with _session_factory() as s:
|
||||
yield s
|
||||
|
||||
|
||||
async def _sync(session, monkeypatch, symbols: list[str], *, source: str = "wikipedia_sp500",
|
||||
prune: bool = False) -> dict:
|
||||
async def fake_fetch(db, universe):
|
||||
return symbols, source
|
||||
|
||||
monkeypatch.setattr(tus, "fetch_universe_symbols", fake_fetch)
|
||||
return await tus.bootstrap_universe(session, "sp500", prune_missing=prune)
|
||||
|
||||
|
||||
async def _intervals(session, symbol: str) -> list[tuple]:
|
||||
rows = (await session.execute(
|
||||
select(UniverseMembership.first_seen_on, UniverseMembership.absent_since)
|
||||
.join(Ticker, Ticker.id == UniverseMembership.ticker_id)
|
||||
.where(Ticker.symbol == symbol)
|
||||
.order_by(UniverseMembership.id)
|
||||
)).all()
|
||||
return [tuple(r) for r in rows]
|
||||
|
||||
|
||||
async def test_a_live_sync_opens_intervals_without_pruning(session: AsyncSession, monkeypatch):
|
||||
"""The scheduled job runs prune_missing=False. If membership were only
|
||||
recorded on prunes the history would be full of holes."""
|
||||
summary = await _sync(session, monkeypatch, ["AAPL", "MSFT"])
|
||||
|
||||
assert summary["membership_observed"] is True
|
||||
assert summary["membership_opened"] == 2
|
||||
assert summary["membership_closed"] == 0
|
||||
assert summary["untracked"] == 0, "recording membership is not pruning"
|
||||
|
||||
intervals = await _intervals(session, "AAPL")
|
||||
assert len(intervals) == 1
|
||||
first_seen, last_seen = intervals[0]
|
||||
assert last_seen is None, "an open interval means still observed"
|
||||
assert abs((first_seen - date.today()).days) <= 1
|
||||
|
||||
|
||||
async def test_a_second_sync_does_not_duplicate_an_open_interval(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
await _sync(session, monkeypatch, ["AAPL"])
|
||||
summary = await _sync(session, monkeypatch, ["AAPL"])
|
||||
|
||||
assert summary["membership_opened"] == 0
|
||||
assert len(await _intervals(session, "AAPL")) == 1
|
||||
|
||||
|
||||
async def test_leaving_closes_the_interval_and_returning_opens_a_new_one(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
await _sync(session, monkeypatch, ["AAPL", "LEAVER"])
|
||||
closing = await _sync(session, monkeypatch, ["AAPL"])
|
||||
assert closing["membership_closed"] == 1
|
||||
|
||||
intervals = await _intervals(session, "LEAVER")
|
||||
assert len(intervals) == 1
|
||||
assert intervals[0][1] is not None, "departure closes the interval"
|
||||
|
||||
returning = await _sync(session, monkeypatch, ["AAPL", "LEAVER"])
|
||||
assert returning["membership_opened"] == 1
|
||||
|
||||
intervals = await _intervals(session, "LEAVER")
|
||||
assert len(intervals) == 2, "re-entry appends; it does not reopen"
|
||||
assert intervals[0][1] is not None, "the earlier interval keeps its end date"
|
||||
assert intervals[1][1] is None
|
||||
|
||||
|
||||
async def test_a_failed_fetch_records_nothing(session: AsyncSession, monkeypatch):
|
||||
"""A cache or seed result means the live fetch failed. The seed list is a
|
||||
fraction of the real universe — closing memberships from it would rewrite
|
||||
history on a network blip."""
|
||||
await _sync(session, monkeypatch, ["AAPL", "MSFT"])
|
||||
|
||||
for failed_source in ("cache", "seed"):
|
||||
summary = await _sync(session, monkeypatch, ["AAPL"], source=failed_source)
|
||||
assert summary["membership_observed"] is False
|
||||
assert summary["membership_closed"] == 0
|
||||
assert summary["membership_opened"] == 0
|
||||
|
||||
# MSFT's membership is untouched: we never looked, so we know nothing new.
|
||||
msft = await _intervals(session, "MSFT")
|
||||
assert len(msft) == 1
|
||||
assert msft[0][1] is None, "still open — a failed fetch is not a departure"
|
||||
|
||||
|
||||
async def test_membership_is_recorded_for_manual_rows_too(session: AsyncSession, monkeypatch):
|
||||
"""Membership and tracking ownership are independent axes: a manually owned
|
||||
symbol that is genuinely in the index still gets intervals."""
|
||||
session.add(Ticker(symbol="MINE", tracking_source=ticker_service.SOURCE_MANUAL))
|
||||
await session.commit()
|
||||
|
||||
await _sync(session, monkeypatch, ["AAPL", "MINE"])
|
||||
|
||||
mine = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "MINE"))
|
||||
).scalar_one()
|
||||
assert mine.tracking_source == ticker_service.SOURCE_MANUAL
|
||||
assert len(await _intervals(session, "MINE")) == 1
|
||||
|
||||
|
||||
async def test_switching_off_scanning_does_not_close_a_membership(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
"""The index still contains the symbol; we simply stopped looking at it.
|
||||
Conflating those would corrupt the point-in-time answer."""
|
||||
await _sync(session, monkeypatch, ["AAPL", "MSFT"])
|
||||
await ticker_service.set_tracked(session, "MSFT", tracked=False)
|
||||
|
||||
summary = await _sync(session, monkeypatch, ["AAPL", "MSFT"])
|
||||
assert summary["membership_closed"] == 0
|
||||
assert (await _intervals(session, "MSFT"))[0][1] is None
|
||||
|
||||
|
||||
async def test_pruning_does_not_close_a_membership_by_itself(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
"""Closing happens because the symbol was absent from a live list, not
|
||||
because a prune ran — the two just usually coincide."""
|
||||
await _sync(session, monkeypatch, ["AAPL", "LEAVER"])
|
||||
session.add(Ticker(symbol="MINE", tracking_source=ticker_service.SOURCE_MANUAL))
|
||||
await session.commit()
|
||||
|
||||
summary = await _sync(session, monkeypatch, ["AAPL"], prune=True)
|
||||
# LEAVER closed because it was absent; MINE never had an interval to close.
|
||||
assert summary["membership_closed"] == 1
|
||||
assert await _intervals(session, "MINE") == []
|
||||
|
||||
|
||||
async def test_a_failed_fetch_does_not_untrack_anything(session: AsyncSession, monkeypatch):
|
||||
"""The seed fallback is ~44 symbols against a ~500-symbol universe. Pruning
|
||||
from it would untrack the entire index on a single network blip — skipping
|
||||
membership recording is only half the safeguard."""
|
||||
session.add_all([
|
||||
Ticker(symbol=s, tracking_source=ticker_service.SOURCE_INDEX)
|
||||
for s in ("AAPL", "MSFT", "NVDA")
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
summary = await _sync(session, monkeypatch, ["AAPL"], source="seed", prune=True)
|
||||
|
||||
assert summary["untracked"] == 0, "a failed fetch must not drive pruning"
|
||||
live = await session.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
assert list(live.scalars().all()) == ["AAPL", "MSFT", "NVDA"]
|
||||
|
||||
|
||||
async def test_a_failed_fetch_does_not_clear_a_prune_flag(session: AsyncSession, monkeypatch):
|
||||
"""Re-entry is a claim about the index. A cache fallback is not evidence the
|
||||
symbol rejoined it."""
|
||||
session.add_all([
|
||||
Ticker(symbol=s, tracking_source=ticker_service.SOURCE_INDEX)
|
||||
for s in ("AAPL", "LEAVER")
|
||||
])
|
||||
await session.commit()
|
||||
await _sync(session, monkeypatch, ["AAPL"], prune=True) # LEAVER untracked
|
||||
|
||||
summary = await _sync(session, monkeypatch, ["AAPL", "LEAVER"], source="cache")
|
||||
assert summary["retracked"] == 0, "stale data must not re-admit a symbol"
|
||||
|
||||
leaver = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == "LEAVER"))
|
||||
).scalar_one()
|
||||
assert leaver.untracked_on is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval boundaries, stated as executable rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _interval(session, symbol: str, universe: str, first: date, absent: date | None):
|
||||
"""Write one interval directly, so boundary tests don't depend on today."""
|
||||
ticker = (
|
||||
await session.execute(select(Ticker).where(Ticker.symbol == symbol))
|
||||
).scalar_one_or_none()
|
||||
if ticker is None:
|
||||
ticker = Ticker(symbol=symbol, tracking_source=ticker_service.SOURCE_INDEX)
|
||||
session.add(ticker)
|
||||
await session.flush()
|
||||
session.add(UniverseMembership(
|
||||
ticker_id=ticker.id, universe=universe,
|
||||
first_seen_on=first, absent_since=absent,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_membership_bounds_are_lower_inclusive_upper_exclusive(session: AsyncSession):
|
||||
"""``absent_since`` is the first observation that found the symbol GONE, so
|
||||
on that date it is already absent. ``first_seen_on`` is the first that found
|
||||
it present, so on that date it is already a member."""
|
||||
await _interval(session, "EDGE", "sp500", date(2026, 3, 10), date(2026, 8, 20))
|
||||
|
||||
async def members(d: date) -> list[str]:
|
||||
return await tus.members_on(session, "sp500", d)
|
||||
|
||||
assert await members(date(2026, 3, 9)) == [], "the day before first observation"
|
||||
assert await members(date(2026, 3, 10)) == ["EDGE"], "lower bound is inclusive"
|
||||
assert await members(date(2026, 8, 19)) == ["EDGE"], "still present the day before"
|
||||
assert await members(date(2026, 8, 20)) == [], "upper bound is exclusive"
|
||||
|
||||
|
||||
async def test_an_open_interval_has_no_upper_bound(session: AsyncSession):
|
||||
await _interval(session, "OPEN", "sp500", date(2026, 3, 10), None)
|
||||
assert await tus.members_on(session, "sp500", date(2030, 1, 1)) == ["OPEN"]
|
||||
|
||||
|
||||
async def test_a_gap_between_two_intervals_is_not_membership(session: AsyncSession):
|
||||
"""The reason re-entry appends instead of reopening."""
|
||||
await _interval(session, "GAP", "sp500", date(2026, 1, 5), date(2026, 3, 2))
|
||||
await _interval(session, "GAP", "sp500", date(2026, 8, 3), None)
|
||||
|
||||
assert await tus.members_on(session, "sp500", date(2026, 2, 1)) == ["GAP"]
|
||||
assert await tus.members_on(session, "sp500", date(2026, 5, 1)) == []
|
||||
assert await tus.members_on(session, "sp500", date(2026, 9, 1)) == ["GAP"]
|
||||
|
||||
|
||||
async def test_membership_is_scoped_per_universe(session: AsyncSession):
|
||||
await _interval(session, "BOTH", "sp500", date(2026, 1, 5), None)
|
||||
await _interval(session, "BOTH", "nasdaq100", date(2026, 6, 1), None)
|
||||
await _interval(session, "SPONLY", "sp500", date(2026, 1, 5), None)
|
||||
|
||||
assert await tus.members_on(session, "sp500", date(2026, 7, 1)) == ["BOTH", "SPONLY"]
|
||||
assert await tus.members_on(session, "nasdaq100", date(2026, 7, 1)) == ["BOTH"]
|
||||
# Before it joined the Nasdaq-100 it was only an S&P name.
|
||||
assert await tus.members_on(session, "nasdaq100", date(2026, 5, 1)) == []
|
||||
|
||||
|
||||
async def test_before_recording_began_returns_empty_not_the_current_set(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
"""The honest limit of this table: a date before the first successful live
|
||||
sync means "we were not looking", not "the index was empty". Answering with
|
||||
today's set would be exactly the survivorship error item 3 exists to end."""
|
||||
await _sync(session, monkeypatch, ["AAPL", "MSFT"])
|
||||
|
||||
assert await tus.members_on(session, "sp500", date(2020, 1, 2)) == []
|
||||
assert await tus.members_on(session, "sp500", date.today()) == ["AAPL", "MSFT"]
|
||||
Reference in new issue
Block a user