fix(tickers): honor the effective date instead of retiring on the mark
Deploy / lint (push) Successful in 11s
Deploy / test (push) Successful in 1m22s
Deploy / deploy (push) Successful in 38s

active_only tested delisted_on IS NULL, so a symbol dropped out of signals the
moment a Form 25 was detected — ten days before Rule 12d2-2 makes the removal
effective, while it was demonstrably still trading. A manual future-dated mark
behaved the same way. It now compares against the database's own date, so a
pending delisting stays live until the day it takes effect.

That exposes a second problem the fix would otherwise create. Trading typically
stops before the ten-day delay expires, so across that window the symbol is
correctly active yet produces no bars — and confirm_delisting returned None for
an already-marked row, which would have fired the staleness warning daily for
ten days, the exact noise this flow exists to remove. It now reports the known
effective date on every path where the delisting is established, so the caller
warns only about gaps that are still unexplained.

CURRENT_DATE renders identically on postgres and sqlite, and the OR is
parenthesized when callers chain further where clauses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:58:51 +02:00
co-authored by Claude Opus 5
parent 1d4ed39fd2
commit 247a7889b9
2 changed files with 84 additions and 16 deletions
+34 -15
View File
@@ -4,7 +4,7 @@ import logging
import re
from datetime import date, timedelta
from sqlalchemy import select, update
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import DuplicateError, NotFoundError, ValidationError
@@ -42,15 +42,23 @@ def _sec_client_factory():
return SecClient()
def active_only(stmt):
def active_only(stmt, *, as_of: date | None = None):
"""Restrict a Ticker query to symbols that still trade.
Opt-in on purpose rather than folded into a shared getter: list and admin
views deliberately keep delisted rows so the delisting is *visible*, which a
silent default would undo. Apply this on the live signal path — scanning,
ranking, scoring, breadth, ingestion — and nowhere else.
``delisted_on`` is an *effective* date, and a Form 25 is known ten days
before it takes effect, so a future date must not drop the symbol yet — it
is still trading and still worth scanning and ingesting. Compared in SQL
against the database's own date; ``as_of`` overrides it for tests.
"""
return stmt.where(Ticker.delisted_on.is_(None))
cutoff = func.current_date() if as_of is None else as_of
return stmt.where(
or_(Ticker.delisted_on.is_(None), Ticker.delisted_on > cutoff)
)
async def add_ticker(db: AsyncSession, symbol: str) -> Ticker:
@@ -155,9 +163,16 @@ async def confirm_delisting(
"""Ask SEC whether ``symbol`` actually delisted; mark it if so.
Called when OHLCV goes stale, because "no new bars" alone cannot tell a
delisting from a halt or a rename. Returns the effective date when this call
marked the symbol, else ``None`` — already-marked and unconfirmed both return
``None``, so the caller keeps its existing alert for anything unproven.
delisting from a halt or a rename. Returns the effective date whenever the
symbol is known to have delisted — whether this call established that or an
earlier one did — and ``None`` while it remains unproven, so the caller warns
only about gaps that still have no explanation.
Returning the already-known date matters between filing and effect: trading
usually stops before the ten-day Rule 12d2-2 delay expires, so the symbol is
correctly still active (see ``active_only``) while producing no bars. Without
this the staleness warning would fire daily across that window — the exact
noise the delisting flow exists to remove.
Deliberately driven by staleness rather than by the SEC fundamentals import:
that importer stalls for days at a time on unrelated Company-Facts gaps, and
@@ -173,17 +188,21 @@ async def confirm_delisting(
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None or not ticker.cik:
if ticker is None:
return None
# Already confirmed by SEC — nothing left to learn. A row an operator marked
# by hand is still worth probing: Form 25 upgrades the estimated date.
known = ticker.delisted_on
# Already confirmed by SEC — nothing left to learn, but the caller still
# needs the date to know this gap is explained. A row an operator marked by
# hand is worth probing: Form 25 upgrades the estimated date.
if ticker.delisted_reason == REASON_FORM_25:
return None
return known
if not ticker.cik:
return known
# No bars at all is an ingestion problem, not evidence of a delisting.
if last_bar is None:
return None
return known
if ((today or date.today()) - last_bar).days < MIN_STALE_DAYS_BEFORE_PROBE:
return None
return known
try:
async with _sec_client_factory() as client:
@@ -196,10 +215,10 @@ async def confirm_delisting(
except SecError:
# Never let a probe failure escalate a routine staleness warning.
logger.warning("delisting probe failed for %s", normalised, exc_info=True)
return None
return known
if filing is None:
return None
return known
# Removal takes effect ten days after filing, so the filing date is not the
# date the symbol stopped trading.
effective = filing["filing_date"] + timedelta(days=FORM_25_EFFECTIVE_DAYS)
@@ -207,7 +226,7 @@ async def confirm_delisting(
db, normalised, delisted_on=effective, reason=REASON_FORM_25
):
return effective
return None
return known
async def clear_delisted(db: AsyncSession, symbol: str) -> bool: