trendlines namable deleteable

This commit is contained in:
Chris Amow 2026-08-09 23:00:46 -05:00
parent f7d0ffce8a
commit 6599c3cd77
15 changed files with 267 additions and 91 deletions

View file

@ -7,11 +7,10 @@ SEED_1H_RANGE=730d
SEED_1M_RANGE=8d SEED_1M_RANGE=8d
# Chart and analysis # Chart and analysis
TIMEFRAMES=1m,2m,5m,15m,30m,1h,4h,1d TIMEFRAMES=1m,5m,15m,30m,1h,1d
BASE_TIMEFRAMES=1m,30m,1d BASE_TIMEFRAMES=1m,30m,1d
MAX_BARS_PER_TF=5000 MAX_BARS_PER_TF=5000
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
MA_SETS__4H=
MA_SETS__1H= MA_SETS__1H=
DAILY_ANCHOR_ET=18:00 DAILY_ANCHOR_ET=18:00
MANUAL_LINES_PATH=./data/manual_lines.json MANUAL_LINES_PATH=./data/manual_lines.json

View file

@ -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 runs with `--reload`, so edits to `main.py` or `static/` take effect without a
rebuild. Rebuild only when `requirements.txt` changes. 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: Without Docker:
```bash ```bash

View file

@ -36,6 +36,9 @@ class Level:
provisional: bool provisional: bool
hidden: bool hidden: bool
period: int | None = None period: int | None = None
color: str | None = None
line_width: int | None = None
number: int | None = None
def price_at(self, t: int) -> float: def price_at(self, t: int) -> float:
return self.anchor_p + self.slope * (t - self.anchor_t) return self.anchor_p + self.slope * (t - self.anchor_t)

View file

