feat(tickers): record delisting instead of deleting the symbol
Retiring a symbol meant delete_ticker or bootstrap_universe(prune_missing), both of which cascade through OHLCV, setups and scores. That destroys exactly the history four research documents already apologise for: today's tracked universe projected backward is survivorship-biased, and hard-deleting every delisted name is what causes it. Keeping the rows preserves the option to fix that — it does not fix it, which needs the replay to model a delisting as an exit event. tickers gains delisted_on / delisted_reason (migration 032). NULL means actively traded. The filter is opt-in via ticker_service.active_only rather than folded into a shared getter: the registry and admin views deliberately keep delisted rows so the delisting is visible, and a silent default would undo that. Applied to the live path only — scanner, momentum ranking, scoring, breadth, fundamentals candidates, SEC universe, earnings import, ingestion loops. run_backtest keeps them on purpose. Detection runs off OHLCV staleness, not off the SEC fundamentals import: that importer stalls for days on unrelated Company-Facts gaps and would take detection down with it. On a stale symbol the scheduler asks SEC for a Form 25/25-NSE/15 and retires it only on a hit, so a halt or a rename (SATS->ECHO) keeps the existing warning. The probe waits 3 stale days so a market-data outage cannot turn into one SEC request per symbol per run. Safe to automate because it is reversible: clear_delisted un-retires a false positive, where a delete had already taken the history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+36
-11
@@ -66,6 +66,7 @@ from app.services.event_study_service import run_and_store as run_event_study_an
|
||||
from app.services.outcome_service import evaluate_pending_setups
|
||||
from app.services.rr_scanner_service import scan_all_tickers
|
||||
from app.services.sentiment_provider_service import build_sentiment_provider
|
||||
from app.services import ticker_service
|
||||
from app.services.ticker_universe_service import bootstrap_universe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -396,8 +397,10 @@ async def _is_job_enabled(db: AsyncSession, job_name: str) -> bool:
|
||||
|
||||
|
||||
async def _get_all_tickers(db: AsyncSession) -> list[str]:
|
||||
"""Return all tracked ticker symbols sorted alphabetically."""
|
||||
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
"""Return all actively-traded ticker symbols sorted alphabetically."""
|
||||
result = await db.execute(
|
||||
ticker_service.active_only(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@@ -412,8 +415,10 @@ async def _get_ohlcv_priority_tickers(db: AsyncSession) -> list[str]:
|
||||
latest_date = func.max(OHLCVRecord.date)
|
||||
missing_first = case((latest_date.is_(None), 0), else_=1)
|
||||
result = await db.execute(
|
||||
select(Ticker.symbol)
|
||||
.outerjoin(OHLCVRecord, OHLCVRecord.ticker_id == Ticker.id)
|
||||
ticker_service.active_only(
|
||||
select(Ticker.symbol)
|
||||
.outerjoin(OHLCVRecord, OHLCVRecord.ticker_id == Ticker.id)
|
||||
)
|
||||
.group_by(Ticker.id, Ticker.symbol)
|
||||
.order_by(missing_first.asc(), latest_date.asc(), Ticker.symbol.asc())
|
||||
)
|
||||
@@ -662,14 +667,34 @@ async def collect_ohlcv(
|
||||
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
|
||||
_log_event(logging.INFO, "ticker_collected", job=job_name, ticker=symbol, status=result.status, records=result.records_ingested)
|
||||
if result.status == "stale":
|
||||
await _record_system_event(
|
||||
severity="warning",
|
||||
source=job_name,
|
||||
code="ohlcv_stale",
|
||||
message=result.message or f"No new OHLCV bars for {symbol}",
|
||||
symbol=symbol,
|
||||
dedup_key=f"ohlcv_stale:{symbol}",
|
||||
# "No new bars" cannot distinguish a delisting from a halt
|
||||
# or a rename, so ask SEC before warning again. A confirmed
|
||||
# delisting retires the symbol (keeping its history) and
|
||||
# ends the alert; anything unproven keeps warning.
|
||||
delisted_on = await ticker_service.confirm_delisting(
|
||||
db, symbol, last_bar=result.last_date
|
||||
)
|
||||
if delisted_on is not None:
|
||||
await _record_system_event(
|
||||
severity="info",
|
||||
source=job_name,
|
||||
code="ticker_delisted",
|
||||
message=(
|
||||
f"{symbol} delisted on {delisted_on} (SEC Form 25/15). "
|
||||
"Retired from signals; price history retained."
|
||||
),
|
||||
symbol=symbol,
|
||||
dedup_key=f"ticker_delisted:{symbol}",
|
||||
)
|
||||
else:
|
||||
await _record_system_event(
|
||||
severity="warning",
|
||||
source=job_name,
|
||||
code="ohlcv_stale",
|
||||
message=result.message or f"No new OHLCV bars for {symbol}",
|
||||
symbol=symbol,
|
||||
dedup_key=f"ohlcv_stale:{symbol}",
|
||||
)
|
||||
if result.status == "partial":
|
||||
# Rate limited — stop and resume next run
|
||||
_log_event(logging.WARNING, "rate_limited", job=job_name, ticker=symbol, processed=processed)
|
||||
|
||||
Reference in New Issue
Block a user