From b0c7806cb66774b0921b5aa75814c12329194c8c Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 07:56:45 +0000 Subject: [PATCH 01/31] feat(chart): expose bucketSeconds meta --- apps/visualizer/src/features/chart/useChartData.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/visualizer/src/features/chart/useChartData.ts b/apps/visualizer/src/features/chart/useChartData.ts index 25d72a9..6795ddd 100644 --- a/apps/visualizer/src/features/chart/useChartData.ts +++ b/apps/visualizer/src/features/chart/useChartData.ts @@ -14,6 +14,7 @@ type Params = { type Result = { candles: Candle[]; indicators: ChartIndicators; + meta: { tf: string; bucketSeconds: number } | null; loading: boolean; error: string | null; refresh: () => Promise; @@ -22,6 +23,7 @@ type Result = { export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result { const [candles, setCandles] = useState([]); const [indicators, setIndicators] = useState({}); + const [meta, setMeta] = useState<{ tf: string; bucketSeconds: number } | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const inFlight = useRef(false); @@ -34,6 +36,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res const res = await fetchChart({ symbol, source, tf, limit }); setCandles(res.candles); setIndicators(res.indicators); + setMeta(res.meta); setError(null); } catch (e: any) { setError(String(e?.message || e)); @@ -50,8 +53,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res useInterval(() => void fetchOnce(), pollMs); return useMemo( - () => ({ candles, indicators, loading, error, refresh: fetchOnce }), - [candles, indicators, loading, error, fetchOnce] + () => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }), + [candles, indicators, meta, loading, error, fetchOnce] ); } - -- 2.49.1 From f57366fad269fde06c90abd4156e94b96a00381f Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 08:01:57 +0000 Subject: [PATCH 02/31] feat(chart): pass build indicator props --- apps/visualizer/src/features/chart/ChartPanel.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/visualizer/src/features/chart/ChartPanel.tsx b/apps/visualizer/src/features/chart/ChartPanel.tsx index 69f4b7a..8b0815e 100644 --- a/apps/visualizer/src/features/chart/ChartPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartPanel.tsx @@ -13,6 +13,8 @@ type Props = { candles: Candle[]; indicators: ChartIndicators; timeframe: string; + bucketSeconds: number; + seriesKey: string; onTimeframeChange: (tf: string) => void; showIndicators: boolean; onToggleIndicators: () => void; @@ -42,6 +44,8 @@ export default function ChartPanel({ candles, indicators, timeframe, + bucketSeconds, + seriesKey, onTimeframeChange, showIndicators, onToggleIndicators, @@ -234,6 +238,7 @@ export default function ChartPanel({ const pointer = pendingMoveRef.current; if (!pointer) return; if (activeToolRef.current !== 'fib-retracement') return; + const start2 = fibStartRef.current; if (!start2) return; setFibDraft({ a: start2, b: pointer }); @@ -295,6 +300,8 @@ export default function ChartPanel({ ema20={indicators.ema20} bb20={indicators.bb20} showIndicators={showIndicators} + bucketSeconds={bucketSeconds} + seriesKey={seriesKey} fib={fibRenderable} fibOpacity={fibEffectiveOpacity} fibSelected={fibSelected} -- 2.49.1 From abaee448352244074b36c7c8234878daef3e2266 Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 08:04:49 +0000 Subject: [PATCH 03/31] feat(chart): wire build indicator meta --- apps/visualizer/src/App.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index e36805f..10c1989 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -119,7 +119,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const [tradePrice, setTradePrice] = useLocalStorageState('trade.form.price', 0); const [tradeSize, setTradeSize] = useLocalStorageState('trade.form.size', 0.1); - const { candles, indicators, loading, error, refresh } = useChartData({ + const { candles, indicators, meta, loading, error, refresh } = useChartData({ symbol, source: source.trim() ? source : undefined, tf, @@ -216,6 +216,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { }, [latest?.close, latest?.oracle, changePct]); const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []); + const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]); + const bucketSeconds = meta?.bucketSeconds ?? 60; return ( void }) { candles={candles} indicators={indicators} timeframe={tf} + bucketSeconds={bucketSeconds} + seriesKey={seriesKey} onTimeframeChange={setTf} showIndicators={showIndicators} onToggleIndicators={() => setShowIndicators((v) => !v)} -- 2.49.1 From 1c8a6900e85486e52ddb21a3e65acba6bb1565a0 Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 08:09:09 +0000 Subject: [PATCH 04/31] feat(chart): add candle build indicator line --- .../src/features/chart/TradingChart.tsx | 159 +++++++++++++++++- 1 file changed, 154 insertions(+), 5 deletions(-) diff --git a/apps/visualizer/src/features/chart/TradingChart.tsx b/apps/visualizer/src/features/chart/TradingChart.tsx index d3c3abc..44044b2 100644 --- a/apps/visualizer/src/features/chart/TradingChart.tsx +++ b/apps/visualizer/src/features/chart/TradingChart.tsx @@ -25,6 +25,8 @@ type Props = { ema20?: SeriesPoint[]; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; showIndicators: boolean; + bucketSeconds: number; + seriesKey: string; fib?: FibRetracement | null; fibOpacity?: number; fibSelected?: boolean; @@ -44,11 +46,23 @@ type Props = { }; type LinePoint = LineData | WhitespaceData; +type BuildSample = { t: number; v: number }; function toTime(t: number): UTCTimestamp { return t as UTCTimestamp; } +function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number { + if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds; + if (candles.length >= 2) { + const last = candles[candles.length - 1]?.time; + const prev = candles[candles.length - 2]?.time; + const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0; + if (Number.isFinite(delta) && delta > 0) return delta; + } + return 60; +} + function samplePriceFromCandles(candles: Candle[]): number | null { for (let i = candles.length - 1; i >= 0; i -= 1) { const close = candles[i]?.close; @@ -82,11 +96,10 @@ function toCandleData(candles: Candle[]): CandlestickData[] { function toVolumeData(candles: Candle[]): HistogramData[] { return candles.map((c) => { - const up = c.close >= c.open; return { time: toTime(c.time), value: c.volume ?? 0, - color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)', + color: 'rgba(148,163,184,0.22)', }; }); } @@ -103,6 +116,8 @@ export default function TradingChart({ ema20, bb20, showIndicators, + bucketSeconds, + seriesKey, fib, fibOpacity = 1, fibSelected = false, @@ -124,9 +139,13 @@ export default function TradingChart({ const onChartCrosshairMoveRef = useRef(onChartCrosshairMove); const onPointerEventRef = useRef(onPointerEvent); const capturedOverlayPointerRef = useRef(null); + const buildSamplesRef = useRef>(new Map()); + const buildKeyRef = useRef(null); + const lastBuildCandleStartRef = useRef(null); const seriesRef = useRef<{ candles?: ISeriesApi<'Candlestick'>; volume?: ISeriesApi<'Histogram'>; + build?: ISeriesApi<'Line'>; oracle?: ISeriesApi<'Line'>; sma20?: ISeriesApi<'Line'>; ema20?: ISeriesApi<'Line'>; @@ -225,7 +244,22 @@ export default function TradingChart({ color: 'rgba(255,255,255,0.15)', }); volumeSeries.priceScale().applyOptions({ - scaleMargins: { top: 0.82, bottom: 0 }, + scaleMargins: { top: 0.88, bottom: 0 }, + }); + + const buildSeries = chart.addSeries(LineSeries, { + color: '#60a5fa', + lineWidth: 2, + priceFormat, + priceScaleId: 'build', + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + buildSeries.priceScale().applyOptions({ + scaleMargins: { top: 0.72, bottom: 0.12 }, + visible: false, + borderVisible: false, }); const oracleSeries = chart.addSeries(LineSeries, { @@ -249,6 +283,7 @@ export default function TradingChart({ seriesRef.current = { candles: candleSeries, volume: volumeSeries, + build: buildSeries, oracle: oracleSeries, sma20: smaSeries, ema20: emaSeries, @@ -551,7 +586,7 @@ export default function TradingChart({ useEffect(() => { const s = seriesRef.current; - if (!s.candles || !s.volume) return; + if (!s.candles || !s.volume || !s.build) return; s.candles.setData(candleData); s.volume.setData(volumeData); s.oracle?.setData(oracleData); @@ -561,17 +596,131 @@ export default function TradingChart({ s.bbLower?.setData(bbLower); s.bbMid?.setData(bbMid); + if (buildKeyRef.current !== seriesKey) { + buildSamplesRef.current.clear(); + buildKeyRef.current = seriesKey; + lastBuildCandleStartRef.current = null; + } + + const bs = resolveBucketSeconds(bucketSeconds, candles); + const eps = 1e-3; + const maxPointsPerCandle = 600; + const minStep = Math.max(0.5, bs / maxPointsPerCandle); + const map = buildSamplesRef.current; + const visibleStarts = new Set(candles.map((c) => c.time)); + for (const start of Array.from(map.keys())) { + if (!visibleStarts.has(start)) map.delete(start); + } + + const last = candles[candles.length - 1]; + if (last) { + const prevStart = lastBuildCandleStartRef.current; + if (prevStart != null && prevStart !== last.time) { + const prevCandle = candles.find((c) => c.time === prevStart); + if (prevCandle) { + const endT = prevStart + bs - eps; + const finalDelta = prevCandle.close - prevCandle.open; + const list = map.get(prevStart) ?? []; + const lastT = list.length ? list[list.length - 1]!.t : -Infinity; + if (endT > lastT + eps) { + list.push({ t: endT, v: finalDelta }); + } else if (list.length) { + list[list.length - 1] = { t: lastT, v: finalDelta }; + } + map.set(prevStart, list); + } + } + + const start = last.time; + const endT = start + bs - eps; + const delta = last.close - last.open; + const nowT = Date.now() / 1000; + const tClamped = Math.min(endT, Math.max(start + eps, nowT)); + const list = map.get(start) ?? []; + if (list.length) { + const lastPt = list[list.length - 1]!; + if (tClamped - lastPt.t < minStep) { + list[list.length - 1] = { t: tClamped, v: delta }; + } else { + const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped)); + if (t > lastPt.t) list.push({ t, v: delta }); + } + } else { + list.push({ t: tClamped, v: delta }); + } + map.set(start, list); + lastBuildCandleStartRef.current = start; + } + + const buildRaw: LinePoint[] = []; + for (const c of candles) { + const list = map.get(c.time); + if (!list?.length) continue; + + const startT = c.time + eps; + const endT = c.time + bs - eps; + if (!(endT > startT)) continue; + + buildRaw.push({ time: toTime(c.time) } as WhitespaceData); + buildRaw.push({ time: toTime(startT), value: 0 } as LineData); + + let lastT = startT; + for (const p of list) { + let t = p.t; + if (t <= lastT + eps) t = lastT + eps; + if (t >= endT) break; + buildRaw.push({ time: toTime(t), value: p.v } as LineData); + lastT = t; + } + + const finalDelta = c.close - c.open; + if (endT > lastT + eps) { + buildRaw.push({ time: toTime(endT), value: finalDelta } as LineData); + } else if (buildRaw.length) { + const prev = buildRaw[buildRaw.length - 1]; + if ('value' in prev) { + buildRaw[buildRaw.length - 1] = { ...prev, value: finalDelta } as LineData; + } + } + } + + const buildColored: LinePoint[] = []; + let lastLineIdx: number | null = null; + let lastLineVal: number | null = null; + for (const p of buildRaw) { + if (!('value' in p)) { + buildColored.push(p); + lastLineIdx = null; + lastLineVal = null; + continue; + } + + if (lastLineIdx != null && lastLineVal != null) { + const delta = p.value - lastLineVal; + const color = delta > 0 ? '#22c55e' : delta < 0 ? '#ef4444' : '#60a5fa'; + const prev = buildColored[lastLineIdx] as LineData; + buildColored[lastLineIdx] = { ...prev, color }; + } + + buildColored.push({ time: p.time, value: p.value } as LineData); + lastLineIdx = buildColored.length - 1; + lastLineVal = p.value; + } + + s.build.setData(buildColored); + s.sma20?.applyOptions({ visible: showIndicators }); s.ema20?.applyOptions({ visible: showIndicators }); s.bbUpper?.applyOptions({ visible: showIndicators }); s.bbLower?.applyOptions({ visible: showIndicators }); s.bbMid?.applyOptions({ visible: showIndicators }); - }, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]); + }, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators, candles, bucketSeconds, seriesKey]); useEffect(() => { const s = seriesRef.current; if (!s.candles) return; s.candles.applyOptions({ priceFormat }); + s.build?.applyOptions({ priceFormat }); s.oracle?.applyOptions({ priceFormat }); s.sma20?.applyOptions({ priceFormat }); s.ema20?.applyOptions({ priceFormat }); -- 2.49.1 From f3c4a999c3b4365b8a360df448072708fb289970 Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 19:49:59 +0000 Subject: [PATCH 05/31] docs: add workflow playbook --- doc/workflow.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 doc/workflow.md diff --git a/doc/workflow.md b/doc/workflow.md new file mode 100644 index 0000000..7ddae8a --- /dev/null +++ b/doc/workflow.md @@ -0,0 +1,93 @@ +# Workflow pracy w projekcie `trade` (dev → staging na VPS) + snapshot/rollback + +Ten plik jest “source of truth” dla sposobu pracy nad zmianami w `trade`. +Cel: **zero ręcznych zmian na VPS**, każdy deploy jest **snapshoot’em**, do którego można wrócić. + +## Dla agenta / po restarcie sesji + +1) Przeczytaj ten plik: `doc/workflow.md`. +2) Kontekst funkcjonalny: `README.md`, `info.md`. +3) Kontekst stacka: `doc/workflow-api-ingest.md` oraz `devops/*/README.md`. +4) Stan VPS/k3s + GitOps: `doc/migration.md` i log zmian: `doc/steps.md`. + +## Zasady (must-have) + +- **Nie edytujemy “na żywo” VPS** (żadnych ręcznych poprawek w kontenerach/plikach na serwerze) → tylko Git + CI + Argo. +- **Sekrety nie trafiają do gita**: `tokens/*.json` są gitignored; w dokumentacji/logach redagujemy hasła/tokeny. +- **Brak `latest`**: obrazy w deployu są przypięte do `sha-` albo digesta. +- **Każda zmiana = snapshot**: “wersja” to commit w repo deploy + przypięte obrazy. + +## Standardowy flow zmian (polecany) + +1) Zmiana w kodzie lokalnie. + - Nie musisz odpalać lokalnego Dockera; na start rób szybkie walidacje (build/typecheck). +2) Commit + push (najlepiej przez PR). +3) CI: + - build → push obrazów (tag `sha-*` lub digest), + - aktualizacja `trade-deploy` (bump obrazu/rewizji). +4) Argo CD (auto-sync na staging) wdraża nowy snapshot w `trade-staging`. +5) Test na VPS: + - API: `/healthz`, `/v1/ticks`, `/v1/chart` + - UI: `trade.rv32i.pl` + - Ingest: logi `trade-ingestor` + napływ ticków do tabeli. + +## Snapshoty i rollback (playbook) + +### Rollback szybki (preferowany) + +- Cofnij snapshot w repo deploy: + - `git revert` commita, który podbił obrazy, **albo** + - w Argo cofnij do poprzedniej rewizji (ten sam efekt). + +Efekt: Argo wraca do poprzedniego “dobrego” zestawu obrazów i konfiguracji. + +### Rollback bezpieczny dla “dużych” zmian (schema/ingest) + +Jeśli zmiana dotyka danych/ingestu, rób ją jako nową wersję vN: +- nowa tabela: `drift_ticks_vN` +- nowa funkcja: `get_drift_candles_vN` +- osobny deploy API/UI (inne porty/sufiksy), a ingest przełączany “cutover”. + +W razie problemów: robisz “cut back” vN → v1 (stare dane zostają nietknięte). + +## Lokalne uruchomienie (opcjonalne, do debugowania) + +Dokładna instrukcja: `doc/workflow-api-ingest.md`. + +Skrót: +```bash +npm install +docker compose -f devops/db/docker-compose.yml up -d +docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init +docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap +docker compose -f devops/app/docker-compose.yml up -d --build api +npm run token:api -- --scopes write --out tokens/alg.json +npm run token:api -- --scopes read --out tokens/read.json +docker compose -f devops/app/docker-compose.yml up -d --build frontend +docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build +``` + +### Frontend dev (Vite) z backendem na VPS (staging) + +Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.rv32i.pl`. + +- Vite trzyma `VITE_API_URL=/api` (default) i proxy’uje `/api/*` do VPS. +- Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki). + +Przykład: + +```bash +cd apps/visualizer +API_PROXY_TARGET=https://trade.rv32i.pl/api \ +API_PROXY_BASIC_AUTH='USER:PASS' \ +npm run dev +``` + +Alternatywnie dla basic auth możesz użyć pliku JSON: +`API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`). + +## Definicja “done” dla zmiany + +- Jest snapshot (commit w deploy) i można wrócić jednym ruchem. +- Staging działa (API/ingest/UI) i ma podstawowe smoke-checki. +- Sekrety nie zostały dodane do repo ani do logów/komentarzy. -- 2.49.1 From af267ad6c9cdbbd760c5d7becd07042c75c9bdd3 Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 19:50:28 +0000 Subject: [PATCH 06/31] feat(dev): support Vite proxy to VPS backend --- apps/visualizer/vite.config.ts | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/visualizer/vite.config.ts b/apps/visualizer/vite.config.ts index 01ebb34..8c5fe11 100644 --- a/apps/visualizer/vite.config.ts +++ b/apps/visualizer/vite.config.ts @@ -7,6 +7,8 @@ import react from '@vitejs/plugin-react'; const DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(DIR, '../..'); +type BasicAuth = { username: string; password: string }; + function readApiReadToken(): string | undefined { if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN; const p = path.join(ROOT, 'tokens', 'read.json'); @@ -20,7 +22,40 @@ function readApiReadToken(): string | undefined { } } +function parseBasicAuth(value: string | undefined): BasicAuth | undefined { + const raw = String(value || '').trim(); + if (!raw) return undefined; + const idx = raw.indexOf(':'); + if (idx <= 0) return undefined; + const username = raw.slice(0, idx).trim(); + const password = raw.slice(idx + 1); + if (!username || !password) return undefined; + return { username, password }; +} + +function readProxyBasicAuth(): BasicAuth | undefined { + const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH); + if (fromEnv) return fromEnv; + + const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim(); + if (!fileRaw) return undefined; + + const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw); + if (!fs.existsSync(p)) return undefined; + try { + const raw = fs.readFileSync(p, 'utf8'); + const json = JSON.parse(raw) as { username?: string; password?: string }; + const username = typeof json?.username === 'string' ? json.username.trim() : ''; + const password = typeof json?.password === 'string' ? json.password : ''; + if (!username || !password) return undefined; + return { username, password }; + } catch { + return undefined; + } +} + const apiReadToken = readApiReadToken(); +const proxyBasicAuth = readProxyBasicAuth(); export default defineConfig({ plugins: [react()], @@ -34,6 +69,13 @@ export default defineConfig({ rewrite: (p) => p.replace(/^\/api/, ''), configure: (proxy) => { proxy.on('proxyReq', (proxyReq) => { + if (proxyBasicAuth) { + const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString( + 'base64' + ); + proxyReq.setHeader('Authorization', `Basic ${b64}`); + return; + } if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`); }); }, -- 2.49.1 From 444f42742048aae67cfa4791dfd591b68559be04 Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 19:50:44 +0000 Subject: [PATCH 07/31] docs: describe Vite dev against VPS backend --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 5c8cde6..e59377c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,20 @@ npm ci npm run dev ``` +### Dev z backendem na VPS (staging) + +Najprościej: trzymaj `VITE_API_URL=/api` i podepnij Vite proxy do VPS (żeby nie bawić się w CORS i nie wkładać tokena do przeglądarki): + +```bash +cd apps/visualizer +API_PROXY_TARGET=https://trade.rv32i.pl/api \ +API_PROXY_BASIC_AUTH='USER:PASS' \ +npm run dev +``` + +Zamiast `API_PROXY_BASIC_AUTH` możesz użyć pliku JSON z polami `username`/`password`: +`API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json`. + ## Docker ```bash -- 2.49.1 From 194d596284355734373a8db1d6b29cdda5bad535 Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 7 Jan 2026 19:59:16 +0000 Subject: [PATCH 08/31] docs(workflow): clarify local FE vs VPS backend defaults --- doc/workflow.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/workflow.md b/doc/workflow.md index 7ddae8a..f032a63 100644 --- a/doc/workflow.md +++ b/doc/workflow.md @@ -17,6 +17,11 @@ Cel: **zero ręcznych zmian na VPS**, każdy deploy jest **snapshoot’em**, do - **Brak `latest`**: obrazy w deployu są przypięte do `sha-` albo digesta. - **Każda zmiana = snapshot**: “wersja” to commit w repo deploy + przypięte obrazy. +## Domyślne środowisko pracy (ważne) + +- **Frontend**: domyślnie pracujemy lokalnie (Vite) i łączymy się z backendem na VPS (staging) przez proxy. Deploy frontendu na VPS robimy tylko jeśli jest to wyraźnie powiedziane (“wdrażam na VPS”). +- **Backend (trade-api + ingestor)**: zmiany backendu weryfikujemy/wdrażamy na VPS (staging), bo tam działa ingestor i tam są dane. Nie traktujemy lokalnego uruchomienia backendu jako domyślnego (tylko na wyraźną prośbę do debugowania). + ## Standardowy flow zmian (polecany) 1) Zmiana w kodzie lokalnie. -- 2.49.1 From 759173b5be7fab0c8095554dcc1d87c26579f666 Mon Sep 17 00:00:00 2001 From: u1 Date: Thu, 8 Jan 2026 05:59:40 +0000 Subject: [PATCH 09/31] fix(dev): enable Vite session auth proxy --- apps/visualizer/vite.config.ts | 111 +++++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/apps/visualizer/vite.config.ts b/apps/visualizer/vite.config.ts index 8c5fe11..6cbf362 100644 --- a/apps/visualizer/vite.config.ts +++ b/apps/visualizer/vite.config.ts @@ -9,6 +9,11 @@ const ROOT = path.resolve(DIR, '../..'); type BasicAuth = { username: string; password: string }; +function stripTrailingSlashes(p: string): string { + const out = p.replace(/\/+$/, ''); + return out || '/'; +} + function readApiReadToken(): string | undefined { if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN; const p = path.join(ROOT, 'tokens', 'read.json'); @@ -56,30 +61,98 @@ function readProxyBasicAuth(): BasicAuth | undefined { const apiReadToken = readApiReadToken(); const proxyBasicAuth = readProxyBasicAuth(); +const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:8787'; + +function parseUrl(v: string): URL | undefined { + try { + return new URL(v); + } catch { + return undefined; + } +} + +const apiProxyTargetUrl = parseUrl(apiProxyTarget); +const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/'); +const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api'); + +function inferUiProxyTarget(apiTarget: string): string | undefined { + try { + const u = new URL(apiTarget); + const p = stripTrailingSlashes(u.pathname || '/'); + if (!p.endsWith('/api')) return undefined; + const basePath = p.slice(0, -'/api'.length) || '/'; + u.pathname = basePath; + u.search = ''; + u.hash = ''; + const out = u.toString(); + return out.endsWith('/') ? out.slice(0, -1) : out; + } catch { + return undefined; + } +} + +const uiProxyTarget = + process.env.FRONTEND_PROXY_TARGET || + process.env.UI_PROXY_TARGET || + process.env.AUTH_PROXY_TARGET || + inferUiProxyTarget(apiProxyTarget) || + (apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined); + +function applyProxyBasicAuth(proxyReq: any) { + if (!proxyBasicAuth) return false; + const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64'); + proxyReq.setHeader('Authorization', `Basic ${b64}`); + return true; +} + +function rewriteSetCookieForLocalDevHttp(proxyRes: any) { + const v = proxyRes?.headers?.['set-cookie']; + if (!v) return; + const rewrite = (cookie: string) => { + let out = cookie.replace(/;\s*secure\b/gi, ''); + out = out.replace(/;\s*domain=[^;]+/gi, ''); + out = out.replace(/;\s*samesite=none\b/gi, '; SameSite=Lax'); + return out; + }; + proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v)); +} + +const proxy: Record = { + '/api': { + target: apiProxyTarget, + changeOrigin: true, + rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p), + configure: (p: any) => { + p.on('proxyReq', (proxyReq: any) => { + if (applyProxyBasicAuth(proxyReq)) return; + if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`); + }); + }, + }, +}; + +if (uiProxyTarget) { + for (const prefix of ['/whoami', '/auth', '/logout']) { + proxy[prefix] = { + target: uiProxyTarget, + changeOrigin: true, + configure: (p: any) => { + p.on('proxyReq', (proxyReq: any) => { + applyProxyBasicAuth(proxyReq); + }); + p.on('proxyRes', (proxyRes: any) => { + rewriteSetCookieForLocalDevHttp(proxyRes); + }); + }, + }; + } +} export default defineConfig({ plugins: [react()], server: { port: 5173, strictPort: true, - proxy: { - '/api': { - target: process.env.API_PROXY_TARGET || 'http://localhost:8787', - changeOrigin: true, - rewrite: (p) => p.replace(/^\/api/, ''), - configure: (proxy) => { - proxy.on('proxyReq', (proxyReq) => { - if (proxyBasicAuth) { - const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString( - 'base64' - ); - proxyReq.setHeader('Authorization', `Basic ${b64}`); - return; - } - if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`); - }); - }, - }, - }, + proxy, }, }); -- 2.49.1 From 545e1abfaa48cf6270d43b3eca8ad83fa1951997 Mon Sep 17 00:00:00 2001 From: u1 Date: Thu, 8 Jan 2026 06:00:11 +0000 Subject: [PATCH 10/31] docs(workflow): update staging Vite proxy notes --- doc/workflow.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/doc/workflow.md b/doc/workflow.md index f032a63..f43f09b 100644 --- a/doc/workflow.md +++ b/doc/workflow.md @@ -33,7 +33,7 @@ Cel: **zero ręcznych zmian na VPS**, każdy deploy jest **snapshoot’em**, do 4) Argo CD (auto-sync na staging) wdraża nowy snapshot w `trade-staging`. 5) Test na VPS: - API: `/healthz`, `/v1/ticks`, `/v1/chart` - - UI: `trade.rv32i.pl` + - UI: `trade.mpabi.pl` - Ingest: logi `trade-ingestor` + napływ ticków do tabeli. ## Snapshoty i rollback (playbook) @@ -74,22 +74,23 @@ docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build ### Frontend dev (Vite) z backendem na VPS (staging) -Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.rv32i.pl`. +Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`. - Vite trzyma `VITE_API_URL=/api` (default) i proxy’uje `/api/*` do VPS. +- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxy’ujemy też `/whoami`, `/auth/*`, `/logout`. +- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`. - Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki). Przykład: ```bash cd apps/visualizer -API_PROXY_TARGET=https://trade.rv32i.pl/api \ -API_PROXY_BASIC_AUTH='USER:PASS' \ +API_PROXY_TARGET=https://trade.mpabi.pl \ npm run dev ``` -Alternatywnie dla basic auth możesz użyć pliku JSON: -`API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`). +Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj: +`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`). ## Definicja “done” dla zmiany -- 2.49.1 From 9420c89f524c585eb15b083c33fa69d97558f031 Mon Sep 17 00:00:00 2001 From: u1 Date: Thu, 8 Jan 2026 06:00:23 +0000 Subject: [PATCH 11/31] docs: update Vite staging proxy instructions --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e59377c..d5250dc 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,14 @@ Najprościej: trzymaj `VITE_API_URL=/api` i podepnij Vite proxy do VPS (żeby ni ```bash cd apps/visualizer -API_PROXY_TARGET=https://trade.rv32i.pl/api \ -API_PROXY_BASIC_AUTH='USER:PASS' \ +API_PROXY_TARGET=https://trade.mpabi.pl \ npm run dev ``` -Zamiast `API_PROXY_BASIC_AUTH` możesz użyć pliku JSON z polami `username`/`password`: -`API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json`. +Vite proxy’uje wtedy: `/api/*`, `/whoami`, `/auth/*`, `/logout` do VPS. Dodatkowo w dev usuwa `Secure` z `Set-Cookie`, żeby sesja działała na `http://localhost:5173`. + +Jeśli staging jest dodatkowo chroniony basic auth (np. Traefik `basicAuth`), ustaw: +`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`). ## Docker -- 2.49.1 From a9ccc0b00eac130bc18cfa7dc3c0979cac49f1c4 Mon Sep 17 00:00:00 2001 From: u1 Date: Fri, 9 Jan 2026 02:05:37 +0100 Subject: [PATCH 12/31] feat(visualizer): add build overlay toggle Default is off so candles match production; enable via the new Build button when needed. --- apps/visualizer/src/App.tsx | 3 ++ .../src/features/chart/ChartPanel.tsx | 7 ++++ .../src/features/chart/ChartToolbar.tsx | 7 ++++ .../src/features/chart/TradingChart.tsx | 35 ++++++++++++++++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index 10c1989..5e529b3 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -107,6 +107,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000)); const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300)); const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true); + const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [bottomTab, setBottomTab] = useLocalStorageState< 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' @@ -288,6 +289,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { onTimeframeChange={setTf} showIndicators={showIndicators} onToggleIndicators={() => setShowIndicators((v) => !v)} + showBuild={showBuild} + onToggleBuild={() => setShowBuild((v) => !v)} seriesLabel={seriesLabel} /> diff --git a/apps/visualizer/src/features/chart/ChartPanel.tsx b/apps/visualizer/src/features/chart/ChartPanel.tsx index 8b0815e..02b5557 100644 --- a/apps/visualizer/src/features/chart/ChartPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartPanel.tsx @@ -18,6 +18,8 @@ type Props = { onTimeframeChange: (tf: string) => void; showIndicators: boolean; onToggleIndicators: () => void; + showBuild: boolean; + onToggleBuild: () => void; seriesLabel: string; }; @@ -49,6 +51,8 @@ export default function ChartPanel({ onTimeframeChange, showIndicators, onToggleIndicators, + showBuild, + onToggleBuild, seriesLabel, }: Props) { const [isFullscreen, setIsFullscreen] = useState(false); @@ -272,6 +276,8 @@ export default function ChartPanel({ onTimeframeChange={onTimeframeChange} showIndicators={showIndicators} onToggleIndicators={onToggleIndicators} + showBuild={showBuild} + onToggleBuild={onToggleBuild} priceAutoScale={priceAutoScale} onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)} seriesLabel={seriesLabel} @@ -300,6 +306,7 @@ export default function ChartPanel({ ema20={indicators.ema20} bb20={indicators.bb20} showIndicators={showIndicators} + showBuild={showBuild} bucketSeconds={bucketSeconds} seriesKey={seriesKey} fib={fibRenderable} diff --git a/apps/visualizer/src/features/chart/ChartToolbar.tsx b/apps/visualizer/src/features/chart/ChartToolbar.tsx index e4dcb39..638dc76 100644 --- a/apps/visualizer/src/features/chart/ChartToolbar.tsx +++ b/apps/visualizer/src/features/chart/ChartToolbar.tsx @@ -5,6 +5,8 @@ type Props = { onTimeframeChange: (tf: string) => void; showIndicators: boolean; onToggleIndicators: () => void; + showBuild: boolean; + onToggleBuild: () => void; priceAutoScale: boolean; onTogglePriceAutoScale: () => void; seriesLabel: string; @@ -19,6 +21,8 @@ export default function ChartToolbar({ onTimeframeChange, showIndicators, onToggleIndicators, + showBuild, + onToggleBuild, priceAutoScale, onTogglePriceAutoScale, seriesLabel, @@ -45,6 +49,9 @@ export default function ChartToolbar({ + diff --git a/apps/visualizer/src/features/chart/TradingChart.tsx b/apps/visualizer/src/features/chart/TradingChart.tsx index 44044b2..cfecb61 100644 --- a/apps/visualizer/src/features/chart/TradingChart.tsx +++ b/apps/visualizer/src/features/chart/TradingChart.tsx @@ -25,6 +25,7 @@ type Props = { ema20?: SeriesPoint[]; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; showIndicators: boolean; + showBuild: boolean; bucketSeconds: number; seriesKey: string; fib?: FibRetracement | null; @@ -116,6 +117,7 @@ export default function TradingChart({ ema20, bb20, showIndicators, + showBuild, bucketSeconds, seriesKey, fib, @@ -596,12 +598,29 @@ export default function TradingChart({ s.bbLower?.setData(bbLower); s.bbMid?.setData(bbMid); + s.build.applyOptions({ visible: showBuild }); + if (!showBuild) { + buildSamplesRef.current.clear(); + buildKeyRef.current = seriesKey; + lastBuildCandleStartRef.current = null; + s.build.setData([]); + } + if (buildKeyRef.current !== seriesKey) { buildSamplesRef.current.clear(); buildKeyRef.current = seriesKey; lastBuildCandleStartRef.current = null; } + if (!showBuild) { + s.sma20?.applyOptions({ visible: showIndicators }); + s.ema20?.applyOptions({ visible: showIndicators }); + s.bbUpper?.applyOptions({ visible: showIndicators }); + s.bbLower?.applyOptions({ visible: showIndicators }); + s.bbMid?.applyOptions({ visible: showIndicators }); + return; + } + const bs = resolveBucketSeconds(bucketSeconds, candles); const eps = 1e-3; const maxPointsPerCandle = 600; @@ -714,7 +733,21 @@ export default function TradingChart({ s.bbUpper?.applyOptions({ visible: showIndicators }); s.bbLower?.applyOptions({ visible: showIndicators }); s.bbMid?.applyOptions({ visible: showIndicators }); - }, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators, candles, bucketSeconds, seriesKey]); + }, [ + candleData, + volumeData, + oracleData, + smaData, + emaData, + bbUpper, + bbLower, + bbMid, + showIndicators, + showBuild, + candles, + bucketSeconds, + seriesKey, + ]); useEffect(() => { const s = seriesRef.current; -- 2.49.1 From 42e0a4d86d42a7af442ec36e7270ebf8faac616b Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:20:53 +0000 Subject: [PATCH 13/31] feat(dev): add visualizer __start helper --- apps/visualizer/__start | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 apps/visualizer/__start diff --git a/apps/visualizer/__start b/apps/visualizer/__start new file mode 100644 index 0000000..8b9ad5f --- /dev/null +++ b/apps/visualizer/__start @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +cd "${SCRIPT_DIR}" + +export API_PROXY_TARGET="${API_PROXY_TARGET:-https://trade.mpabi.pl}" +export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}" +export VITE_API_URL="${VITE_API_URL:-/api}" +export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}" +export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}" + +if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then + if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then + export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json" + else + echo "Missing basic auth config for VPS proxy." + echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2 + fi +fi + +npm run dev -- 2.49.1 From ae41f1a9debe4583f5f12a5f614062c2dfa10278 Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:21:46 +0000 Subject: [PATCH 14/31] feat(hasura): add GraphQL WS subscription helper --- apps/visualizer/src/lib/graphqlWs.ts | 181 +++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 apps/visualizer/src/lib/graphqlWs.ts diff --git a/apps/visualizer/src/lib/graphqlWs.ts b/apps/visualizer/src/lib/graphqlWs.ts new file mode 100644 index 0000000..fa5a126 --- /dev/null +++ b/apps/visualizer/src/lib/graphqlWs.ts @@ -0,0 +1,181 @@ +type HeadersMap = Record; + +type SubscribeParams = { + query: string; + variables?: Record; + onData: (data: T) => void; + onError?: (err: string) => void; + onStatus?: (s: { connected: boolean }) => void; +}; + +function envString(name: string): string | undefined { + const v = (import.meta as any).env?.[name]; + const s = v == null ? '' : String(v).trim(); + return s ? s : undefined; +} + +function resolveGraphqlHttpUrl(): string { + return envString('VITE_HASURA_URL') || '/graphql'; +} + +function resolveGraphqlWsUrl(): string { + const explicit = envString('VITE_HASURA_WS_URL'); + if (explicit) { + if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit; + if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`; + if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`; + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const path = explicit.startsWith('/') ? explicit : `/${explicit}`; + return `${proto}//${host}${path}`; + } + + const httpUrl = resolveGraphqlHttpUrl(); + if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl; + if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`; + if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`; + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`; + return `${proto}//${host}${path}`; +} + +function resolveAuthHeaders(): HeadersMap | undefined { + const token = envString('VITE_HASURA_AUTH_TOKEN'); + if (token) return { authorization: `Bearer ${token}` }; + const secret = envString('VITE_HASURA_ADMIN_SECRET'); + if (secret) return { 'x-hasura-admin-secret': secret }; + return undefined; +} + +type WsMessage = + | { type: 'connection_ack' | 'ka' | 'complete' } + | { type: 'connection_error'; payload?: any } + | { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } } + | { type: 'error'; id: string; payload?: any }; + +export type SubscriptionHandle = { + unsubscribe: () => void; +}; + +export function subscribeGraphqlWs({ query, variables, onData, onError, onStatus }: SubscribeParams): SubscriptionHandle { + const wsUrl = resolveGraphqlWsUrl(); + const headers = resolveAuthHeaders(); + let ws: WebSocket | null = null; + let closed = false; + let started = false; + let reconnectTimer: number | null = null; + + const subId = '1'; + + const emitError = (e: unknown) => { + const msg = typeof e === 'string' ? e : String((e as any)?.message || e); + onError?.(msg); + }; + + const setConnected = (connected: boolean) => onStatus?.({ connected }); + + const start = () => { + if (!ws || started) return; + started = true; + ws.send( + JSON.stringify({ + id: subId, + type: 'start', + payload: { query, variables: variables ?? {} }, + }) + ); + }; + + const connect = () => { + if (closed) return; + started = false; + try { + ws = new WebSocket(wsUrl, 'graphql-ws'); + } catch (e) { + emitError(e); + reconnectTimer = window.setTimeout(connect, 1000); + return; + } + + ws.onopen = () => { + setConnected(true); + const payload = headers ? { headers } : {}; + ws?.send(JSON.stringify({ type: 'connection_init', payload })); + }; + + ws.onmessage = (ev) => { + let msg: WsMessage; + try { + msg = JSON.parse(String(ev.data)); + } catch (e) { + emitError(e); + return; + } + + if (msg.type === 'connection_ack') { + start(); + return; + } + + if (msg.type === 'connection_error') { + emitError(msg.payload || 'connection_error'); + return; + } + + if (msg.type === 'ka' || msg.type === 'complete') return; + + if (msg.type === 'error') { + emitError(msg.payload || 'subscription_error'); + return; + } + + if (msg.type === 'data') { + const errors = msg.payload?.errors; + if (Array.isArray(errors) && errors.length) { + emitError(errors.map((e) => e.message).join(' | ')); + return; + } + if (msg.payload?.data != null) onData(msg.payload.data as T); + } + }; + + ws.onerror = () => { + setConnected(false); + }; + + ws.onclose = () => { + setConnected(false); + if (closed) return; + reconnectTimer = window.setTimeout(connect, 1000); + }; + }; + + connect(); + + return { + unsubscribe: () => { + closed = true; + setConnected(false); + if (reconnectTimer != null) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (!ws) return; + try { + ws.send(JSON.stringify({ id: subId, type: 'stop' })); + ws.send(JSON.stringify({ type: 'connection_terminate' })); + } catch { + // ignore + } + try { + ws.close(); + } catch { + // ignore + } + ws = null; + }, + }; +} -- 2.49.1 From dff4d347ad08580251a951747ce29ec55a4e3510 Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:22:32 +0000 Subject: [PATCH 15/31] feat(dlob): add stats subscription hook --- .../src/features/market/useDlobStats.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/visualizer/src/features/market/useDlobStats.ts diff --git a/apps/visualizer/src/features/market/useDlobStats.ts b/apps/visualizer/src/features/market/useDlobStats.ts new file mode 100644 index 0000000..dbce7ff --- /dev/null +++ b/apps/visualizer/src/features/market/useDlobStats.ts @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useState } from 'react'; +import { subscribeGraphqlWs } from '../../lib/graphqlWs'; + +export type DlobStats = { + marketName: string; + markPrice: number | null; + oraclePrice: number | null; + bestBid: number | null; + bestAsk: number | null; + mid: number | null; + spreadAbs: number | null; + spreadBps: number | null; + depthBidBase: number | null; + depthAskBase: number | null; + depthBidUsd: number | null; + depthAskUsd: number | null; + imbalance: number | null; + updatedAt: string | null; +}; + +function toNum(v: unknown): number | null { + if (v == null) return null; + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v === 'string') { + const s = v.trim(); + if (!s) return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; + } + return null; +} + +type HasuraDlobStatsRow = { + market_name: string; + mark_price?: string | null; + oracle_price?: string | null; + best_bid_price?: string | null; + best_ask_price?: string | null; + mid_price?: string | null; + spread_abs?: string | null; + spread_bps?: string | null; + depth_bid_base?: string | null; + depth_ask_base?: string | null; + depth_bid_usd?: string | null; + depth_ask_usd?: string | null; + imbalance?: string | null; + updated_at?: string | null; +}; + +type SubscriptionData = { + dlob_stats_latest: HasuraDlobStatsRow[]; +}; + +export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } { + const [stats, setStats] = useState(null); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + + const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]); + + useEffect(() => { + if (!normalizedMarket) { + setStats(null); + setError(null); + setConnected(false); + return; + } + + setError(null); + + const query = ` + subscription DlobStats($market: String!) { + dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) { + market_name + mark_price + oracle_price + best_bid_price + best_ask_price + mid_price + spread_abs + spread_bps + depth_bid_base + depth_ask_base + depth_bid_usd + depth_ask_usd + imbalance + updated_at + } + } + `; + + const sub = subscribeGraphqlWs({ + query, + variables: { market: normalizedMarket }, + onStatus: ({ connected }) => setConnected(connected), + onError: (e) => setError(e), + onData: (data) => { + const row = data?.dlob_stats_latest?.[0]; + if (!row?.market_name) return; + setStats({ + marketName: row.market_name, + markPrice: toNum(row.mark_price), + oraclePrice: toNum(row.oracle_price), + bestBid: toNum(row.best_bid_price), + bestAsk: toNum(row.best_ask_price), + mid: toNum(row.mid_price), + spreadAbs: toNum(row.spread_abs), + spreadBps: toNum(row.spread_bps), + depthBidBase: toNum(row.depth_bid_base), + depthAskBase: toNum(row.depth_ask_base), + depthBidUsd: toNum(row.depth_bid_usd), + depthAskUsd: toNum(row.depth_ask_usd), + imbalance: toNum(row.imbalance), + updatedAt: row.updated_at ?? null, + }); + }, + }); + + return () => sub.unsubscribe(); + }, [normalizedMarket]); + + return { stats, connected, error }; +} -- 2.49.1 From 9592d6ac165a9aad919d9039ca0e563465f3d6e2 Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:22:46 +0000 Subject: [PATCH 16/31] docs(env): update visualizer defaults --- apps/visualizer/.env.example | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/visualizer/.env.example b/apps/visualizer/.env.example index 9e85877..aae6f87 100644 --- a/apps/visualizer/.env.example +++ b/apps/visualizer/.env.example @@ -1,12 +1,15 @@ # Default: UI reads ticks from the same-origin API proxy at `/api`. VITE_API_URL=/api -# Fallback (optional): query Hasura directly (not recommended in browser). -VITE_HASURA_URL=http://localhost:8080/v1/graphql -# Optional (only if you intentionally query Hasura directly from the browser): +# Hasura GraphQL endpoint (supports subscriptions via WS). +# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`. +VITE_HASURA_URL=/graphql +# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`. +# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws). +# VITE_HASURA_WS_URL=/graphql-ws +# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`): # VITE_HASURA_AUTH_TOKEN=YOUR_JWT -# VITE_HASURA_ADMIN_SECRET=devsecret -VITE_SYMBOL=PUMP-PERP +VITE_SYMBOL=SOL-PERP # Optional: filter by source (leave empty for all) # VITE_SOURCE=drift_oracle VITE_POLL_MS=1000 -- 2.49.1 From 5a9c2b0a852227d41f5c8d27090e9f7246eef264 Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:23:15 +0000 Subject: [PATCH 17/31] feat(chart): add 5s/15s/30s timeframes --- apps/visualizer/src/features/chart/ChartToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/visualizer/src/features/chart/ChartToolbar.tsx b/apps/visualizer/src/features/chart/ChartToolbar.tsx index 638dc76..6e9328a 100644 --- a/apps/visualizer/src/features/chart/ChartToolbar.tsx +++ b/apps/visualizer/src/features/chart/ChartToolbar.tsx @@ -14,7 +14,7 @@ type Props = { onToggleFullscreen: () => void; }; -const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const; +const timeframes = ['5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const; export default function ChartToolbar({ timeframe, -- 2.49.1 From 879f45aa5ce992d94001577562e54727f989f5eb Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:23:51 +0000 Subject: [PATCH 18/31] feat(chart): parse candle flow rows for brick bars --- apps/visualizer/src/lib/api.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/visualizer/src/lib/api.ts b/apps/visualizer/src/lib/api.ts index ff220f2..87d16a4 100644 --- a/apps/visualizer/src/lib/api.ts +++ b/apps/visualizer/src/lib/api.ts @@ -6,6 +6,9 @@ export type Candle = { close: number; volume?: number; oracle?: number | null; + flow?: { up: number; down: number; flat: number }; + flowRows?: number[]; + flowMoves?: number[]; }; export type SeriesPoint = { @@ -68,9 +71,18 @@ export async function fetchChart(params: { close: Number(c.close), volume: c.volume == null ? undefined : Number(c.volume), oracle: c.oracle == null ? null : Number(c.oracle), + flow: + (c as any)?.flow && typeof (c as any).flow === 'object' + ? { + up: Number((c as any).flow.up), + down: Number((c as any).flow.down), + flat: Number((c as any).flow.flat), + } + : undefined, + flowRows: Array.isArray((c as any)?.flowRows) ? (c as any).flowRows.map((x: any) => Number(x)) : undefined, + flowMoves: Array.isArray((c as any)?.flowMoves) ? (c as any).flowMoves.map((x: any) => Number(x)) : undefined, })), indicators: json.indicators || {}, meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) }, }; } - -- 2.49.1 From fa0ff11b5a0f3180141075a6da9c45984cfda207 Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:43:49 +0000 Subject: [PATCH 19/31] feat(visualizer): add DLOB dashboard and SOL default --- apps/visualizer/src/App.tsx | 148 ++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 14 deletions(-) diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index 5e529b3..38f842f 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useLocalStorageState } from './app/hooks/useLocalStorageState'; import AppShell from './layout/AppShell'; @@ -11,6 +12,11 @@ import Button from './ui/Button'; import TopNav from './layout/TopNav'; import AuthStatus from './layout/AuthStatus'; import LoginScreen from './layout/LoginScreen'; +import { useDlobStats } from './features/market/useDlobStats'; +import { useDlobL2 } from './features/market/useDlobL2'; +import { useDlobSlippage } from './features/market/useDlobSlippage'; +import { useDlobDepthBands } from './features/market/useDlobDepthBands'; +import DlobDashboard from './features/market/DlobDashboard'; function envNumber(name: string, fallback: number): number { const v = (import.meta as any).env?.[name]; @@ -36,6 +42,11 @@ function formatQty(v: number | null | undefined, decimals: number): string { return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } +function orderbookBarStyle(scale: number): CSSProperties { + const s = Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0; + return { ['--ob-bar-scale' as any]: s } as CSSProperties; +} + type WhoamiResponse = { ok?: boolean; user?: string | null; @@ -99,9 +110,9 @@ export default function App() { } function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { - const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []); + const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []); - const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP')); + const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP')); const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', '')); const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m')); const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000)); @@ -110,7 +121,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [bottomTab, setBottomTab] = useLocalStorageState< - 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' + 'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' >('trade.bottomTab', 'positions'); const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long'); const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>( @@ -120,6 +131,16 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const [tradePrice, setTradePrice] = useLocalStorageState('trade.form.price', 0); const [tradeSize, setTradeSize] = useLocalStorageState('trade.form.size', 0.1); + useEffect(() => { + if (symbol === 'BONK-PERP') { + setSymbol('1MBONK-PERP'); + return; + } + if (!markets.includes(symbol)) { + setSymbol('SOL-PERP'); + } + }, [markets, setSymbol, symbol]); + const { candles, indicators, meta, loading, error, refresh } = useChartData({ symbol, source: source.trim() ? source : undefined, @@ -128,12 +149,18 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { pollMs, }); + const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol); + const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 14 }); + const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol); + const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol); + const latest = candles.length ? candles[candles.length - 1] : null; const first = candles.length ? candles[0] : null; const changePct = first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null; const orderbook = useMemo(() => { + if (dlobL2) return { asks: dlobL2.asks, bids: dlobL2.bids, mid: dlobL2.mid as number | null }; if (!latest) return { asks: [], bids: [], mid: null as number | null }; const mid = latest.close; const step = Math.max(mid * 0.00018, 0.0001); @@ -164,7 +191,19 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { }); return { asks, bids, mid }; - }, [latest]); + }, [dlobL2, latest]); + + const maxAskTotal = useMemo(() => { + let max = 0; + for (const r of orderbook.asks) max = Math.max(max, r.total || 0); + return max; + }, [orderbook.asks]); + + const maxBidTotal = useMemo(() => { + let max = 0; + for (const r of orderbook.bids) max = Math.max(max, r.total || 0); + return max; + }, [orderbook.bids]); const trades = useMemo(() => { const slice = candles.slice(-24).reverse(); @@ -184,6 +223,24 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { return latest?.close ?? tradePrice; }, [latest?.close, tradeOrderType, tradePrice]); + const orderValueUsd = useMemo(() => { + if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null; + if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null; + const v = effectiveTradePrice * tradeSize; + return Number.isFinite(v) && v > 0 ? v : null; + }, [effectiveTradePrice, tradeSize]); + + const dynamicSlippage = useMemo(() => { + if (orderValueUsd == null) return null; + const side = tradeSide === 'short' ? 'sell' : 'buy'; + const rows = slippageRows.filter((r) => r.side === side).slice(); + rows.sort((a, b) => a.sizeUsd - b.sizeUsd); + if (!rows.length) return null; + const biggest = rows[rows.length - 1]; + const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest; + return match; + }, [orderValueUsd, slippageRows, tradeSide]); + const topItems = useMemo( () => [ { key: 'BTC', label: 'BTC', changePct: 1.28, active: false }, @@ -209,12 +266,28 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { ), }, { key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) }, - { key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' }, - { key: 'oi', label: 'Open Interest', value: '—' }, - { key: 'vol', label: '24h Volume', value: '—' }, - { key: 'details', label: 'Market Details', value: View }, + { key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) }, + { key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) }, + { + key: 'spread', + label: 'Spread', + value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`, + sub: formatUsd(dlob?.spreadAbs ?? null), + }, + { + key: 'dlob', + label: 'DLOB', + value: dlobConnected ? 'live' : '—', + sub: dlobError ? {dlobError} : dlob?.updatedAt || '—', + }, + { + key: 'l2', + label: 'L2', + value: dlobL2Connected ? 'live' : '—', + sub: dlobL2Error ? {dlobL2Error} : dlobL2?.updatedAt || '—', + }, ]; - }, [latest?.close, latest?.oracle, changePct]); + }, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error]); const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []); const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]); @@ -292,11 +365,30 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { showBuild={showBuild} onToggleBuild={() => setShowBuild((v) => !v)} seriesLabel={seriesLabel} + dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }} /> + ), + }, { id: 'positions', label: 'Positions', content:
Positions (next)
}, { id: 'orders', label: 'Orders', content:
Orders (next)
}, { id: 'balances', label: 'Balances', content:
Balances (next)
}, @@ -323,7 +415,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { title={
Orderbook
-
{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}
+
{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}
} > @@ -341,18 +433,26 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
{orderbook.asks.map((r) => ( -
+
0 ? r.total / maxAskTotal : 0)} + > {formatQty(r.price, 3)} {formatQty(r.size, 2)} {formatQty(r.total, 2)}
))}
- {latest ? formatQty(latest.close, 3) : '—'} + {formatQty(orderbook.mid, 3)} mid
{orderbook.bids.map((r) => ( -
+
0 ? r.total / maxBidTotal : 0)} + > {formatQty(r.price, 3)} {formatQty(r.size, 2)} {formatQty(r.total, 2)} @@ -487,7 +587,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
Slippage (Dynamic) - + + {slippageError ? ( + {slippageError} + ) : dynamicSlippage?.impactBps == null ? ( + slippageConnected ? ( + '—' + ) : ( + 'offline' + ) + ) : ( + <> + {dynamicSlippage.impactBps.toFixed(1)} bps{' '} + + ({dynamicSlippage.sizeUsd.toLocaleString()} USD) + {dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9 + ? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill` + : ''} + + + )} +
Margin Required -- 2.49.1 From 62baa9700edbc65651cb053e171b92b257c09d0d Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:46:20 +0000 Subject: [PATCH 20/31] feat(chart): overlay DLOB quotes + layers --- .../src/features/chart/ChartPanel.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/visualizer/src/features/chart/ChartPanel.tsx b/apps/visualizer/src/features/chart/ChartPanel.tsx index 02b5557..f06a677 100644 --- a/apps/visualizer/src/features/chart/ChartPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartPanel.tsx @@ -6,12 +6,13 @@ import ChartSideToolbar from './ChartSideToolbar'; import ChartToolbar from './ChartToolbar'; import TradingChart from './TradingChart'; import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive'; -import type { IChartApi } from 'lightweight-charts'; +import { LineStyle, type IChartApi } from 'lightweight-charts'; import type { OverlayLayer } from './ChartPanel.types'; type Props = { candles: Candle[]; indicators: ChartIndicators; + dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null; timeframe: string; bucketSeconds: number; seriesKey: string; @@ -45,6 +46,7 @@ function isEditableTarget(t: EventTarget | null): boolean { export default function ChartPanel({ candles, indicators, + dlobQuotes, timeframe, bucketSeconds, seriesKey, @@ -61,6 +63,7 @@ export default function ChartPanel({ const [fib, setFib] = useState(null); const [fibDraft, setFibDraft] = useState(null); const [layers, setLayers] = useState([ + { id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 }, { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 }, ]); const [layersOpen, setLayersOpen] = useState(false); @@ -196,6 +199,37 @@ export default function ChartPanel({ return Math.max(0, Math.min(1, v)); } + const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]); + const quotesVisible = Boolean(quotesLayer?.visible); + const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1); + + const priceLines = useMemo(() => { + if (!quotesVisible) return []; + return [ + { + id: 'dlob-bid', + title: 'DLOB Bid', + price: dlobQuotes?.bid ?? null, + color: `rgba(34,197,94,${quotesOpacity})`, + lineStyle: LineStyle.Dotted, + }, + { + id: 'dlob-mid', + title: 'DLOB Mid', + price: dlobQuotes?.mid ?? null, + color: `rgba(230,233,239,${quotesOpacity})`, + lineStyle: LineStyle.Dashed, + }, + { + id: 'dlob-ask', + title: 'DLOB Ask', + price: dlobQuotes?.ask ?? null, + color: `rgba(239,68,68,${quotesOpacity})`, + lineStyle: LineStyle.Dotted, + }, + ]; + }, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]); + function updateLayer(layerId: string, patch: Partial) { setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l))); } @@ -309,6 +343,7 @@ export default function ChartPanel({ showBuild={showBuild} bucketSeconds={bucketSeconds} seriesKey={seriesKey} + priceLines={priceLines} fib={fibRenderable} fibOpacity={fibEffectiveOpacity} fibSelected={fibSelected} -- 2.49.1 From 912a78588d021586446820d20a6e5f78940b47f4 Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:47:22 +0000 Subject: [PATCH 21/31] feat(chart): update layers panel UI --- .../src/features/chart/ChartLayersPanel.tsx | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/apps/visualizer/src/features/chart/ChartLayersPanel.tsx b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx index e4569f4..3942e45 100644 --- a/apps/visualizer/src/features/chart/ChartLayersPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx @@ -142,38 +142,30 @@ export default function ChartLayersPanel({
Actions
- {drawingsLayer ? ( -
+ {layers.map((layer) => ( +
- onToggleLayerVisible(drawingsLayer.id)} - > + onToggleLayerVisible(layer.id)}>
- onToggleLayerLocked(drawingsLayer.id)} - > + onToggleLayerLocked(layer.id)}>
- {drawingsLayer.name} - {fibPresent ? ' (1)' : ' (0)'} + {layer.name} + {layer.id === 'drawings' ? {fibPresent ? ' (1)' : ' (0)'} : null}
- onSetLayerOpacity(drawingsLayer.id, next)} /> + onSetLayerOpacity(layer.id, next)} />
- ) : null} + ))} {drawingsLayer && fibPresent ? (
); } - -- 2.49.1 From 6904be4a5186186683b76b7ca9b785ca279f7def Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:54:22 +0000 Subject: [PATCH 22/31] feat(chart): render brick-stack flow bars --- .../src/features/chart/TradingChart.tsx | 589 +++++++++++++++--- 1 file changed, 504 insertions(+), 85 deletions(-) diff --git a/apps/visualizer/src/features/chart/TradingChart.tsx b/apps/visualizer/src/features/chart/TradingChart.tsx index cfecb61..1b0cb3f 100644 --- a/apps/visualizer/src/features/chart/TradingChart.tsx +++ b/apps/visualizer/src/features/chart/TradingChart.tsx @@ -1,13 +1,18 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { CandlestickSeries, ColorType, CrosshairMode, HistogramSeries, + type IPrimitivePaneRenderer, + type IPrimitivePaneView, type IChartApi, type ISeriesApi, + type ISeriesPrimitive, LineStyle, LineSeries, + type SeriesAttachedParameter, + type Time, createChart, type UTCTimestamp, type CandlestickData, @@ -28,6 +33,15 @@ type Props = { showBuild: boolean; bucketSeconds: number; seriesKey: string; + priceLines?: Array<{ + id: string; + title: string; + price: number | null; + color: string; + lineWidth?: number; + lineStyle?: LineStyle; + axisLabelVisible?: boolean; + }>; fib?: FibRetracement | null; fibOpacity?: number; fibSelected?: boolean; @@ -49,6 +63,13 @@ type Props = { type LinePoint = LineData | WhitespaceData; type BuildSample = { t: number; v: number }; +const BUILD_UP_COLOR = '#22c55e'; +const BUILD_DOWN_COLOR = '#ef4444'; +const BUILD_FLAT_COLOR = '#60a5fa'; +const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)'; +const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)'; +const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)'; + function toTime(t: number): UTCTimestamp { return t as UTCTimestamp; } @@ -107,7 +128,361 @@ function toVolumeData(candles: Candle[]): HistogramData[] { function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] { if (!points?.length) return []; - return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value })); + return points.map((p) => + p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value } + ); +} + +function colorForDelta(delta: number): string { + if (delta > 0) return BUILD_UP_COLOR; + if (delta < 0) return BUILD_DOWN_COLOR; + return BUILD_FLAT_COLOR; +} + +function sliceColorForDelta(delta: number): string { + if (delta > 0) return BUILD_UP_SLICE; + if (delta < 0) return BUILD_DOWN_SLICE; + return BUILD_FLAT_SLICE; +} + +type SliceDir = -1 | 0 | 1; + +function dirForDelta(delta: number): SliceDir { + if (delta > 0) return 1; + if (delta < 0) return -1; + return 0; +} + +function buildDeltaSeriesForCandle(candle: Candle, bs: number, samples: BuildSample[] | undefined): LinePoint[] { + const eps = 1e-3; + const startT = candle.time + eps; + const endT = candle.time + bs - eps; + if (!(endT > startT)) return []; + + const points: BuildSample[] = [{ t: startT, v: 0 }]; + let lastT = startT; + for (const p of samples || []) { + let t = p.t; + if (t <= lastT + eps) t = lastT + eps; + if (t >= endT) break; + points.push({ t, v: p.v }); + lastT = t; + } + + const finalDelta = candle.close - candle.open; + if (endT > lastT + eps) { + points.push({ t: endT, v: finalDelta }); + } else if (points.length) { + points[points.length - 1] = { ...points[points.length - 1]!, v: finalDelta }; + } + + const out: LinePoint[] = [{ time: toTime(candle.time) } as WhitespaceData]; + out.push({ time: toTime(points[0]!.t), value: points[0]!.v } as LineData); + + let lastLineIdx = out.length - 1; + let lastVal = points[0]!.v; + for (let i = 1; i < points.length; i += 1) { + const v = points[i]!.v; + const prev = out[lastLineIdx] as LineData; + out[lastLineIdx] = { ...prev, color: colorForDelta(v - lastVal) } as LineData; + out.push({ time: toTime(points[i]!.t), value: v } as LineData); + lastLineIdx = out.length - 1; + lastVal = v; + } + return out; +} + +type BuildSlicesState = { + enabled: boolean; + candles: Candle[]; + bucketSeconds: number; + samples: Map; + series: ISeriesApi<'Histogram', Time> | null; + chart: SeriesAttachedParameter