import asyncio import json import base64 import json import logging import time import uuid from datetime import date as Date from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import RedirectResponse from pydantic import BaseModel, Field from app.bars.models import Timeframe from app.config import DEFAULT_MAX_BARS_PER_TF from app.analysis.levels import Side from app.analysis.manual_lines import ManualLine from app.analysis.user_prefs import MA_ALERT_PERIODS from app.api.deps import require_token from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture from app.market.es_options import nearby_expirations from app.market.schwab_quotes import run_search # Everything here needs the token when CHART_AUTH_TOKEN is set. /health and # /version live in app.api.meta and stay open on purpose. logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", dependencies=[Depends(require_token)]) def positioned_level(runtime, line_id: str): return next(level for level in runtime.levels if level.id == line_id) @router.post("/debug/captures", status_code=201) async def create_debug_capture(request: Request): try: content_length = int(request.headers.get("content-length", "0") or 0) except ValueError: raise HTTPException(400, "Invalid Content-Length") from None if content_length > CAPTURE_MAX_BYTES: raise HTTPException(413, "Capture exceeds 10 MB") if request.headers.get("content-type", "").split(";", 1)[0] != "image/png": raise HTTPException(415, "Diagnostic capture must be image/png") try: encoded = request.headers.get("x-capture-metadata", "") metadata = json.loads(base64.b64decode(encoded, validate=True)) if encoded else {} if not isinstance(metadata, dict): raise ValueError except (ValueError, json.JSONDecodeError): raise HTTPException(400, "Invalid capture metadata") from None image = await request.body() try: capture_id, details = save_capture(image, metadata) except ValueError as exc: raise HTTPException(400, str(exc)) from exc logger.warning( "CAPTUREDBG id=%s tf=%s viewport=%sx%s", capture_id, details.get("timeframe"), details.get("viewport_width"), details.get("viewport_height"), ) return { "id": capture_id, "url": f"/api/debug/captures/{capture_id}", "metadata_url": f"/api/debug/captures/{capture_id}/meta", } class LineCreate(BaseModel): tf: Timeframe side: Side anchor_t: int anchor_p: float end_t: int end_p: float note: str = "" hidden: bool = False color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$") line_width: int = Field(2, ge=1, le=9) cutoff_t: int | None = None armed: bool = True class PriceAlertCreate(BaseModel): """A horizontal level typed in rather than drawn. Structurally just a manual line with zero slope, so it inherits persistence, editing, clustering and — importantly — the rule that a hand-placed level alerts regardless of confluence score. """ price: float = Field(gt=0) note: str = "" tf: Timeframe = Timeframe.D1 color: str = Field("#e0a34a", pattern=r"^#[0-9a-fA-F]{6}$") line_width: int = Field(2, ge=1, le=9) alert_early_points: float | None = Field(None, ge=0) class LinePatch(BaseModel): side: Side | None = None note: str | None = None hidden: bool | None = None color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$") line_width: int | None = Field(None, ge=1, le=9) anchor_t: int | None = None anchor_p: float | None = None slope: float | None = None last_t: int | None = None cutoff_t: int | None = None armed: bool | None = None pinned: bool | None = None x: float | None = Field(None, ge=0.0, le=1.0) y: float | None = Field(None, ge=0.0, le=1.0) collapsed: bool | None = None alert_early_points: float | None = Field(None, ge=0) scale: float | None = Field(None, ge=0.5, le=3) @router.get("/status") def status(request: Request): runtime = request.app.state.runtime return { "stream": runtime.stream.status, "source": runtime.stream.source.name, "delay_minutes": runtime.stream.source.delay_minutes, "symbol": runtime.stream.symbol, "last_bar_t": runtime.stream.last_bar_t, "bars_held": runtime.store.counts(), "trendline_geometry": ( "source_tf" if runtime.settings.trendline_source_geometry else "legacy" ), "warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe}, # How late the event loop is running. Rising numbers mean something is # blocking it — see docs/async_refactor.md. "loop_lag_ms": { "recent": round(runtime.loop_lag_recent * 1000, 1), "worst": round(runtime.loop_lag_worst * 1000, 1), }, "needs_login": runtime.needs_login(), } @router.get("/events") def events( request: Request, before: int | None = Query(None), limit: int = Query(50, ge=1, le=200), ): items, more = request.app.state.runtime.events.page(before=before, limit=limit) return {"events": items, "more": more} @router.get("/schwab/login") def schwab_login(request: Request): settings = request.app.state.runtime.settings if not settings.schwab_api_key or not settings.schwab_app_secret: raise HTTPException(503, "Live source is not configured") return RedirectResponse(request.app.state.runtime.start_schwab_login()) @router.get("/bars") def bars( request: Request, tf: str = "1m", limit: int | None = Query(None, ge=1, le=DEFAULT_MAX_BARS_PER_TF), ): try: timeframe = Timeframe(tf) except ValueError as exc: raise HTTPException(400, "Unknown timeframe") from exc values = request.app.state.runtime.store.get(timeframe, limit) return {"tf": timeframe.value, "bars": [bar.to_dict() for bar in values]} @router.get("/levels") def levels(request: Request, tf: str = "all"): values = request.app.state.runtime.levels if tf != "all": try: timeframe = Timeframe(tf) except ValueError as exc: raise HTTPException(400, "Unknown timeframe") from exc values = [level for level in values if level.tf is timeframe] return {"levels": [level.to_dict() for level in values]} @router.get("/confluence") def confluence(request: Request): runtime = request.app.state.runtime return { "price": runtime.price, "clusters": [cluster.to_dict() for cluster in runtime.clusters], } @router.get("/prefs/ma-alerts") def get_ma_alerts(request: Request): return request.app.state.runtime.ma_alerts() @router.put("/prefs/ma-alerts") def put_ma_alerts(request: Request, payload: dict): periods = payload.get("1d", []) if not isinstance(periods, list) or not all(period in MA_ALERT_PERIODS for period in periods): raise HTTPException(400, "Unknown MA period") return request.app.state.runtime.set_ma_alerts({ "1d": sorted({int(period) for period in periods}), }) @router.post("/lines", status_code=201) def create_line(request: Request, payload: LineCreate): if payload.end_t == payload.anchor_t: raise HTTPException(400, "Line endpoints must have different times") line = ManualLine( id=f"ml_{uuid.uuid4().hex}", tf=payload.tf, side=payload.side, anchor_t=payload.anchor_t, anchor_p=payload.anchor_p, slope=(payload.end_p - payload.anchor_p) / (payload.end_t - payload.anchor_t), last_t=payload.end_t, created_at=int(time.time()), note=payload.note, hidden=payload.hidden, color=payload.color, line_width=payload.line_width, cutoff_t=payload.cutoff_t, armed=payload.armed, ) runtime = request.app.state.runtime line = runtime.manual_lines.add(line) runtime.rebuild_levels() return positioned_level(runtime, line.id).to_dict() @router.post("/lines/price", status_code=201) def create_price_alert(request: Request, payload: PriceAlertCreate): runtime = request.app.state.runtime now = int(time.time()) # Side is only used for the label; clustering derives it positionally. reference = runtime.price if runtime.price is not None else payload.price line = ManualLine( id=f"ml_{uuid.uuid4().hex}", tf=payload.tf, side=Side.RESISTANCE if payload.price >= reference else Side.SUPPORT, anchor_t=now, anchor_p=payload.price, slope=0.0, # A horizontal level has no natural end. The span only matters to the # fallback geometry; price_at() is constant either way. last_t=now + 3600, created_at=now, note=payload.note, color=payload.color, line_width=payload.line_width, alert_early_points=payload.alert_early_points, ) line = runtime.manual_lines.add(line) runtime.rebuild_levels() return positioned_level(runtime, line.id).to_dict() class CommentCreate(BaseModel): text: str = Field(min_length=1, max_length=2000) # Pinned to a moment on the chart, or floating and always on screen. pinned: bool = True anchor_t: int | None = None anchor_p: float | None = None x: float = Field(0.72, ge=0.0, le=1.0) y: float = Field(0.12, ge=0.0, le=1.0) color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$") tf: Timeframe = Timeframe.M1 icon: Literal[ "arrow-up", "arrow-down", "face-smile", "hand-point-right", "skull", "face-laugh-squint", "champagne-glasses", "arrow-left", "arrow-right", "hand", "right-to-bracket", "play", ] | None = None scale: float = Field(1.0, ge=0.5, le=3) @router.post("/comments", status_code=201) def create_comment(request: Request, payload: CommentCreate): """A note on the chart. Stored with the lines so it shares their numbering, filtering and deletion, but it is never a level — see ManualLineStore.levels. """ runtime = request.app.state.runtime now = int(time.time()) anchored = payload.anchor_t if payload.anchor_t is not None else now line = ManualLine( id=f"ml_{uuid.uuid4().hex}", tf=payload.tf, # Side is meaningless for a comment; clustering never sees it. side=Side.SUPPORT, anchor_t=anchored, anchor_p=payload.anchor_p if payload.anchor_p is not None else (runtime.price or 0.0), slope=0.0, last_t=anchored, created_at=now, note=payload.text, color=payload.color, kind="symbol" if payload.icon else "comment", icon=payload.icon or "", scale=payload.scale, pinned=payload.pinned, x=payload.x, y=payload.y, # A comment must never alert, whatever else changes around it. armed=False, ) line = runtime.manual_lines.add(line) return line.to_dict() class SnapReport(BaseModel): """What the browser computed for one snap, for diagnosing chart geometry.""" cursor_t: int | None = None cursor_p: float | None = None cursor_x: float | None = None snapped_t: int | None = None snapped_p: float | None = None bars_held: int | None = None first_bar_t: int | None = None last_bar_t: int | None = None tf: str | None = None chart_w: float | None = None chart_h: float | None = None cursor_y: float | None = None # Where the indicator actually landed versus where the price says it should # — the only way to tell a wrong answer from a correctly-computed one drawn # in the wrong place. dot_y: float | None = None expected_y: float | None = None bar_low_y: float | None = None bar_high_y: float | None = None note: str | None = None @router.post("/debug/snap", status_code=204) def debug_snap(payload: SnapReport): """Record one snap sample from a browser running diagnostic mode. Chart geometry bugs live in the client, and the browser is usually on a different machine from whoever is debugging it — so its own numbers cannot be read any other way. Off unless the page is opened with ?diag=1; see AGENTS.md. Logged at warning level so it appears without reconfiguring uvicorn's log levels. """ logger.warning("SNAPDBG %s", payload.model_dump()) return Response(status_code=204) @router.get("/debug/captures/{capture_id}/meta") def get_debug_capture_metadata(capture_id: str): path = capture_path(capture_id, ".json") if path is None: raise HTTPException(404, "Capture not found") return json.loads(path.read_text(encoding="utf-8")) @router.get("/drawings") def drawings(request: Request): """Every drawing, comments included, for the sidebar list.""" store = request.app.state.runtime.manual_lines return {"drawings": [ {**line.to_dict(), "kind": line.drawing_kind} for line in store.drawings() ]} @router.patch("/lines/{line_id}") def patch_line(request: Request, line_id: str, payload: LinePatch): changes = payload.model_dump(exclude_none=True) # None deliberately clears a per-level override and restores ATR behavior. if "alert_early_points" in payload.model_fields_set: changes["alert_early_points"] = payload.alert_early_points if "cutoff_t" in payload.model_fields_set: changes["cutoff_t"] = payload.cutoff_t if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes: raise HTTPException(400, "Line endpoints must have different times") try: line = request.app.state.runtime.manual_lines.update(line_id, changes) except KeyError as exc: raise HTTPException(404, "Line not found") from exc request.app.state.runtime.rebuild_levels() # A comment has no level form — returning one would hand the caller a shape # that looks like something the confluence engine tracks. return line.to_dict() if line.is_comment else positioned_level( request.app.state.runtime, line.id, ).to_dict() @router.delete("/lines/{line_id}", status_code=204) def delete_line(request: Request, line_id: str): try: request.app.state.runtime.manual_lines.delete(line_id) except KeyError as exc: raise HTTPException(404, "Line not found") from exc request.app.state.runtime.rebuild_levels() return Response(status_code=204) @router.get("/es-options/expirations") def es_option_expirations(): return {"expirations": [row.to_dict() for row in nearby_expirations()]} @router.get("/es-options/search") async def es_option_search( request: Request, date: str, root: str, side: Literal["P", "C"] = "P", mode: Literal["delta", "price"] = "delta", min: float = Query(...), max: float = Query(...), ): try: day = Date.fromisoformat(date) except ValueError as exc: raise HTTPException(400, "Invalid expiration date") from exc if not root or len(root) > 16: raise HTTPException(400, "Invalid root") try: result = await asyncio.to_thread( run_search, request.app.state.runtime.settings, day=day, root=root, side=side, mode=mode, low=min, high=max, ) except FileNotFoundError as exc: raise HTTPException(503, "Schwab token missing") from exc except ValueError as exc: raise HTTPException(502, str(exc)) from exc return result