A comment is a ManualLine with kind="comment", so it inherits persistence, the shared drawing-number sequence, the list, filtering and deletion rather than needing a parallel set of endpoints. The rule that must never bend is that ManualLineStore.levels() excludes them: a comment reaching the level list would join a confluence cluster and push a phone notification about a piece of text. It is created with armed=False, and PATCH returns to_dict() rather than to_level() for a comment, so nothing is ever handed a level-shaped comment. Three tests cover the exclusion, the shared numbering and the kind derived for drawings saved before comments existed. Pinned comments carry anchor_t and anchor_p and travel with the chart; floating ones carry x and y as fractions of the pane, hold their place through any zoom and can be dragged. They render as DOM rather than canvas because they hold arbitrary text, collapse to a numbered dot, and a floating one must ignore the time scale entirely. A pinned comment scrolled out of view parks on the edge it left, pointing back toward itself, so it never simply disappears. Lines & levels becomes Drawings, filtered by type and by text. The text match covers the label, the kind and the #number, so "comment", "cpi" and "7" all narrow the list, and Delete acts on whatever the filter shows — which is what makes deleting by type or by string one button. Verified in a browser: placing a comment renders it at the click, clicking it collapses it, scrolling away parks it on the edge, a floating one holds its pixel position through a 300-bar scroll, and the filters cut 3 rows to 1 by type, 1 by text and 0 for a miss, with no console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
8 KiB
Python
232 lines
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()
|
|
# 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 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)
|