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>
230 lines
7.8 KiB
Python
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)
|