Improve local chart development
This commit is contained in:
parent
49cd06089f
commit
f7d0ffce8a
7 changed files with 73 additions and 19 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ __pycache__/
|
||||||
.env
|
.env
|
||||||
.schwab_token.json
|
.schwab_token.json
|
||||||
data/manual_lines.json
|
data/manual_lines.json
|
||||||
|
artifacts/playwright/
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ code; it records decisions and verified API facts that are expensive to rediscov
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open <http://localhost:8000>. Override the host port with
|
Then open <http://localhost:8010>. Override the host port with, for example,
|
||||||
`PORT=8010 docker compose up` if 8000 is busy — on the VPS itself it always is,
|
`PORT=8020 docker compose up`. The port binds to all host interfaces, so another
|
||||||
that's the Coolify UI. The source tree is bind-mounted and uvicorn
|
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
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,20 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
ports:
|
||||||
# Host port is overridable: `PORT=8010 docker compose up`. On the VPS
|
# Host port is overridable. Port 8000 is already used by Coolify on the VPS.
|
||||||
# itself 8000 is already taken by the Coolify UI.
|
- "0.0.0.0:${PORT:-8010}:8000"
|
||||||
- "${PORT:-8000}:8000"
|
|
||||||
volumes:
|
volumes:
|
||||||
# Bind-mount the source so --reload picks up edits without a rebuild.
|
# Bind-mount the source so --reload picks up edits without a rebuild.
|
||||||
- .:/app
|
- .:/app
|
||||||
environment:
|
environment:
|
||||||
- PYTHONDONTWRITEBYTECODE=1
|
- PYTHONDONTWRITEBYTECODE=1
|
||||||
- PYTHONUNBUFFERED=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
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,12 @@ createApp({
|
||||||
drawMode.value = !drawMode.value;
|
drawMode.value = !drawMode.value;
|
||||||
drawPoints.value = [];
|
drawPoints.value = [];
|
||||||
selectedLine.value = null;
|
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) {
|
async function handleChartClick(param) {
|
||||||
|
|
@ -101,6 +107,7 @@ createApp({
|
||||||
if (point.snappedSide) drawSide.value = point.snappedSide;
|
if (point.snappedSide) drawSide.value = point.snappedSide;
|
||||||
drawPoints.value.push(point);
|
drawPoints.value.push(point);
|
||||||
if (drawPoints.value.length < 2) return;
|
if (drawPoints.value.length < 2) return;
|
||||||
|
chartApi.clearLinePreview();
|
||||||
const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t);
|
const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t);
|
||||||
if (start.t === end.t) { drawPoints.value = []; return; }
|
if (start.t === end.t) { drawPoints.value = []; return; }
|
||||||
const temporaryId = `tmp_${Date.now()}`;
|
const temporaryId = `tmp_${Date.now()}`;
|
||||||
|
|
@ -191,6 +198,7 @@ createApp({
|
||||||
chartApi = new ConfluenceChart();
|
chartApi = new ConfluenceChart();
|
||||||
chartApi.create(document.getElementById('chart'));
|
chartApi.create(document.getElementById('chart'));
|
||||||
chartApi.setClickHandler(handleChartClick);
|
chartApi.setClickHandler(handleChartClick);
|
||||||
|
chartApi.setMoveHandler(handleChartMove);
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
connect();
|
connect();
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ 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.bars = [];
|
this.bars = [];
|
||||||
this.levels = [];
|
this.levels = [];
|
||||||
this.onChartClick = null;
|
this.onChartClick = null;
|
||||||
|
this.onChartMove = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(el) {
|
create(el) {
|
||||||
|
|
@ -24,8 +26,9 @@ class ConfluenceChart {
|
||||||
rightPriceScale: { borderVisible: false },
|
rightPriceScale: { borderVisible: false },
|
||||||
});
|
});
|
||||||
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
||||||
upColor: '#3fb984', downColor: '#e45757', borderVisible: false,
|
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
||||||
wickUpColor: '#3fb984', wickDownColor: '#e45757',
|
borderUpColor: '#1d6849', borderDownColor: '#963737',
|
||||||
|
wickUpColor: '#1d6849', wickDownColor: '#963737',
|
||||||
});
|
});
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
||||||
|
|
@ -34,12 +37,18 @@ class ConfluenceChart {
|
||||||
this.chart.subscribeClick(param => {
|
this.chart.subscribeClick(param => {
|
||||||
if (param.point && param.time && this.onChartClick) this.onChartClick(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) {
|
setBars(bars) {
|
||||||
this.bars = bars;
|
this.bars = bars;
|
||||||
this.candles.setData(bars.map(this.toCandle));
|
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) {
|
updateBar(bar) {
|
||||||
|
|
@ -95,6 +104,32 @@ class ConfluenceChart {
|
||||||
|
|
||||||
setClickHandler(handler) { this.onChartClick = handler; }
|
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) {
|
pointFromClick(param, snap) {
|
||||||
const rawPrice = this.candles.coordinateToPrice(param.point.y);
|
const rawPrice = this.candles.coordinateToPrice(param.point.y);
|
||||||
let point = { t: Number(param.time), p: rawPrice, snappedSide: null };
|
let point = { t: Number(param.time), p: rawPrice, snappedSide: null };
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@
|
||||||
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
|
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
|
||||||
</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>
|
||||||
<h2>Confluence zones</h2>
|
<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-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">
|
||||||
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
|
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
|
||||||
|
|
@ -58,6 +59,7 @@
|
||||||
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</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 class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
<h2>Alert log</h2>
|
<h2>Alert log</h2>
|
||||||
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
<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>
|
<div v-for="alert in alerts" :key="alert.at" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
:root { color-scheme:light; --bg:#e8dfcf; --panel:#f7f1e6; --chart-bg:#fbf7ef; --fg:#2c2924; --muted:#746c60; --line:#d2c5b2; --accent:#b7771d; --green:#27825c; --red:#bd4545; }
|
||||||
@media (prefers-color-scheme: light) { :root { color-scheme:light; --bg:#eef1f3; --panel:#fff; --chart-bg:#fff; --fg:#17202a; --muted:#66717d; --line:#dce2e7; } }
|
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; }
|
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; }
|
#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; }
|
#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; }
|
.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; }
|
||||||
.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; }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; } }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue