chart/app/api/routes.py

373 lines
14 KiB
Python

import json
import base64
import json
import logging
import time
import uuid
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from app.bars.models import Timeframe
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
from app.api.deps import require_token
from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture
# 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),
},
}
@router.get("/bars")
def bars(request: Request, tf: str = "1m", limit: int = Query(500, ge=1, le=5000)):
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.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}")
def get_debug_capture(capture_id: str):
"""A diagnostic capture, behind the same auth as everything else.
These are screenshots of somebody's screen. The id is 72 bits of entropy so
the URL is unguessable, but a capability URL still leaks through anything
that records URLs — proxy logs, browser history, a pasted link. Requiring a
session costs nothing: a browser already holds the cookie, and an API client
already sends the token.
"""
path = capture_path(capture_id, ".png")
if path is None:
raise HTTPException(404, "Capture not found")
return FileResponse(path, media_type="image/png")
@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 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)