chart/app/api/routes.py
Chris Amow 2efcec6a76 Price trendlines across bars, add one-shot alerts, collapse layers, drop 1h MAs
The trendline bug: a line continued past its second anchor at a different
slope. Two conventions were fighting, and both were wrong.

Lightweight Charts spaces bars evenly however much time separates them — a
weekend is forty-nine hours and one bar wide. The renderer extended the line by
interpolating between bar indices, which looked straight but disagreed with the
server, since price_at() advances per second. Measured on real bars that reached
147 points: the chart drew a level the alerts did not believe in. Making the
renderer match price_at() fixed the disagreement and made the visible kick worse,
because now the line really did climb an hour's worth of slope across a one-bar
maintenance break.

Neither convention is what a person means by drawing a line. A trendline advances
per bar, so both sides now evaluate in bar space: a new bar_space module the
runtime uses to position sloped levels, mirrored by indexAt() in the chart. The
line is straight on screen and the alert fires where it is drawn.

Also, from testing against the live chart:

- A plain click with the trendline tool armed did nothing and left the tool
  armed, so the next click began a new line — which is how the slope change was
  first noticed. Click-click and press-drag-release are both supported now, with
  the rubber band following the cursor between clicks.
- Hand-placed levels are armed, fire once, then disarm themselves, and can be
  re-armed from the sidebar. Verified end to end: created armed, tripped within
  thirty seconds, disarmed, re-armed.
- Layers is collapsible.
- The 1h moving averages are gone; only the daily set remains.

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

174 lines
5.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
@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()
@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)