feat(universe): separate scanner membership from retained history
Deploy / lint (push) Successful in 43s
Deploy / test (push) Failing after 2m9s
Deploy / deploy (push) Skipped

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:
dennisthiessenandClaude Opus 5 committed 2026-09-07 19:30:05 +02:00
1 parent f2ef8e5438
commit ad3e47dc50
17 files changed
+2027 -57

No files matched your search

+86
View File
@@ -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",)]
+132
View File
@@ -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",)]
+19 -8
View File
@@ -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):
+19 -5
View File
@@ -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
+477
View File
@@ -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"]