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
# 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

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
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

View file

@ -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)

View file

@ -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()]

View file

@ -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()

View file

@ -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]]] = {}

View file

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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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');

View file

@ -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();
}
}

View file

@ -12,7 +12,7 @@
<div id="app">
<header>
<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>
<main>
<section class="chart-shell">
@ -22,9 +22,12 @@
</div>
<div class="drawing-tools">
<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>
<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-else-if="selectedLine">Line selected</span>
</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>
<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>
</div>
<div class="layer-group">
@ -51,6 +53,24 @@
</div>
<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>
<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>
<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">

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; }
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; }
.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); }
#chart { height:calc(100vh - 190px); min-height:420px; }
.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 { 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; }
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; }
.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; }
.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():
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):