"""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, TickerTrackingUpdate, ) 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 ticker to the registry, or claim an existing index-owned one. Responses: - **New symbol** — created as manually tracked and returned. - **Existing, index-owned** — 200 with the existing row, now marked manually tracked. Not a 409: the operator is saying "keep this stock", and refusing while leaving it prunable would defeat the protection they just asked for. Ownership only ever moves towards manual, so a bootstrap cannot take it back when the symbol next joins the index. - **Existing, already manual** — 409 Duplicate. Nothing left to change. Claiming never clears ``scan_disabled_on`` or ``untracked_on``: wanting a stock kept is not the same as asking for it to be scanned, and silently re-enabling something switched off by hand would undo a decision nobody revisited. ``POST /tickers/{symbol}/tracking`` is the explicit control. """ 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.post("/tickers/{symbol}/tracking", response_model=APIEnvelope) async def set_ticker_tracking( symbol: str, body: TickerTrackingUpdate, _user: User = Depends(require_access), db: AsyncSession = Depends(get_db), ): """Include or exclude one symbol from scanning, keeping its history. Untracking is not delisting — the security still trades, we just stop scanning it. The case this exists for: a manually added symbol no provider can serve, which would otherwise sit in the universe forever producing no bars and flagging every backtest as incomplete. """ changed = await ticker_service.set_tracked(db, symbol, tracked=body.tracked) 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})