diff --git a/.gitignore b/.gitignore index 10768c8..23240c8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .env .schwab_token.json data/manual_lines.json +artifacts/playwright/ diff --git a/README.md b/README.md index 824cf8e..5ea309b 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ code; it records decisions and verified API facts that are expensive to rediscov docker compose up --build ``` -Then open . Override the host port with -`PORT=8010 docker compose up` if 8000 is busy — on the VPS itself it always is, -that's the Coolify UI. The source tree is bind-mounted and uvicorn +Then open . Override the host port with, for example, +`PORT=8020 docker compose up`. The port binds to all host interfaces, so another +machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted and uvicorn runs with `--reload`, so edits to `main.py` or `static/` take effect without a rebuild. Rebuild only when `requirements.txt` changes. diff --git a/docker-compose.yml b/docker-compose.yml index f97cb6f..0a1c10d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,12 +6,20 @@ services: context: . dockerfile: Dockerfile.dev ports: - # Host port is overridable: `PORT=8010 docker compose up`. On the VPS - # itself 8000 is already taken by the Coolify UI. - - "${PORT:-8000}:8000" + # Host port is overridable. Port 8000 is already used by Coolify on the VPS. + - "0.0.0.0:${PORT:-8010}:8000" volumes: # Bind-mount the source so --reload picks up edits without a rebuild. - .:/app environment: - PYTHONDONTWRITEBYTECODE=1 - PYTHONUNBUFFERED=1 + + playwright: + image: mcr.microsoft.com/playwright:v1.55.0-noble + command: sleep infinity + depends_on: + - api + ipc: host + volumes: + - ./artifacts/playwright:/artifacts diff --git a/static/app.js b/static/app.js index 9f66156..4fd7d34 100644 --- a/static/app.js +++ b/static/app.js @@ -90,6 +90,12 @@ createApp({ drawMode.value = !drawMode.value; drawPoints.value = []; selectedLine.value = null; + chartApi.clearLinePreview(); + } + + function handleChartMove(param) { + if (!drawMode.value || drawPoints.value.length !== 1) return; + chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), timeframe.value); } async function handleChartClick(param) { @@ -101,6 +107,7 @@ createApp({ if (point.snappedSide) drawSide.value = point.snappedSide; drawPoints.value.push(point); if (drawPoints.value.length < 2) return; + chartApi.clearLinePreview(); const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t); if (start.t === end.t) { drawPoints.value = []; return; } const temporaryId = `tmp_${Date.now()}`; @@ -191,6 +198,7 @@ createApp({ chartApi = new ConfluenceChart(); chartApi.create(document.getElementById('chart')); chartApi.setClickHandler(handleChartClick); + chartApi.setMoveHandler(handleChartMove); window.addEventListener('keydown', handleKeydown); refreshStatus(); connect(); diff --git a/static/chart.js b/static/chart.js index ce39825..6dcf1df 100644 --- a/static/chart.js +++ b/static/chart.js @@ -4,9 +4,11 @@ class ConfluenceChart { this.candles = null; this.resizeObserver = null; this.levelSeries = new Map(); + this.previewSeries = null; this.bars = []; this.levels = []; this.onChartClick = null; + this.onChartMove = null; } create(el) { @@ -24,8 +26,9 @@ class ConfluenceChart { rightPriceScale: { borderVisible: false }, }); this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, { - upColor: '#3fb984', downColor: '#e45757', borderVisible: false, - wickUpColor: '#3fb984', wickDownColor: '#e45757', + upColor: '#27825c', downColor: '#bd4545', borderVisible: true, + borderUpColor: '#1d6849', borderDownColor: '#963737', + wickUpColor: '#1d6849', wickDownColor: '#963737', }); this.resizeObserver = new ResizeObserver(() => { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); @@ -34,12 +37,18 @@ class ConfluenceChart { this.chart.subscribeClick(param => { if (param.point && param.time && this.onChartClick) this.onChartClick(param); }); + this.chart.subscribeCrosshairMove(param => { + if (param.point && param.time && this.onChartMove) this.onChartMove(param); + }); } setBars(bars) { this.bars = bars; this.candles.setData(bars.map(this.toCandle)); - this.chart.timeScale().fitContent(); + this.chart.timeScale().setVisibleLogicalRange({ + from: Math.max(0, bars.length - 160), + to: bars.length + 5, + }); } updateBar(bar) { @@ -95,6 +104,32 @@ class ConfluenceChart { setClickHandler(handler) { this.onChartClick = handler; } + setMoveHandler(handler) { this.onChartMove = handler; } + + setLinePreview(start, end, tf) { + 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 }))); + } + + clearLinePreview() { + if (!this.previewSeries) return; + this.chart.removeSeries(this.previewSeries); + this.previewSeries = null; + } + pointFromClick(param, snap) { const rawPrice = this.candles.coordinateToPrice(param.point.y); let point = { t: Number(param.time), p: rawPrice, snappedSide: null }; diff --git a/static/index.html b/static/index.html index 42020ea..5df644c 100644 --- a/static/index.html +++ b/static/index.html @@ -50,14 +50,16 @@ -

Confluence zones

-
No active zones near current structure.
-
-
{{ cluster.side }}{{ cluster.score.toFixed(1) }}
-
{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}
-
{{ cluster.members.map(member => member.label).join(' · ') }}
-
{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts
-
+

Alert log

No alerts fired.
{{ alert.message }}
diff --git a/static/style.css b/static/style.css index a8458d7..84734e0 100644 --- a/static/style.css +++ b/static/style.css @@ -1,5 +1,4 @@ -:root { color-scheme: dark; --bg:#090d12; --panel:#10161d; --chart-bg:#0c1117; --fg:#e8edf3; --muted:#82909f; --line:#202b36; --accent:#efb643; --green:#3fb984; --red:#e45757; } -@media (prefers-color-scheme: light) { :root { color-scheme:light; --bg:#eef1f3; --panel:#fff; --chart-bg:#fff; --fg:#17202a; --muted:#66717d; --line:#dce2e7; } } +:root { color-scheme:light; --bg:#e8dfcf; --panel:#f7f1e6; --chart-bg:#fbf7ef; --fg:#2c2924; --muted:#746c60; --line:#d2c5b2; --accent:#b7771d; --green:#27825c; --red:#bd4545; } * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; } #app { min-height:100vh; padding:18px; } @@ -17,6 +16,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted #chart { height:calc(100vh - 190px); min-height:420px; } .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; } .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; } }