chart/app/api/routes.py
Chris Amow 078bc8f42c Fix trendline anchors jumping away from where they were drawn
Two faults, both of which made a finished line disagree with its own preview.

A placed anchor was being discarded by a twitch. finishToolGesture recomputed
`dragged` from the second click's own pointerdown/up, so a few pixels of
movement while pressing was read as a fresh press-drag-release: the anchor from
the first click was thrown away and the line began at the second click instead,
to the right of where it was meant to start. The preview had been rubber-banding
from the real anchor the whole time, which is why the result jumped on commit. A
pending anchor now wins over the current click's drag.

Snapping ignored where you pointed. snapPoint took the nearer of the bar's high
and low but only within 8px, so a cursor between the two snapped to neither and
returned a raw mid-bar price — and the side quietly fell back to the dropdown.
The gate is gone: with snapping on, an anchor always lands on the nearer extreme
of the nearest bar, and that choice *is* the side, a high being resistance and a
low support. app.js already preferred snappedSide over the dropdown, so the
inference was written and simply never fired.

snapPoint also guards against being handed an already-snapped point, which
carries no cursor y and previously compared against NaN.

Verified by driving the gesture in a browser: click, move, then a second click
with 4px of movement while pressed now yields anchor_t equal to the first
click's time and anchor_p exactly equal to the bar's high.

Also lands the groundwork for chart comments, inert until the UI is wired: a
`kind` on ManualLine with `pinned`, `x`, `y` and `collapsed`, a POST /comments
endpoint, GET /drawings, and the chart's DOM comment layer. Comments are stored
with the lines so they share numbering, filtering and deletion, and
ManualLineStore.levels() excludes them — a comment reaching the level list would
join a confluence cluster and push a notification about a piece of text. Tests
cover the exclusion, the derived kind for lines saved before comments existed,
and that numbering is shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:18:40 -05:00

230 lines
7.8 KiB
Python

import time
import uuid
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
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
# /version live in app.api.meta and stay open on purpose.
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
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=4)
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=4)
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=4)
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
@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(),
"warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe},
}
@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,
)
runtime = request.app.state.runtime
line = runtime.manual_lines.add(line)
runtime.rebuild_levels()
return line.to_level().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,
)
line = runtime.manual_lines.add(line)
runtime.rebuild_levels()
return line.to_level().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
@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="comment",
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()
@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)
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()
return line.to_level().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)