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 {{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }} @@ -129,6 +130,20 @@ Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score. + + + Fibonacci + {{ armedTool === 'fibonacci' ? 'drag swings' : '' }} + + + Label + + Colour + Width{{ width }}px + + Drag from one swing to the other. Levels at 0, 23.6, 38.2, 50, 61.8, 78.6 and 100. + + Trendline @@ -235,6 +250,7 @@ Levels Comments Symbols + Fibonacci All TFs @@ -288,6 +304,7 @@ #{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }} #{{ item.number }} · SYMBOL · {{ item.comment.note }} + #{{ item.number }} · FIB · {{ item.label }} Price
Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.
Drag from one swing to the other. Levels at 0, 23.6, 38.2, 50, 61.8, 78.6 and 100.