diff --git a/.env.example b/.env.example index 94175b5..3b732b8 100644 --- a/.env.example +++ b/.env.example @@ -7,11 +7,10 @@ SEED_1H_RANGE=730d SEED_1M_RANGE=8d # Chart and analysis -TIMEFRAMES=1m,2m,5m,15m,30m,1h,4h,1d +TIMEFRAMES=1m,5m,15m,30m,1h,1d BASE_TIMEFRAMES=1m,30m,1d MAX_BARS_PER_TF=5000 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 -MA_SETS__4H= MA_SETS__1H= DAILY_ANCHOR_ET=18:00 MANUAL_LINES_PATH=./data/manual_lines.json diff --git a/README.md b/README.md index 5ea309b..a617f7f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted an runs with `--reload`, so edits to `main.py` or `static/` take effect without a rebuild. Rebuild only when `requirements.txt` changes. +The Compose stack also includes Playwright for browser screenshots. It reaches +the app over the internal Compose network and writes ignored artifacts locally: + +```bash +docker compose exec playwright playwright screenshot \ + --lang en-US --wait-for-timeout 5000 http://api:8000 /artifacts/chart.png +``` + Without Docker: ```bash diff --git a/app/analysis/levels.py b/app/analysis/levels.py index 228dfaf..4001741 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -36,6 +36,9 @@ class Level: provisional: bool hidden: bool period: int | None = None + color: str | None = None + line_width: int | None = None + number: int | None = None def price_at(self, t: int) -> float: return self.anchor_p + self.slope * (t - self.anchor_t) diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 1949a95..7ba3c07 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -1,6 +1,7 @@ import json from dataclasses import asdict, dataclass, replace from pathlib import Path +from threading import RLock from app.analysis.levels import Level, LevelKind, Side from app.bars.models import Timeframe @@ -19,6 +20,9 @@ class ManualLine: created_at: int note: str = "" hidden: bool = False + color: str = "#65b7cf" + line_width: int = 2 + number: int = 0 def to_level(self) -> Level: return Level( @@ -28,7 +32,7 @@ class ManualLine: side=self.side, weight=TIMEFRAME_WEIGHT[self.tf], score=1.0, - label=f"{self.tf.value} {self.side.value}", + label=self.note or f"{self.tf.value} {self.side.value}", anchor_t=self.anchor_t, anchor_p=self.anchor_p, slope=self.slope, @@ -38,6 +42,9 @@ class ManualLine: last_t=self.last_t, provisional=False, hidden=self.hidden, + color=self.color, + line_width=self.line_width, + number=self.number, ) def to_dict(self) -> dict: @@ -59,13 +66,23 @@ class ManualLine: created_at=int(value["created_at"]), note=str(value.get("note", "")), hidden=bool(value.get("hidden", False)), + color=str(value.get("color", "#65b7cf")), + line_width=int(value.get("line_width", 2)), + number=int(value.get("number", 0)), ) 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()} + self._lock = RLock() + loaded = self.load() + next_number = max((line.number for line in loaded), default=0) + for index, line in enumerate(loaded): + if line.number <= 0: + next_number += 1 + loaded[index] = replace(line, number=next_number) + self.lines: dict[str, ManualLine] = {line.id: line for line in loaded} def load(self) -> list[ManualLine]: if not self.path.exists(): @@ -74,37 +91,43 @@ class ManualLineStore: 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, + with self._lock: + 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", ) - + "\n", - encoding="utf-8", - ) - temporary.replace(self.path) + temporary.replace(self.path) def add(self, line: ManualLine) -> ManualLine: - self.lines[line.id] = line - self.save() - return line + with self._lock: + if line.number <= 0: + line = replace(line, number=max((value.number for value in self.lines.values()), default=0) + 1) + 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 + with self._lock: + 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() + with self._lock: + 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 4a69c3f..8d0b915 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -2,7 +2,7 @@ import time import uuid from fastapi import APIRouter, HTTPException, Query, Request, Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.bars.models import Timeframe from app.analysis.levels import Side @@ -20,12 +20,16 @@ class LineCreate(BaseModel): 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 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) @router.get("/health") @@ -39,6 +43,7 @@ def status(request: Request): 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(), @@ -92,9 +97,11 @@ def create_line(request: Request, payload: LineCreate): created_at=int(time.time()), note=payload.note, hidden=payload.hidden, + color=payload.color, + line_width=payload.line_width, ) runtime = request.app.state.runtime - runtime.manual_lines.add(line) + line = runtime.manual_lines.add(line) runtime.rebuild_levels() return line.to_level().to_dict() diff --git a/app/config.py b/app/config.py index fd9487e..30e04e4 100644 --- a/app/config.py +++ b/app/config.py @@ -27,11 +27,10 @@ class Settings(BaseSettings): yahoo_poll_seconds: float = 20 seed_1h_range: str = "730d" seed_1m_range: str = "8d" - timeframes: str = "1m,2m,5m,15m,30m,1h,4h,1d" + timeframes: str = "1m,5m,15m,30m,1h,1d" base_timeframes: str = "1m,30m,1d" max_bars_per_tf: int = 5000 ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200" - ma_sets__4h: str = "" ma_sets__1h: str = "" daily_anchor_et: str = "18:00" manual_lines_path: Path = Path("./data/manual_lines.json") @@ -50,7 +49,6 @@ class Settings(BaseSettings): def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]: configured = { Timeframe.D1: self.ma_sets__1d, - Timeframe.H4: self.ma_sets__4h, Timeframe.H1: self.ma_sets__1h, } result: dict[Timeframe, list[tuple[str, int]]] = {} diff --git a/app/market/base.py b/app/market/base.py index 93102f2..1171f8f 100644 --- a/app/market/base.py +++ b/app/market/base.py @@ -6,6 +6,7 @@ from app.bars.models import Bar, Timeframe class MarketDataSource(Protocol): name: str + delay_minutes: int def supports_history(self) -> bool: ... diff --git a/app/market/replay.py b/app/market/replay.py index 51fbdf4..88ed36a 100644 --- a/app/market/replay.py +++ b/app/market/replay.py @@ -8,6 +8,7 @@ from app.bars.models import Bar, Timeframe class ReplaySource: name = "replay" + delay_minutes = 0 def __init__(self, path: str | Path, speed: float = 0): self.path = Path(path) diff --git a/app/market/yahoo.py b/app/market/yahoo.py index c2f9f11..1de3064 100644 --- a/app/market/yahoo.py +++ b/app/market/yahoo.py @@ -47,6 +47,7 @@ def parse_chart(payload: dict[str, Any], tf: Timeframe, source: str = "yahoo") - class YahooSource: name = "yahoo" + delay_minutes = 10 def __init__(self, poll_seconds: float = 20, client: httpx.AsyncClient | None = None): self.poll_seconds = poll_seconds diff --git a/docker-compose.yml b/docker-compose.yml index 0a1c10d..97d52d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,9 +17,11 @@ services: playwright: image: mcr.microsoft.com/playwright:v1.55.0-noble - command: sleep infinity + command: sh -c "npm install --global playwright@1.55.0 && sleep infinity" depends_on: - api ipc: host + environment: + - LANG=en_US.UTF-8 volumes: - ./artifacts/playwright:/artifacts diff --git a/static/app.js b/static/app.js index 4fd7d34..9839076 100644 --- a/static/app.js +++ b/static/app.js @@ -2,7 +2,7 @@ const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue; const defaultPrefs = { base_tf: '1m', - enabled: { ma: { '1d': [10, 20, 50, 100, 200], '4h': [], '1h': [] }, manual: true, auto: false }, + enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false }, hidden_levels_score: false, }; @@ -17,11 +17,15 @@ createApp({ const clusters = ref([]); const alerts = ref([]); const drawMode = ref(false); + const drawName = ref(''); + const drawColor = ref('#65b7cf'); + const drawWidth = ref(2); 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 selectedLines = ref([]); + const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; const now = ref(Date.now()); let chartApi = null; let socket = null; @@ -32,6 +36,9 @@ createApp({ const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t)); return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`; }); + const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); + const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); + const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); async function refreshStatus() { const response = await fetch('/api/status'); @@ -95,12 +102,13 @@ createApp({ function handleChartMove(param) { if (!drawMode.value || drawPoints.value.length !== 1) return; - chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), timeframe.value); + chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), drawColor.value, drawWidth.value); } async function handleChartClick(param) { if (!drawMode.value) { selectedLine.value = chartApi.hitTest(param); + selectedLines.value = selectedLine.value ? [selectedLine.value] : []; return; } const point = chartApi.pointFromClick(param, snap.value); @@ -113,9 +121,10 @@ createApp({ 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}`, + weight: 1, score: 1, label: drawName.value || `${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, + color: drawColor.value, line_width: drawWidth.value, }; levels.value.push(optimistic); syncVisibleLevels(); @@ -124,13 +133,14 @@ createApp({ 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 }), + 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, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }), }); 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; + selectedLines.value = [saved.id]; } catch (error) { levels.value = levels.value.filter(level => level.id !== temporaryId); console.error('Unable to save line', error); @@ -139,13 +149,64 @@ createApp({ } async function deleteSelected() { - if (!selectedLine.value) return; - const id = selectedLine.value; - levels.value = levels.value.filter(level => level.id !== id); - selectedLine.value = null; + const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean); + await deleteLines(ids); + } + + async function deleteLine(id) { + await deleteLines([id]); + } + + async function deleteLines(ids) { + if (!ids.length) return; + const deleting = new Set(ids); + levels.value = levels.value.filter(level => !deleting.has(level.id)); + if (selectedLine.value && deleting.has(selectedLine.value)) selectedLine.value = null; + selectedLines.value = selectedLines.value.filter(id => !deleting.has(id)); + syncVisibleLevels(); + const responses = []; + for (const id of ids) { + responses.push(await fetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' })); + } + responses.forEach(response => { + if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`); + }); + } + + function selectLine(id) { + selectedLine.value = id; + selectedLines.value = [id]; + } + + function toggleLineSelection(id) { + selectedLines.value = selectedLines.value.includes(id) + ? selectedLines.value.filter(value => value !== id) + : [...selectedLines.value, id]; + selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null; + } + + function toggleSelectAll() { + selectedLines.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id); + selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null; + } + + async function deleteSelectedLines() { + await deleteLines([...selectedLines.value]); + } + + async function renameLine(line, name) { + await updateLineStyle(line, { note: name.trim() }); + } + + async function updateLineStyle(line, changes) { + const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(changes), + }); + if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; } + const saved = await response.json(); + levels.value = levels.value.map(level => level.id === line.id ? saved : level); 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) { @@ -211,6 +272,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawSide, snap, drawPoints, selectedLine, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected }; + return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 6dcf1df..f5045cb 100644 --- a/static/chart.js +++ b/static/chart.js @@ -4,14 +4,18 @@ class ConfluenceChart { this.candles = null; this.resizeObserver = null; this.levelSeries = new Map(); - this.previewSeries = null; + this.previewLine = null; this.bars = []; this.levels = []; this.onChartClick = null; this.onChartMove = null; + this.tooltip = null; + this.chartEl = null; + this.clickListener = null; } create(el) { + this.chartEl = el; this.chart = LightweightCharts.createChart(el, { autoSize: true, layout: { @@ -34,11 +38,31 @@ 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); - }); + this.tooltip = document.createElement('div'); + this.tooltip.className = 'chart-tooltip'; + el.appendChild(this.tooltip); + const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + preview.classList.add('chart-preview'); + preview.setAttribute('aria-hidden', 'true'); + this.previewLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.previewLine.setAttribute('hidden', ''); + preview.appendChild(this.previewLine); + el.appendChild(preview); + this.clickListener = event => { + if (!this.onChartClick) return; + const bounds = el.getBoundingClientRect(); + const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }; + const time = this.chart.timeScale().coordinateToTime(point.x); + if (time != null) this.onChartClick({ point, time }); + }; + el.addEventListener('click', this.clickListener); this.chart.subscribeCrosshairMove(param => { - if (param.point && param.time && this.onChartMove) this.onChartMove(param); + if (!param.point || !param.time) { + this.tooltip.hidden = true; + return; + } + this.updateLineTooltip(param); + if (this.onChartMove) this.onChartMove(param); }); } @@ -71,13 +95,15 @@ class ConfluenceChart { 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, + color: level.color || ConfluenceChart.tfColors[level.tf], + lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1), lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, lastValueVisible: true, title: level.label, + // Overlays outside the visible price range must not flatten the candles. + autoscaleInfoProvider: () => null, }; if (!entry) { entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, options) }; @@ -89,14 +115,7 @@ class ConfluenceChart { 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) }, - ]; + data = this.lineData(level); } entry.series.setData(data); } @@ -106,28 +125,35 @@ class ConfluenceChart { setMoveHandler(handler) { this.onChartMove = handler; } - setLinePreview(start, end, tf) { + setLinePreview(start, end, color, lineWidth) { if (start.t === end.t) return; - if (!this.previewSeries) { - this.previewSeries = this.chart.addSeries(LightweightCharts.LineSeries, { - color: ConfluenceChart.tfColors[tf], - lineWidth: 2, - lineStyle: LightweightCharts.LineStyle.Dashed, - priceLineVisible: false, - lastValueVisible: false, - crosshairMarkerVisible: false, - }); - } - this.previewSeries.applyOptions({ color: ConfluenceChart.tfColors[tf] }); - this.previewSeries.setData([start, end] - .sort((a, b) => a.t - b.t) - .map(point => ({ time: point.t, value: point.p }))); + const x1 = this.chart.timeScale().timeToCoordinate(start.t); + const x2 = this.chart.timeScale().timeToCoordinate(end.t); + const y1 = this.candles.priceToCoordinate(start.p); + const y2 = this.candles.priceToCoordinate(end.p); + if ([x1, x2, y1, y2].some(value => value == null)) return; + this.previewLine.removeAttribute('hidden'); + this.previewLine.setAttribute('x1', x1); + this.previewLine.setAttribute('y1', y1); + this.previewLine.setAttribute('x2', x2); + this.previewLine.setAttribute('y2', y2); + this.previewLine.setAttribute('stroke', color); + this.previewLine.setAttribute('stroke-width', lineWidth); + this.previewLine.setAttribute('stroke-dasharray', '6 4'); } clearLinePreview() { - if (!this.previewSeries) return; - this.chart.removeSeries(this.previewSeries); - this.previewSeries = null; + if (this.previewLine) this.previewLine.setAttribute('hidden', ''); + } + + updateLineTooltip(param) { + const id = this.hitTest(param); + const line = this.levels.find(level => level.id === id); + this.tooltip.hidden = !line; + if (!line) return; + this.tooltip.textContent = `#${line.number} ${line.label}`; + this.tooltip.style.left = `${param.point.x + 12}px`; + this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`; } pointFromClick(param, snap) { @@ -147,23 +173,48 @@ class ConfluenceChart { } 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 }; + const points = this.lineData(level).map(point => ({ + x: this.chart.timeScale().timeToCoordinate(point.time), + y: this.candles.priceToCoordinate(point.value), + })); + for (let index = 1; index < points.length; index += 1) { + const [start, end] = [points[index - 1], points[index]]; + if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue; + if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue; + const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x); + const distance = Math.abs(lineY - param.point.y); + if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; + } } return best?.id || null; } + lineData(level) { + const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); + const points = [ + { time: level.anchor_t, value: level.anchor_p }, + { time: level.last_t, value: secondPrice }, + ]; + const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t); + const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t); + const lastIndex = this.bars.length - 1; + if (anchorIndex >= 0 && secondIndex > anchorIndex && lastIndex > secondIndex) { + const value = level.anchor_p + + (secondPrice - level.anchor_p) * (lastIndex - anchorIndex) / (secondIndex - anchorIndex); + points.push({ time: this.bars[lastIndex].t, value }); + } + return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time); + } + toCandle(bar) { return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c }; } destroy() { if (this.resizeObserver) this.resizeObserver.disconnect(); + if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chart) this.chart.remove(); } } diff --git a/static/index.html b/static/index.html index 5df644c..b548930 100644 --- a/static/index.html +++ b/static/index.html @@ -12,7 +12,7 @@