373 lines
14 KiB
Python
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)
|