diff --git a/.gitignore b/.gitignore index bc4f72a..10768c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc .venv/ .env +.schwab_token.json +data/manual_lines.json diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py new file mode 100644 index 0000000..1949a95 --- /dev/null +++ b/app/analysis/manual_lines.py @@ -0,0 +1,110 @@ +import json +from dataclasses import asdict, dataclass, replace +from pathlib import Path + +from app.analysis.levels import Level, LevelKind, Side +from app.bars.models import Timeframe +from app.config import TIMEFRAME_WEIGHT + + +@dataclass(slots=True) +class ManualLine: + id: str + tf: Timeframe + side: Side + anchor_t: int + anchor_p: float + slope: float + last_t: int + created_at: int + note: str = "" + hidden: bool = False + + def to_level(self) -> Level: + return Level( + id=self.id, + kind=LevelKind.MANUAL, + tf=self.tf, + side=self.side, + weight=TIMEFRAME_WEIGHT[self.tf], + score=1.0, + label=f"{self.tf.value} {self.side.value}", + anchor_t=self.anchor_t, + anchor_p=self.anchor_p, + slope=self.slope, + points=None, + touches=0, + first_t=self.anchor_t, + last_t=self.last_t, + provisional=False, + hidden=self.hidden, + ) + + def to_dict(self) -> dict: + value = asdict(self) + value["tf"] = self.tf.value + value["side"] = self.side.value + return value + + @classmethod + def from_dict(cls, value: dict) -> "ManualLine": + return cls( + id=str(value["id"]), + tf=Timeframe(value["tf"]), + side=Side(value["side"]), + anchor_t=int(value["anchor_t"]), + anchor_p=float(value["anchor_p"]), + slope=float(value["slope"]), + last_t=int(value.get("last_t", value["anchor_t"])), + created_at=int(value["created_at"]), + note=str(value.get("note", "")), + hidden=bool(value.get("hidden", False)), + ) + + +class ManualLineStore: + def __init__(self, path: str | Path): + self.path = Path(path) + self.lines: dict[str, ManualLine] = {line.id: line for line in self.load()} + + def load(self) -> list[ManualLine]: + if not self.path.exists(): + return [] + payload = json.loads(self.path.read_text(encoding="utf-8")) + return [ManualLine.from_dict(value) for value in payload] + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + temporary.write_text( + json.dumps( + [line.to_dict() for line in self.lines.values()], + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + temporary.replace(self.path) + + def add(self, line: ManualLine) -> ManualLine: + self.lines[line.id] = line + self.save() + return line + + def update(self, line_id: str, changes: dict) -> ManualLine: + if line_id not in self.lines: + raise KeyError(line_id) + line = replace(self.lines[line_id], **changes) + self.lines[line_id] = line + self.save() + return line + + def delete(self, line_id: str) -> None: + if line_id not in self.lines: + raise KeyError(line_id) + del self.lines[line_id] + self.save() + + def levels(self) -> list[Level]: + return [line.to_level() for line in self.lines.values()] diff --git a/app/api/routes.py b/app/api/routes.py index ffeaf25..4a69c3f 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,10 +1,33 @@ -from fastapi import APIRouter, HTTPException, Query, Request +import time +import uuid + +from fastapi import APIRouter, HTTPException, Query, Request, Response +from pydantic import BaseModel from app.bars.models import Timeframe +from app.analysis.levels import Side +from app.analysis.manual_lines import ManualLine router = APIRouter(prefix="/api") +class LineCreate(BaseModel): + tf: Timeframe + side: Side + anchor_t: int + anchor_p: float + end_t: int + end_p: float + note: str = "" + hidden: bool = False + + +class LinePatch(BaseModel): + side: Side | None = None + note: str | None = None + hidden: bool | None = None + + @router.get("/health") def health(): return {"status": "ok", "service": "chart"} @@ -52,3 +75,46 @@ def confluence(request: Request): "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, + ) + runtime = request.app.state.runtime + 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) + 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) diff --git a/app/api/ws.py b/app/api/ws.py index ef39365..1042e51 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -60,26 +60,31 @@ async def websocket_endpoint(websocket: WebSocket): async def receive(): nonlocal tf, prefs - while True: - message = await websocket.receive_json() - if message.get("type") == "subscribe": - tf = Timeframe(message.get("tf", "1m")) - await websocket.send_json(snapshot(runtime, tf, prefs)) - elif message.get("type") == "prefs": - prefs = message - clusters = connection_clusters(runtime, prefs) - await websocket.send_json( - { - "type": "clusters", - "price": runtime.price, - "clusters": [cluster.to_dict() for cluster in clusters], - } - ) + try: + while True: + message = await websocket.receive_json() + if message.get("type") == "subscribe": + tf = Timeframe(message.get("tf", "1m")) + await websocket.send_json(snapshot(runtime, tf, prefs)) + elif message.get("type") == "prefs": + prefs = message + clusters = connection_clusters(runtime, prefs) + await websocket.send_json( + { + "type": "clusters", + "price": runtime.price, + "clusters": [cluster.to_dict() for cluster in clusters], + } + ) + except WebSocketDisconnect: + queue.put_nowait({"type": "disconnect"}) receiver = asyncio.create_task(receive()) try: while True: event = await queue.get() + if event["type"] == "disconnect": + break if event["type"] == "bar" and event["bar"].tf is tf: await websocket.send_json( {"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()} diff --git a/app/runtime.py b/app/runtime.py index fc4204c..3a09521 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -7,6 +7,7 @@ from app.analysis.levels import Level from app.analysis.moving_averages import build_ma_levels from app.analysis.confluence import Cluster, cluster_levels from app.analysis.indicators import atr +from app.analysis.manual_lines import ManualLineStore from app.bars.store import InMemoryBarStore from app.config import Settings from app.market.factory import live_source, seed_source @@ -24,14 +25,19 @@ class Runtime: clusters: list[Cluster] = field(default_factory=list) price: float | None = None atr15: float = 0.0 + manual_lines: ManualLineStore = field(init=False) + ma_levels: list[Level] = field(default_factory=list) def __post_init__(self) -> None: self.store = InMemoryBarStore(self.settings.max_bars_per_tf) self.aggregator = Aggregator(self.settings.enabled_timeframes) + self.manual_lines = ManualLineStore(self.settings.manual_lines_path) + self.levels = self.manual_lines.levels() self.stream = StreamService(live_source(self.settings), self.settings.yahoo_symbol) self.stream.add_handler(self.on_bar) async def on_bar(self, bar: Bar) -> None: + evaluate_alerts = False for aggregated in self.aggregator.update(bar): self.store.put(aggregated) self.broadcast({"type": "bar", "bar": aggregated}) @@ -39,9 +45,11 @@ class Runtime: self.rebuild_levels() if aggregated.tf is Timeframe.M1 and aggregated.closed: self.price = aggregated.c - values = atr(self.store.get(Timeframe.M15), 14) - self.atr15 = next((value for value in reversed(values) if value is not None), 0.0) - self.rebuild_clusters(evaluate_alerts=True) + evaluate_alerts = True + if evaluate_alerts: + values = atr(self.store.get(Timeframe.M15), 14) + self.atr15 = next((value for value in reversed(values) if value is not None), 0.0) + self.rebuild_clusters(evaluate_alerts=True) def broadcast(self, event: dict) -> None: for queue in self.subscribers.copy(): @@ -50,10 +58,11 @@ class Runtime: queue.put_nowait(event) def rebuild_levels(self) -> None: - self.levels = build_ma_levels( + self.ma_levels = build_ma_levels( {tf: self.store.get(tf) for tf in self.settings.ma_sets}, self.settings.ma_sets, ) + self.levels = self.ma_levels + self.manual_lines.levels() self.broadcast({"type": "levels", "levels": self.levels}) self.rebuild_clusters() diff --git a/static/app.js b/static/app.js index 91ce2e5..9f66156 100644 --- a/static/app.js +++ b/static/app.js @@ -16,6 +16,11 @@ createApp({ const levels = ref([]); const clusters = ref([]); const alerts = ref([]); + const drawMode = ref(false); + const drawSide = ref('support'); + const snap = ref(true); + const drawPoints = ref([]); + const selectedLine = ref(null); const timeframes = ['1m', '2m', '5m', '15m', '30m', '1h', '4h', '1d']; const now = ref(Date.now()); let chartApi = null; @@ -81,6 +86,68 @@ createApp({ oscillator.stop(context.currentTime + 0.35); } + function toggleDraw() { + drawMode.value = !drawMode.value; + drawPoints.value = []; + selectedLine.value = null; + } + + async function handleChartClick(param) { + if (!drawMode.value) { + selectedLine.value = chartApi.hitTest(param); + return; + } + const point = chartApi.pointFromClick(param, snap.value); + if (point.snappedSide) drawSide.value = point.snappedSide; + drawPoints.value.push(point); + if (drawPoints.value.length < 2) return; + const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t); + if (start.t === end.t) { drawPoints.value = []; return; } + const temporaryId = `tmp_${Date.now()}`; + const optimistic = { + id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value, + weight: 1, score: 1, label: `${timeframe.value} ${drawSide.value}`, + anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t), + points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false, + }; + levels.value.push(optimistic); + syncVisibleLevels(); + drawPoints.value = []; + drawMode.value = false; + try { + const response = await fetch('/api/lines', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const saved = await response.json(); + levels.value = levels.value.filter(level => level.id !== temporaryId); + if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved); + selectedLine.value = saved.id; + } catch (error) { + levels.value = levels.value.filter(level => level.id !== temporaryId); + console.error('Unable to save line', error); + } + syncVisibleLevels(); + } + + async function deleteSelected() { + if (!selectedLine.value) return; + const id = selectedLine.value; + levels.value = levels.value.filter(level => level.id !== id); + selectedLine.value = null; + syncVisibleLevels(); + const response = await fetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`); + } + + function handleKeydown(event) { + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) { + event.preventDefault(); + deleteSelected(); + } + } + function selectTimeframe(tf) { timeframe.value = tf; prefs.value.base_tf = tf; @@ -123,6 +190,8 @@ createApp({ onMounted(() => { chartApi = new ConfluenceChart(); chartApi.create(document.getElementById('chart')); + chartApi.setClickHandler(handleChartClick); + window.addEventListener('keydown', handleKeydown); refreshStatus(); connect(); timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000); @@ -131,8 +200,9 @@ createApp({ clearInterval(timer); if (socket) socket.close(); if (chartApi) chartApi.destroy(); + window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, selectTimeframe, allEnabled, toggleGroup }; + return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawSide, snap, drawPoints, selectedLine, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 50ced03..ce39825 100644 --- a/static/chart.js +++ b/static/chart.js @@ -4,6 +4,9 @@ class ConfluenceChart { this.candles = null; this.resizeObserver = null; this.levelSeries = new Map(); + this.bars = []; + this.levels = []; + this.onChartClick = null; } create(el) { @@ -28,16 +31,25 @@ class ConfluenceChart { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); }); this.resizeObserver.observe(el); + this.chart.subscribeClick(param => { + if (param.point && param.time && this.onChartClick) this.onChartClick(param); + }); } setBars(bars) { + this.bars = bars; this.candles.setData(bars.map(this.toCandle)); this.chart.timeScale().fitContent(); } - updateBar(bar) { this.candles.update(this.toCandle(bar)); } + updateBar(bar) { + this.candles.update(this.toCandle(bar)); + if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; + else this.bars.push(bar); + } syncLevels(levels) { + this.levels = levels; const wanted = new Set(levels.filter(level => !level.hidden).map(level => level.id)); for (const [id, entry] of this.levelSeries) { if (!wanted.has(id)) { @@ -48,10 +60,11 @@ class ConfluenceChart { for (const level of levels) { if (level.hidden) continue; let entry = this.levelSeries.get(level.id); + const isMa = level.kind === 'ma'; const options = { color: ConfluenceChart.tfColors[level.tf], lineWidth: level.tf === '1d' ? 2 : 1, - lineType: LightweightCharts.LineType.WithSteps, + lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, lastValueVisible: true, @@ -63,10 +76,53 @@ class ConfluenceChart { } else { entry.series.applyOptions(options); } - entry.series.setData((level.points || []).map(([time, value]) => ({ time, value }))); + let data; + if (isMa) { + data = (level.points || []).map(([time, value]) => ({ time, value })); + } else { + const first = this.bars[0]?.t || level.anchor_t; + const last = this.bars[this.bars.length - 1]?.t || level.last_t; + const extension = Math.max(60, Math.floor((last - first) * 0.2)); + const end = Math.max(last, level.last_t) + extension; + data = [ + { time: level.anchor_t, value: level.anchor_p }, + { time: end, value: level.anchor_p + level.slope * (end - level.anchor_t) }, + ]; + } + entry.series.setData(data); } } + setClickHandler(handler) { this.onChartClick = handler; } + + pointFromClick(param, snap) { + const rawPrice = this.candles.coordinateToPrice(param.point.y); + let point = { t: Number(param.time), p: rawPrice, snappedSide: null }; + if (!snap || !this.bars.length) return point; + const nearest = this.bars.reduce((best, bar) => Math.abs(bar.t - point.t) < Math.abs(best.t - point.t) ? bar : best); + const candidates = [ + { p: nearest.h, side: 'resistance' }, + { p: nearest.l, side: 'support' }, + ]; + const snapped = candidates + .map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - param.point.y) })) + .sort((a, b) => a.distance - b.distance)[0]; + if (snapped.distance <= 8) point = { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; + return point; + } + + hitTest(param) { + const t = Number(param.time); + let best = null; + for (const level of this.levels.filter(value => value.kind === 'manual')) { + const price = level.anchor_p + level.slope * (t - level.anchor_t); + const coordinate = this.candles.priceToCoordinate(price); + const distance = Math.abs(coordinate - param.point.y); + if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; + } + return best?.id || null; + } + toCandle(bar) { return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c }; } diff --git a/static/index.html b/static/index.html index e11307f..42020ea 100644 --- a/static/index.html +++ b/static/index.html @@ -20,6 +20,14 @@