From 58a64c1eebac85da535ac3f9aad4e9381c628e6c Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Wed, 19 Aug 2026 09:09:47 +0000 Subject: [PATCH] fib levels --- app/analysis/manual_lines.py | 6 +- app/api/routes.py | 13 ++-- docs/implementation.md | 9 +++ static/app.js | 48 ++++++++++++++- static/chart.js | 112 ++++++++++++++++++++++++++++++++++- static/index.html | 17 ++++++ tests/test_fibonacci.py | 42 +++++++++++++ tests/test_manual_lines.py | 4 +- 8 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 tests/test_fibonacci.py diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 23510ce..91fb776 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -54,6 +54,10 @@ class ManualLine: def is_comment(self) -> bool: return self.drawing_kind in {"comment", "symbol"} + @property + def is_overlay(self) -> bool: + return self.drawing_kind in {"comment", "symbol", "fibonacci"} + @property def horizontal(self) -> bool: """A typed price level rather than a drawn trendline.""" @@ -194,7 +198,7 @@ class ManualLineStore: put it in a confluence cluster and fire a push notification about a piece of text. """ - return [line.to_level() for line in self.lines.values() if not line.is_comment] + return [line.to_level() for line in self.lines.values() if not line.is_overlay] def drawings(self) -> list[ManualLine]: """Everything drawn, comments included, newest number last.""" diff --git a/app/api/routes.py b/app/api/routes.py index 62c04df..b672c33 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -82,6 +82,7 @@ class LineCreate(BaseModel): line_width: int = Field(2, ge=1, le=9) cutoff_t: int | None = None armed: bool = True + kind: str = "" class PriceAlertCreate(BaseModel): @@ -214,16 +215,17 @@ def put_ma_alerts(request: Request, payload: dict): @router.post("/lines", status_code=201) def create_line(request: Request, payload: LineCreate): - if payload.end_t == payload.anchor_t: + if payload.end_t == payload.anchor_t and payload.kind != "fibonacci": raise HTTPException(400, "Line endpoints must have different times") + last_t = payload.end_t if payload.end_t != payload.anchor_t else payload.anchor_t + 1 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, + slope=(payload.end_p - payload.anchor_p) / (last_t - payload.anchor_t), + last_t=last_t, created_at=int(time.time()), note=payload.note, hidden=payload.hidden, @@ -231,10 +233,13 @@ def create_line(request: Request, payload: LineCreate): line_width=payload.line_width, cutoff_t=payload.cutoff_t, armed=payload.armed, + kind=payload.kind, ) runtime = request.app.state.runtime line = runtime.manual_lines.add(line) runtime.rebuild_levels() + if line.is_overlay: + return {**line.to_dict(), "kind": line.drawing_kind} return positioned_level(runtime, line.id).to_dict() @@ -388,7 +393,7 @@ def patch_line(request: Request, line_id: str, payload: LinePatch): 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 positioned_level( + return line.to_dict() if line.is_overlay else positioned_level( request.app.state.runtime, line.id, ).to_dict() diff --git a/docs/implementation.md b/docs/implementation.md index f3003a7..c883ec1 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1207,3 +1207,12 @@ past `bars[-1].t` asked `timeToCoordinate` for that bar. Placement and rendering now share the same future-slot map trendlines already use (`indexAt` / `timeAtIndex` / `coordinateAtTime`). A mark in the right-hand whitespace stays there. + +### 2026-08-18 — Fibonacci retrace + +A Fibonacci tool under Tools: drag from one swing to the other, same +two-click/drag gesture as a trendline. Stored as `kind=fibonacci` so it +shares numbering, hide, delete and colour with other drawings, but it is +not a confluence level. The chart draws native price lines at 0, 23.6, +38.2, 50, 61.8, 78.6 and 100 between the two prices. 0% is the first +click. diff --git a/static/app.js b/static/app.js index 6c134aa..d366e92 100644 --- a/static/app.js +++ b/static/app.js @@ -130,6 +130,7 @@ createApp({ // Comments are not levels, so they never arrive over the levels socket and // are fetched and refreshed explicitly. const comments = ref([]); + const fibs = ref([]); const drawingFilter = ref(''); const drawingKind = ref('all'); const maAlerts = ref({ '1d': [] }); @@ -281,7 +282,15 @@ createApp({ hidden: comment.hidden, comment, })); - return [...lines, ...notes].sort((a, b) => a.number - b.number); + const retraces = fibs.value.map(fib => ({ + id: fib.id, + number: fib.number, + kind: 'fibonacci', + label: fib.note || 'fib', + hidden: fib.hidden, + line: fib, + })); + return [...lines, ...notes, ...retraces].sort((a, b) => a.number - b.number); }); const filteredDrawings = computed(() => { @@ -691,6 +700,10 @@ createApp({ await createSymbol(result); return; } + if (result.tool === 'fibonacci') { + await createFibonacci(result.start, result.end); + return; + } if (result.tool === 'level') { alertPrice.value = result.price; await addPriceAlert(); @@ -704,7 +717,10 @@ createApp({ if (!response.ok) return; const payload = await response.json(); comments.value = (payload.drawings || []).filter(item => ['comment', 'symbol'].includes(item.kind)); + fibs.value = (payload.drawings || []).filter(item => item.kind === 'fibonacci'); syncComments(); + if (chartApi) chartApi.setFibs(prefs.value.enabled.drawings + ? fibs.value.filter(item => !item.hidden) : []); } function syncComments() { @@ -808,6 +824,27 @@ createApp({ syncVisibleLevels(); } + async function createFibonacci(start, end) { + const side = start.p <= end.p ? 'support' : 'resistance'; + const response = await apiFetch('/api/lines', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tf: timeframe.value, side, + anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, + note: drawName.value, color: drawColor.value, line_width: drawWidth.value, + kind: 'fibonacci', + }), + }); + if (!response.ok) { + console.error(`Unable to save fibonacci: HTTP ${response.status}`); + return; + } + await refreshComments(); + const saved = await response.json(); + selectedDrawing.value = saved.id; + selectedDrawings.value = [saved.id]; + } + async function createTrendline(first, second) { const [start, end] = [first, second].sort((a, b) => a.t - b.t); const side = start.snappedSide || end.snappedSide || drawSide.value; @@ -980,7 +1017,7 @@ createApp({ const saved = await response.json(); // A comment is not a level and must not be pushed into the level list, // where it would be drawn as a line. - if (['comment', 'symbol'].includes(line.kind)) { await refreshComments(); return; } + if (['comment', 'symbol', 'fibonacci'].includes(line.kind)) { await refreshComments(); return; } levels.value = levels.value.map(level => level.id === line.id ? saved : level); syncVisibleLevels(); } @@ -1002,6 +1039,7 @@ createApp({ }); if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; } const saved = await response.json(); + if (line.kind === 'fibonacci') { await refreshComments(); return; } levels.value = levels.value.map(level => level.id === line.id ? saved : level); syncVisibleLevels(); } @@ -1197,6 +1235,8 @@ createApp({ syncVisibleLevels(); syncComments(); if (chartApi) chartApi.setRthEnabled(prefs.value.enabled.rth); + if (chartApi) chartApi.setFibs(prefs.value.enabled.drawings + ? fibs.value.filter(item => !item.hidden) : []); sendPrefs(); }, { deep: true }); @@ -1233,7 +1273,9 @@ createApp({ watch(selectedDrawing, id => { if (chartApi) { chartApi.setSelectedDrawing(id); - chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null); + chartApi.setSelectedLine( + levels.value.some(level => level.id === id) || fibs.value.some(fib => fib.id === id) ? id : null, + ); } revealSelectedDrawing(id); }); diff --git a/static/chart.js b/static/chart.js index e234067..4748c2b 100644 --- a/static/chart.js +++ b/static/chart.js @@ -134,6 +134,9 @@ class ConfluenceChart { this.overlayLayer = null; this.rthPrimitive = null; this.rthEnabled = true; + this.fibs = []; + this.fibLines = new Map(); + this.fibPreview = null; this.futureSpace = null; this.snapDot = null; this.snapLabel = null; @@ -373,6 +376,9 @@ class ConfluenceChart { this.previewLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); this.previewLine.setAttribute('hidden', ''); preview.appendChild(this.previewLine); + this.fibPreview = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + this.fibPreview.setAttribute('hidden', ''); + preview.appendChild(this.fibPreview); this.overlayLayer.appendChild(preview); this.lineBridgeLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.lineBridgeLayer.classList.add('chart-line-bridges'); @@ -684,6 +690,49 @@ class ConfluenceChart { } } + setFibs(fibs) { + this.fibs = fibs || []; + this.syncFibLines(); + this.scheduleOverlays(true); + } + + static fibPrices(fib) { + const start = Number(fib.anchor_p); + const end = start + Number(fib.slope || 0) * (Number(fib.last_t) - Number(fib.anchor_t)); + return ConfluenceChart.FIB_RATIOS.map(item => ({ + ...item, + price: start + (end - start) * item.r, + })); + } + + syncFibLines() { + if (!this.candles) return; + const wanted = new Set(); + for (const fib of this.fibs) { + if (fib.hidden) continue; + for (const level of ConfluenceChart.fibPrices(fib)) { + const id = `${fib.id}:${level.r}`; + wanted.add(id); + const options = { + price: level.price, + color: fib.color || '#c8992f', + lineWidth: level.r === 0 || level.r === 0.618 || level.r === 1 ? 2 : 1, + lineStyle: level.r === 0.618 ? LightweightCharts.LineStyle.Solid : LightweightCharts.LineStyle.Dashed, + axisLabelVisible: true, + title: `${level.label} ${level.price.toFixed(2)}`, + }; + const existing = this.fibLines.get(id); + if (existing) existing.applyOptions(options); + else this.fibLines.set(id, this.candles.createPriceLine(options)); + } + } + for (const [id, line] of this.fibLines) { + if (wanted.has(id)) continue; + this.candles.removePriceLine(line); + this.fibLines.delete(id); + } + } + setComments(comments) { this.comments = comments; this.scheduleOverlays(true); @@ -1602,6 +1651,10 @@ class ConfluenceChart { } renderPending(a, b) { + if (this.armedTool === 'fibonacci') { + this.setFibPreview(a, b); + return; + } if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); } @@ -1699,7 +1752,9 @@ class ConfluenceChart { const a = dragged ? this.snapPoint(start) : this.pendingAnchor; this.pendingAnchor = null; this.clearLinePreview(); - if (a.t == null || b.t == null || a.t === b.t) return; + if (a.t == null || b.t == null) return; + if (tool !== 'fibonacci' && a.t === b.t) return; + if (tool === 'fibonacci' && a.p === b.p) return; this.onToolComplete?.({ tool, start: a, end: b }); } @@ -1800,6 +1855,10 @@ class ConfluenceChart { } const a = this.snapPoint(start); const b = this.snapPoint(end); + if (this.armedTool === 'fibonacci') { + this.setFibPreview(a, b); + return; + } if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); } @@ -1879,6 +1938,28 @@ class ConfluenceChart { node.focusTimer = setTimeout(() => node.root.classList.remove('focus'), 800); } + setFibPreview(start, end) { + if (!this.fibPreview || start?.p == null || end?.p == null || start.p === end.p) return; + if (this.previewLine) this.previewLine.setAttribute('hidden', ''); + this.fibPreview.removeAttribute('hidden'); + this.fibPreview.replaceChildren(); + const width = this.overlayLayer?.clientWidth || this.chartEl.clientWidth; + for (const item of ConfluenceChart.FIB_RATIOS) { + const y = this.candles.priceToCoordinate(start.p + (end.p - start.p) * item.r); + if (y == null) continue; + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', 0); + line.setAttribute('x2', width); + line.setAttribute('y1', y); + line.setAttribute('y2', y); + line.setAttribute('stroke', '#c8992f'); + line.setAttribute('stroke-width', item.r === 0 || item.r === 0.618 || item.r === 1 ? 1.5 : 1); + if (item.r !== 0.618) line.setAttribute('stroke-dasharray', '4 4'); + line.setAttribute('opacity', '0.75'); + this.fibPreview.appendChild(line); + } + } + setLinePreview(start, end, color, lineWidth) { if (start.t === end.t) return; const x1 = this.chart.timeScale().timeToCoordinate(start.t); @@ -1898,6 +1979,10 @@ class ConfluenceChart { clearLinePreview() { if (this.previewLine) this.previewLine.setAttribute('hidden', ''); + if (this.fibPreview) { + this.fibPreview.setAttribute('hidden', ''); + this.fibPreview.replaceChildren(); + } } barAtTime(time) { @@ -1970,6 +2055,13 @@ class ConfluenceChart { const distance = Math.abs(lineY - param.point.y); if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; } + for (const fib of this.fibs.filter(item => !item.hidden)) { + for (const level of ConfluenceChart.fibPrices(fib)) { + const y = this.candles.priceToCoordinate(level.price); + const distance = y == null ? Infinity : Math.abs(y - param.point.y); + if (distance <= 6 && (!best || distance < best.distance)) best = { id: fib.id, distance }; + } + } return best?.id || null; } @@ -2314,10 +2406,16 @@ class ConfluenceChart { })); } + fibAsHandleLevel() { + const fib = this.fibs.find(item => item.id === this.selectedLineId && !item.hidden); + if (!fib) return null; + return { ...fib, kind: 'manual', slope: Number(fib.slope) || 0 }; + } + renderAnchorHandles() { const level = this.levels.find( value => value.id === this.selectedLineId && value.kind === 'manual' && !value.hidden, - ); + ) || this.fibAsHandleLevel(); if (!level || !this.anchorHandles.length) { this.anchorHandles.forEach(handle => handle.setAttribute('hidden', '')); this.lineHitTarget?.setAttribute('hidden', ''); @@ -2723,6 +2821,16 @@ class ConfluenceChart { } } +ConfluenceChart.FIB_RATIOS = [ + { r: 0, label: '0' }, + { r: 0.236, label: '23.6' }, + { r: 0.382, label: '38.2' }, + { r: 0.5, label: '50' }, + { r: 0.618, label: '61.8' }, + { r: 0.786, label: '78.6' }, + { r: 1, label: '100' }, +]; + ConfluenceChart.tfColors = { '1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b', '30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073', diff --git a/static/index.html b/static/index.html index 8f0afa2..3be568c 100644 --- a/static/index.html +++ b/static/index.html @@ -31,6 +31,7 @@ HELD {{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }} Drag on the chart from one point to the other Click or drag on the chart to set the price + Drag from one swing to the other Drawing selected — Delete removes it +
+ +
+ + +
+

Drag from one swing to the other. Levels at 0, 23.6, 38.2, 50, 61.8, 78.6 and 100.

+
+