trendlines namable deleteable
This commit is contained in:
parent
f7d0ffce8a
commit
6599c3cd77
15 changed files with 267 additions and 91 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]]] = {}
|
||||||
|
|
|
||||||
|
|
@ -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: ...
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
127
static/chart.js
127
static/chart.js
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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; } }
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue