Files
signal-platform/app/services/ticker_service.py
T
dennisthiessenandClaude Opus 5 247a7889b9
Deploy / lint (push) Successful in 11s
Deploy / test (push) Successful in 1m22s
Deploy / deploy (push) Successful in 38s
fix(tickers): honor the effective date instead of retiring on the mark
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>
2026-08-11 16:58:51 +02:00

254 lines
9.6 KiB
Python

"""Ticker Registry service: add, delete, list, and retire tracked tickers."""
import logging
import re
from datetime import date, timedelta
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import DuplicateError, NotFoundError, ValidationError
from app.models.ticker import Ticker
logger = logging.getLogger(__name__)
# Reasons a symbol may be marked delisted, narrowest first.
REASON_FORM_25 = "form_25" # SEC Form 25/25-NSE/15 confirmed the exchange exit
REASON_MANUAL = "manual" # an operator decided
# How long a symbol must be without bars before we spend an SEC request asking
# whether it delisted. Guards against a market-data outage probing the whole
# universe at once; a real delisting is still stale days later.
MIN_STALE_DAYS_BEFORE_PROBE = 3
# Rule 12d2-2: a Form 25 removal takes effect ten days after filing, so the
# filing date is not the date the security stopped trading.
FORM_25_EFFECTIVE_DAYS = 10
# How far before the last bar a Form 25 may be filed and still explain this gap.
# An exchange can file shortly before trading actually stops; anything older
# concerns a class that was already gone while the symbol kept printing bars.
FILING_LOOKBACK_DAYS = 30
def _sec_client_factory():
"""Build the SEC client for a delisting probe (patched in tests).
Imported lazily so the SEC/httpx stack stays off the import path of every
module that only wants ``active_only``.
"""
from app.services.sec_client import SecClient
return SecClient()
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.
"""
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:
"""Add a new ticker after validation.
Validates: non-empty, uppercase alphanumeric. Auto-uppercases input.
Raises DuplicateError if symbol already tracked.
"""
stripped = symbol.strip()
if not stripped:
raise ValidationError("Ticker symbol must not be empty or whitespace-only")
normalised = stripped.upper()
if not re.fullmatch(r"[A-Z0-9]+", normalised):
raise ValidationError(
f"Ticker symbol must be alphanumeric: {normalised}"
)
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
if result.scalar_one_or_none() is not None:
raise DuplicateError(f"Ticker already exists: {normalised}")
ticker = Ticker(symbol=normalised)
db.add(ticker)
await db.commit()
await db.refresh(ticker)
return ticker
async def delete_ticker(db: AsyncSession, symbol: str) -> None:
"""Delete a ticker and cascade all associated data.
Raises NotFoundError if the symbol is not tracked.
"""
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
await db.delete(ticker)
await db.commit()
async def list_tickers(db: AsyncSession) -> list[Ticker]:
"""Return all tracked tickers sorted alphabetically by symbol.
Delisted symbols are included and carry ``delisted_on`` — the registry is
where an operator needs to *see* that a symbol retired, not where it should
quietly disappear.
"""
result = await db.execute(select(Ticker).order_by(Ticker.symbol.asc()))
return list(result.scalars().all())
async def mark_delisted(
db: AsyncSession,
symbol: str,
*,
delisted_on: date,
reason: str = REASON_MANUAL,
) -> bool:
"""Record that a symbol stopped trading. True if this changed anything.
Idempotent, so the staleness path can call it every run without churning the
row: re-marking is a no-op. The one exception is an SEC confirmation landing
on a row an operator marked by hand — Form 25 carries the real effective
date, so it replaces the operator's estimate. Nothing downgrades a confirmed
row back to a manual one.
"""
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
if ticker.delisted_on is not None:
upgrading = (
reason == REASON_FORM_25 and ticker.delisted_reason != REASON_FORM_25
)
if not upgrading:
return False
await db.execute(
update(Ticker)
.where(Ticker.id == ticker.id)
.values(delisted_on=delisted_on, delisted_reason=reason)
)
await db.commit()
logger.info(
"ticker %s marked delisted on %s (%s)", normalised, delisted_on, reason
)
return True
async def confirm_delisting(
db: AsyncSession,
symbol: str,
*,
last_bar: date | None,
today: date | None = None,
) -> date | None:
"""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 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
detection wired into it would stall with it.
The probe waits for ``MIN_STALE_DAYS_BEFORE_PROBE``. A delisted symbol stays
stale forever, so the delay costs nothing, and it keeps a broad market-data
outage — where every tracked symbol reports stale at once — from turning into
one SEC request per symbol per run.
"""
from app.services.sec_client import SecError
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
return None
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 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 known
if ((today or date.today()) - last_bar).days < MIN_STALE_DAYS_BEFORE_PROBE:
return known
try:
async with _sec_client_factory() as client:
# Only a Form 25 filed around or after the last bar can explain THIS
# gap. An older one belongs to a class that stopped trading before
# the symbol was still printing bars, and must not retire it.
filing = await client.delisting_filing(
ticker.cik, not_before=last_bar - timedelta(days=FILING_LOOKBACK_DAYS)
)
except SecError:
# Never let a probe failure escalate a routine staleness warning.
logger.warning("delisting probe failed for %s", normalised, exc_info=True)
return known
if filing is 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)
if await mark_delisted(
db, normalised, delisted_on=effective, reason=REASON_FORM_25
):
return effective
return known
async def clear_delisted(db: AsyncSession, symbol: str) -> bool:
"""Un-retire a symbol. True if it had been marked.
The counterpart that makes automatic marking acceptable: a false positive
costs one row update, where a delete would have cost the price history.
"""
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
if ticker.delisted_on is None:
return False
await db.execute(
update(Ticker)
.where(Ticker.id == ticker.id)
.values(delisted_on=None, delisted_reason=None)
)
await db.commit()
logger.info("ticker %s un-marked as delisted", normalised)
return True