@ -1,6 +1,7 @@
import json import json
from dataclasses import asdict, dataclass, replace from dataclasses import asdict, dataclass, replace
from pathlib import Path from pathlib import Path
from threading import RLock
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe from app.bars.models import Timeframe
@ -19,6 +20,9 @@ class ManualLine:
created_at: int created_at: int
note: str = "" note: str = ""
hidden: bool = False hidden: bool = False
color: str = "#65b7cf"
line_width: int = 2
number: int = 0
def to_level(self) -> Level: def to_level(self) -> Level:
return Level( return Level(
@ -28,7 +32,7 @@ class ManualLine:
side=self.side, side=self.side,
weight=TIMEFRAME_WEIGHT[self.tf], weight=TIMEFRAME_WEIGHT[self.tf],
score=1.0, 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_t=self.anchor_t,
anchor_p=self.anchor_p, anchor_p=self.anchor_p,
slope=self.slope, slope=self.slope,
@ -38,6 +42,9 @@ class ManualLine:
last_t=self.last_t, last_t=self.last_t,
provisional=False, provisional=False,
hidden=self.hidden, hidden=self.hidden,
color=self.color,
line_width=self.line_width,
number=self.number,
) )
def to_dict(self) -> dict: def to_dict(self) -> dict:
@ -59,13 +66,23 @@ class ManualLine:
created_at=int(value["created_at"]), created_at=int(value["created_at"]),
note=str(value.get("note", "")), note=str(value.get("note", "")),
hidden=bool(value.get("hidden", False)), 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: class ManualLineStore:
def __init__(self, path: str | Path): def __init__(self, path: str | Path):
self.path = Path(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]: def load(self) -> list[ManualLine]:
if not self.path.exists(): if not self.path.exists():
@ -74,37 +91,43 @@ class ManualLineStore:
return [ManualLine.from_dict(value) for value in payload] return [ManualLine.from_dict(value) for value in payload]
def save(self) -> None: def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True) with self._lock:
temporary = self.path.with_suffix(self.path.suffix + ".tmp") self.path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text( temporary = self.path.with_suffix(self.path.suffix + ".tmp")
json.dumps( temporary.write_text(
[line.to_dict() for line in self.lines.values()], json.dumps(
indent=2, [line.to_dict() for line in self.lines.values()],
sort_keys=True, indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
) )
+ "\n", temporary.replace(self.path)
encoding="utf-8",
)
temporary.replace(self.path)
def add(self, line: ManualLine) -> ManualLine: def add(self, line: ManualLine) -> ManualLine:
self.lines[line.id] = line with self._lock:
self.save() if line.number <= 0:
return line 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: def update(self, line_id: str, changes: dict) -> ManualLine:
if line_id not in self.lines: with self._lock:
raise KeyError(line_id) if line_id not in self.lines:
line = replace(self.lines[line_id], **changes) raise KeyError(line_id)
self.lines[line_id] = line line = replace(self.lines[line_id], **changes)
self.save() self.lines[line_id] = line
return line self.save()
return line
def delete(self, line_id: str) -> None: def delete(self, line_id: str) -> None:
if line_id not in self.lines: with self._lock:
raise KeyError(line_id) if line_id not in self.lines:
del self.lines[line_id] raise KeyError(line_id)
self.save() del self.lines[line_id]
self.save()
def levels(self) -> list[Level]: def levels(self) -> list[Level]:
return [line.to_level() for line in self.lines.values()] return [line.to_level() for line in self.lines.values()]

View file

@ -2,7 +2,7 @@ import time
import uuid import uuid
from fastapi import APIRouter, HTTPException, Query, Request, Response 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.bars.models import Timeframe
from app.analysis.levels import Side from app.analysis.levels import Side
@ -20,12 +20,16 @@ class LineCreate(BaseModel):
end_p: float end_p: float
note: str = "" note: str = ""
hidden: bool = False 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): class LinePatch(BaseModel):
side: Side | None = None side: Side | None = None
note: str | None = None note: str | None = None
hidden: bool | 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") @router.get("/health")
@ -39,6 +43,7 @@ def status(request: Request):
return { return {
"stream": runtime.stream.status, "stream": runtime.stream.status,
"source": runtime.stream.source.name, "source": runtime.stream.source.name,
"delay_minutes": runtime.stream.source.delay_minutes,
"symbol": runtime.stream.symbol, "symbol": runtime.stream.symbol,
"last_bar_t": runtime.stream.last_bar_t, "last_bar_t": runtime.stream.last_bar_t,
"bars_held": runtime.store.counts(), "bars_held": runtime.store.counts(),
@ -92,9 +97,11 @@ def create_line(request: Request, payload: LineCreate):
created_at=int(time.time()), created_at=int(time.time()),
note=payload.note, note=payload.note,
hidden=payload.hidden, hidden=payload.hidden,
color=payload.color,
line_width=payload.line_width,
) )
runtime = request.app.state.runtime runtime = request.app.state.runtime
runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
runtime.rebuild_levels() runtime.rebuild_levels()
return line.to_level().to_dict() return line.to_level().to_dict()

View file

@ -27,11 +27,10 @@ class Settings(BaseSettings):
yahoo_poll_seconds: float = 20 yahoo_poll_seconds: float = 20
seed_1h_range: str = "730d" seed_1h_range: str = "730d"
seed_1m_range: str = "8d" 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" base_timeframes: str = "1m,30m,1d"
max_bars_per_tf: int = 5000 max_bars_per_tf: int = 5000
ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200" ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200"
ma_sets__4h: str = ""
ma_sets__1h: str = "" ma_sets__1h: str = ""
daily_anchor_et: str = "18:00" daily_anchor_et: str = "18:00"
manual_lines_path: Path = Path("./data/manual_lines.json") 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]]]: def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]:
configured = { configured = {
Timeframe.D1: self.ma_sets__1d, Timeframe.D1: self.ma_sets__1d,
Timeframe.H4: self.ma_sets__4h,
Timeframe.H1: self.ma_sets__1h, Timeframe.H1: self.ma_sets__1h,
} }
result: dict[Timeframe, list[tuple[str, int]]] = {} result: dict[Timeframe, list[tuple[str, int]]] = {}

View file

@ -6,6 +6,7 @@ from app.bars.models import Bar, Timeframe
class MarketDataSource(Protocol): class MarketDataSource(Protocol):
name: str name: str
delay_minutes: int
def supports_history(self) -> bool: ... def supports_history(self) -> bool: ...

View file

