Detection could retire an actively traded symbol — silently, since it then
vanishes from every signal. Three causes:
- Form 25 is filed per security class. An issuer removing its notes, preferred
or warrants files one while the common keeps trading. The filing's own
descriptionClassSecurity distinguishes them, so the primary document is now
fetched and read; anything not recognisably common equity is rejected, as is
anything unreadable (pre-2009 filings have no primary_doc.xml). Fail closed.
- Form 15 ends a reporting obligation and is no evidence trading stopped. The
whole family is dropped.
- A historical filing for a long-gone class could retire a symbol whose bars ran
years later, stamping the old date. Filings before the last bar (less a 30-day
lead for the exchange) are now ignored.
Rule 12d2-2 makes removal effective ten days after filing, so delisted_on is the
effective date rather than the filing date.
bootstrap_universe(prune_missing=True) still ran a cascading delete over
delisted rows, undoing the retention this branch exists for; it now skips them
and reports kept_delisted so the count is explicable.
clear_delisted had no route, which made "safe to automate because it is
reversible" false — reversal needed SQL. POST/DELETE /tickers/{symbol}/delisting
now mark and un-mark, giving an operator a non-destructive alternative to the
cascading DELETE that was the only option.
Shared-CIK siblings (GOOG/GOOGL) stay safe by construction: the probe is
per-symbol and gated on that symbol's own staleness, so a class that still
trades is never probed.
Not addressed: pruning a symbol merely dropped from the index still destroys its
history — the same survivorship problem in a different costume, needing a
tracked/membership state separate from delisting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
86 lines
2.8 KiB
Python
86 lines
2.8 KiB
Python
"""Tickers router: CRUD endpoints for the Ticker Registry."""
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.dependencies import get_db, require_access
|
|
from app.models.user import User
|
|
from app.schemas.common import APIEnvelope
|
|
from app.schemas.ticker import TickerCreate, TickerDelistingUpdate, TickerResponse
|
|
from app.services import ticker_service
|
|
|
|
router = APIRouter(tags=["tickers"])
|
|
|
|
|
|
@router.post("/tickers", response_model=APIEnvelope)
|
|
async def create_ticker(
|
|
body: TickerCreate,
|
|
_user: User = Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Add a new ticker to the registry."""
|
|
ticker = await ticker_service.add_ticker(db, body.symbol)
|
|
return APIEnvelope(
|
|
status="success",
|
|
data=TickerResponse.model_validate(ticker).model_dump(mode="json"),
|
|
)
|
|
|
|
|
|
@router.get("/tickers", response_model=APIEnvelope)
|
|
async def list_tickers(
|
|
_user: User = Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List all tracked tickers sorted alphabetically."""
|
|
tickers = await ticker_service.list_tickers(db)
|
|
return APIEnvelope(
|
|
status="success",
|
|
data=[
|
|
TickerResponse.model_validate(t).model_dump(mode="json")
|
|
for t in tickers
|
|
],
|
|
)
|
|
|
|
|
|
@router.delete("/tickers/{symbol}", response_model=APIEnvelope)
|
|
async def delete_ticker(
|
|
symbol: str,
|
|
_user: User = Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a ticker and all associated data."""
|
|
await ticker_service.delete_ticker(db, symbol)
|
|
return APIEnvelope(status="success", data=None)
|
|
|
|
|
|
@router.post("/tickers/{symbol}/delisting", response_model=APIEnvelope)
|
|
async def mark_ticker_delisted(
|
|
symbol: str,
|
|
body: TickerDelistingUpdate,
|
|
_user: User = Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Retire a symbol: excluded from signals, price history kept.
|
|
|
|
The non-destructive alternative to DELETE, which cascades the history away.
|
|
"""
|
|
changed = await ticker_service.mark_delisted(
|
|
db, symbol, delisted_on=body.delisted_on, reason=ticker_service.REASON_MANUAL
|
|
)
|
|
return APIEnvelope(status="success", data={"changed": changed})
|
|
|
|
|
|
@router.delete("/tickers/{symbol}/delisting", response_model=APIEnvelope)
|
|
async def clear_ticker_delisting(
|
|
symbol: str,
|
|
_user: User = Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Un-retire a symbol wrongly marked delisted.
|
|
|
|
Automatic marking is only defensible because this exists: a false positive
|
|
costs one row update rather than the price history a delete would take.
|
|
"""
|
|
changed = await ticker_service.clear_delisted(db, symbol)
|
|
return APIEnvelope(status="success", data={"changed": changed})
|