chart/app/api/routes.py
Chris Amow cc25871032 Keep the Schwab token alive, and reconnect from the header
The live socket never made a REST call, so the seven-day refresh
token expired while the chart still looked fine. A deploy then
could not log in. Ping user preferences every six hours, and when
the grant is already dead offer a one-click reconnect that writes
the token on the existing callback.
2026-08-18 10:11:02 +00:00

432 lines
15 KiB
Python

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("/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