@ -8,6 +8,7 @@ from app.bars.models import Bar, Timeframe
class ReplaySource: class ReplaySource:
name = "replay" name = "replay"
delay_minutes = 0
def __init__(self, path: str | Path, speed: float = 0): def __init__(self, path: str | Path, speed: float = 0):
self.path = Path(path) self.path = Path(path)

View file

@ -47,6 +47,7 @@ def parse_chart(payload: dict[str, Any], tf: Timeframe, source: str = "yahoo") -
class YahooSource: class YahooSource:
name = "yahoo" name = "yahoo"
delay_minutes = 10
def __init__(self, poll_seconds: float = 20, client: httpx.AsyncClient | None = None): def __init__(self, poll_seconds: float = 20, client: httpx.AsyncClient | None = None):
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds

View file

@ -17,9 +17,11 @@ services:
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.55.0-noble 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: depends_on:
- api - api
ipc: host ipc: host
environment:
- LANG=en_US.UTF-8
volumes: volumes:
- ./artifacts/playwright:/artifacts - ./artifacts/playwright:/artifacts

View file

@ -2,7 +2,7 @@ const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
const defaultPrefs = { const defaultPrefs = {
base_tf: '1m', 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, hidden_levels_score: false,
}; };
@ -17,11 +17,15 @@ createApp({
const clusters = ref([]); const clusters = ref([]);
const alerts = ref([]); const alerts = ref([]);
const drawMode = ref(false); const drawMode = ref(false);
const drawName = ref('');
const drawColor = ref('#65b7cf');
const drawWidth = ref(2);
const drawSide = ref('support'); const drawSide = ref('support');
const snap = ref(true); const snap = ref(true);
const drawPoints = ref([]); const drawPoints = ref([]);
const selectedLine = ref(null); 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()); const now = ref(Date.now());
let chartApi = null; let chartApi = null;
let socket = null; let socket = null;
@ -32,6 +36,9 @@ createApp({
const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t)); 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`; 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() { async function refreshStatus() {
const response = await fetch('/api/status'); const response = await fetch('/api/status');
@ -95,12 +102,13 @@ createApp({
function handleChartMove(param) { function handleChartMove(param) {
if (!drawMode.value || drawPoints.value.length !== 1) return; 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) { async function handleChartClick(param) {
if (!drawMode.value) { if (!drawMode.value) {
selectedLine.value = chartApi.hitTest(param); selectedLine.value = chartApi.hitTest(param);
selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
return; return;
} }
const point = chartApi.pointFromClick(param, snap.value); const point = chartApi.pointFromClick(param, snap.value);
@ -113,9 +121,10 @@ createApp({
const temporaryId = `tmp_${Date.now()}`; const temporaryId = `tmp_${Date.now()}`;
const optimistic = { const optimistic = {
id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value, 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), 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, 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); levels.value.push(optimistic);
syncVisibleLevels(); syncVisibleLevels();
@ -124,13 +133,14 @@ createApp({
try { try {
const response = await fetch('/api/lines', { const response = await fetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, 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}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json(); const saved = await response.json();
levels.value = levels.value.filter(level => level.id !== temporaryId); levels.value = levels.value.filter(level => level.id !== temporaryId);
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved); if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
selectedLine.value = saved.id; selectedLine.value = saved.id;
selectedLines.value = [saved.id];
} catch (error) { } catch (error) {
levels.value = levels.value.filter(level => level.id !== temporaryId); levels.value = levels.value.filter(level => level.id !== temporaryId);
console.error('Unable to save line', error); console.error('Unable to save line', error);
@ -139,13 +149,64 @@ createApp({
} }
async function deleteSelected() { async function deleteSelected() {
if (!selectedLine.value) return; const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean);
const id = selectedLine.value; await deleteLines(ids);
levels.value = levels.value.filter(level => level.id !== id); }
selectedLine.value = null;
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(); 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) { function handleKeydown(event) {
@ -211,6 +272,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); 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'); }).mount('#app');

View file

@ -4,14 +4,18 @@ class ConfluenceChart {
this.candles = null; this.candles = null;
this.resizeObserver = null; this.resizeObserver = null;
this.levelSeries = new Map(); this.levelSeries = new Map();
this.previewSeries = null; this.previewLine = null;
this.bars = []; this.bars = [];
this.levels = []; this.levels = [];
this.onChartClick = null; this.onChartClick = null;
this.onChartMove = null; this.onChartMove = null;
this.tooltip = null;
this.chartEl = null;
this.clickListener = null;
} }
create(el) { create(el) {
this.chartEl = el;
this.chart = LightweightCharts.createChart(el, { this.chart = LightweightCharts.createChart(el, {
autoSize: true, autoSize: true,
layout: { layout: {
@ -34,11 +38,31 @@ class ConfluenceChart {
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
}); });
this.resizeObserver.observe(el); this.resizeObserver.observe(el);
this.chart.subscribeClick(param => { this.tooltip = document.createElement('div');
if (param.point && param.time && this.onChartClick) this.onChartClick(param); 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 => { 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); let entry = this.levelSeries.get(level.id);
const isMa = level.kind === 'ma'; const isMa = level.kind === 'ma';
const options = { const options = {
color: ConfluenceChart.tfColors[level.tf], color: level.color || ConfluenceChart.tfColors[level.tf],
lineWidth: level.tf === '1d' ? 2 : 1, lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1),
lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple,
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: true, lastValueVisible: true,
title: level.label, title: level.label,
// Overlays outside the visible price range must not flatten the candles.
autoscaleInfoProvider: () => null,
}; };
if (!entry) { if (!entry) {
entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, options) }; entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, options) };
@ -89,14 +115,7 @@ class ConfluenceChart {
if (isMa) { if (isMa) {
data = (level.points || []).map(([time, value]) => ({ time, value })); data = (level.points || []).map(([time, value]) => ({ time, value }));
} else { } else {
const first = this.bars[0]?.t || level.anchor_t; data = this.lineData(level);
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); entry.series.setData(data);
} }
@ -106,28 +125,35 @@ class ConfluenceChart {
setMoveHandler(handler) { this.onChartMove = handler; } setMoveHandler(handler) { this.onChartMove = handler; }
setLinePreview(start, end, tf) { setLinePreview(start, end, color, lineWidth) {
if (start.t === end.t) return; if (start.t === end.t) return;
if (!this.previewSeries) { const x1 = this.chart.timeScale().timeToCoordinate(start.t);
this.previewSeries = this.chart.addSeries(LightweightCharts.LineSeries, { const x2 = this.chart.timeScale().timeToCoordinate(end.t);
color: ConfluenceChart.tfColors[tf], const y1 = this.candles.priceToCoordinate(start.p);
lineWidth: 2, const y2 = this.candles.priceToCoordinate(end.p);
lineStyle: LightweightCharts.LineStyle.Dashed, if ([x1, x2, y1, y2].some(value => value == null)) return;
priceLineVisible: false, this.previewLine.removeAttribute('hidden');
lastValueVisible: false, this.previewLine.setAttribute('x1', x1);
crosshairMarkerVisible: false, this.previewLine.setAttribute('y1', y1);
}); this.previewLine.setAttribute('x2', x2);
} this.previewLine.setAttribute('y2', y2);
this.previewSeries.applyOptions({ color: ConfluenceChart.tfColors[tf] }); this.previewLine.setAttribute('stroke', color);
this.previewSeries.setData([start, end] this.previewLine.setAttribute('stroke-width', lineWidth);
.sort((a, b) => a.t - b.t) this.previewLine.setAttribute('stroke-dasharray', '6 4');
.map(point => ({ time: point.t, value: point.p })));
} }
clearLinePreview() { clearLinePreview() {
if (!this.previewSeries) return; if (this.previewLine) this.previewLine.setAttribute('hidden', '');
this.chart.removeSeries(this.previewSeries); }
this.previewSeries = null;
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) { pointFromClick(param, snap) {
@ -147,23 +173,48 @@ class ConfluenceChart {
} }
hitTest(param) { hitTest(param) {
const t = Number(param.time);
let best = null; let best = null;
for (const level of this.levels.filter(value => value.kind === 'manual')) { for (const level of this.levels.filter(value => value.kind === 'manual')) {
const price = level.anchor_p + level.slope * (t - level.anchor_t); const points = this.lineData(level).map(point => ({
const coordinate = this.candles.priceToCoordinate(price); x: this.chart.timeScale().timeToCoordinate(point.time),
const distance = Math.abs(coordinate - param.point.y); y: this.candles.priceToCoordinate(point.value),
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; }));
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; 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) { toCandle(bar) {
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c }; return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
} }
destroy() { destroy() {
if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
if (this.chart) this.chart.remove(); if (this.chart) this.chart.remove();
} }
} }

View file

@ -12,7 +12,7 @@
<div id="app"> <div id="app">
<header> <header>
<div><span class="eyebrow">CME FUTURES</span><h1>/ES <strong>CONFLUENCE</strong></h1></div> <div><span class="eyebrow">CME FUTURES</span><h1>/ES <strong>CONFLUENCE</strong></h1></div>
<div class="status" :class="status.stream"><i></i>{{ status.stream }} · {{ status.source || 'source' }}</div> <div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
</header> </header>
<main> <main>
<section class="chart-shell"> <section class="chart-shell">
@ -22,9 +22,12 @@
</div> </div>
<div class="drawing-tools"> <div class="drawing-tools">
<button :class="{active: drawMode}" @click="toggleDraw">Trendline</button> <button :class="{active: drawMode}" @click="toggleDraw">Trendline</button>
<input class="line-name" v-model.trim="drawName" placeholder="Line name" aria-label="Trendline name">
<input type="color" v-model="drawColor" aria-label="New trendline color">
<select v-model.number="drawWidth" aria-label="New trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
<select v-model="drawSide" aria-label="Line side"><option value="support">Support</option><option value="resistance">Resistance</option></select> <select v-model="drawSide" aria-label="Line side"><option value="support">Support</option><option value="resistance">Resistance</option></select>
<label><input type="checkbox" v-model="snap">Snap</label> <label><input type="checkbox" v-model="snap">Snap</label>
<button @click="deleteSelected" :disabled="!selectedLine">Delete</button> <button @click="deleteSelected" :disabled="!hasLineSelection">Delete</button>
<span v-if="drawMode">{{ drawPoints.length ? 'Place second point' : 'Place first point' }}</span> <span v-if="drawMode">{{ drawPoints.length ? 'Place second point' : 'Place first point' }}</span>
<span v-else-if="selectedLine">Line selected</span> <span v-else-if="selectedLine">Line selected</span>
</div> </div>
@ -42,7 +45,6 @@
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div> <div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
</div> </div>
<div class="layer-group optional"> <div class="layer-group optional">
<label><input type="checkbox" :checked="allEnabled('4h')" @change="toggleGroup('4h', $event.target.checked)"><span class="swatch tf-4h"></span>4h MAs</label>
<label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</label> <label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</label>
</div> </div>
<div class="layer-group"> <div class="layer-group">
@ -51,6 +53,24 @@
</div> </div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label> <label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
<details class="sidebar-section" open> <details class="sidebar-section" open>
<summary>Trendlines ({{ manualLines.length }})</summary>
<div class="trendline-actions" v-if="manualLines.length">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>
</div>
<div v-if="!manualLines.length" class="empty">No manual trendlines.</div>
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
<span>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
<div class="line-style-controls" @click.stop>
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
<select :value="line.line_width || 2" aria-label="Trendline width" @change="updateLineStyle(line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
</div>
</div>
</details>
<details class="sidebar-section">
<summary>Confluence zones</summary> <summary>Confluence zones</summary>
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div> <div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
<div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side"> <div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side">

View file

@ -12,11 +12,12 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
.symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; } .symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select { padding:4px 8px; font-size:10px; }.drawing-tools select { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { height:calc(100vh - 190px); min-height:420px; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview { position:absolute; z-index:4; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview line[hidden] { display:none; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } .sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-4h { background:#ec7b42; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } .layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-4h { background:#ec7b42; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; } .cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }main { grid-template-columns:1fr; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } } @media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }

View file

@ -6,7 +6,7 @@ from app.bars.models import Timeframe
def sample_line(): def sample_line():
return ManualLine("ml_test", Timeframe.H4, Side.RESISTANCE, 100, 5000, -0.01, 200, 300) return ManualLine("ml_test", Timeframe.H4, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
def test_json_persistence_round_trip(tmp_path): def test_json_persistence_round_trip(tmp_path):