Improve local chart development

This commit is contained in:
Chris Amow 2026-08-09 22:18:36 -05:00
parent 49cd06089f
commit f7d0ffce8a
7 changed files with 73 additions and 19 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ __pycache__/
.env
.schwab_token.json
data/manual_lines.json
artifacts/playwright/

View file

@ -14,9 +14,9 @@ code; it records decisions and verified API facts that are expensive to rediscov
docker compose up --build
```
Then open <http://localhost:8000>. 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 <http://localhost:8010>. 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.

View file

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

View file

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

View file

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

View file

@ -50,14 +50,16 @@
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
</div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
<h2>Confluence zones</h2>
<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 class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
<div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
<div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
</div>
<details class="sidebar-section" open>
<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">
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
<div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
<div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
</div>
</details>
<h2>Alert log</h2>
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
<div v-for="alert in alerts" :key="alert.at" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>

View file

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