diff --git a/README.md b/README.md index 5c8cde6..d5250dc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,21 @@ 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.mpabi.pl \ +npm run dev +``` + +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 ```bash 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 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 diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index e36805f..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,17 +110,18 @@ 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)); 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' + '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'>( @@ -119,7 +131,17 @@ 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({ + 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, tf, @@ -127,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); @@ -163,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(); @@ -183,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 }, @@ -208,14 +266,32 @@ 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]); + 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)} + 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)
}, @@ -316,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) : '—'}
} > @@ -334,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)} @@ -480,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 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 ? (
); } - diff --git a/apps/visualizer/src/features/chart/ChartPanel.tsx b/apps/visualizer/src/features/chart/ChartPanel.tsx index 69f4b7a..f06a677 100644 --- a/apps/visualizer/src/features/chart/ChartPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartPanel.tsx @@ -6,16 +6,21 @@ 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; onTimeframeChange: (tf: string) => void; showIndicators: boolean; onToggleIndicators: () => void; + showBuild: boolean; + onToggleBuild: () => void; seriesLabel: string; }; @@ -41,10 +46,15 @@ function isEditableTarget(t: EventTarget | null): boolean { export default function ChartPanel({ candles, indicators, + dlobQuotes, timeframe, + bucketSeconds, + seriesKey, onTimeframeChange, showIndicators, onToggleIndicators, + showBuild, + onToggleBuild, seriesLabel, }: Props) { const [isFullscreen, setIsFullscreen] = useState(false); @@ -53,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); @@ -188,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))); } @@ -234,6 +276,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 }); @@ -267,6 +310,8 @@ export default function ChartPanel({ onTimeframeChange={onTimeframeChange} showIndicators={showIndicators} onToggleIndicators={onToggleIndicators} + showBuild={showBuild} + onToggleBuild={onToggleBuild} priceAutoScale={priceAutoScale} onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)} seriesLabel={seriesLabel} @@ -295,6 +340,10 @@ export default function ChartPanel({ ema20={indicators.ema20} bb20={indicators.bb20} showIndicators={showIndicators} + showBuild={showBuild} + bucketSeconds={bucketSeconds} + seriesKey={seriesKey} + priceLines={priceLines} fib={fibRenderable} fibOpacity={fibEffectiveOpacity} fibSelected={fibSelected} diff --git a/apps/visualizer/src/features/chart/ChartToolbar.tsx b/apps/visualizer/src/features/chart/ChartToolbar.tsx index e4dcb39..6e9328a 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; @@ -12,13 +14,15 @@ 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, 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 d3c3abc..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, @@ -25,6 +30,18 @@ type Props = { ema20?: SeriesPoint[]; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; showIndicators: boolean; + 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; @@ -44,11 +61,30 @@ 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; } +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,18 +118,371 @@ 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)', }; }); } 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