import { getChartSeriesTable, getLatestDlobTable } from '../features/market/dlobSource'; import { bollingerBands, ema, macd, rsi, sma } from './indicators'; export type Candle = { time: number; // unix seconds open: number; high: number; low: number; close: number; volume?: number; oracle?: number | null; flow?: { up: number; down: number; flat: number }; flowRows?: number[]; flowMoves?: number[]; }; export type SeriesPoint = { time: number; // unix seconds value: number | null; }; export type ChartIndicators = { oracle?: SeriesPoint[]; sma20?: SeriesPoint[]; ema20?: SeriesPoint[]; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; rsi14?: SeriesPoint[]; macd?: { macd: SeriesPoint[]; signal: SeriesPoint[] }; }; type GraphqlError = { message: string }; type HasuraDerivedTsRow = { event_ts: string; oracle_price?: number | string | null; mark_price?: number | string | null; mid_price?: number | string | null; }; type HasuraLatestDlobRow = { updated_at?: string | null; oracle_price?: number | string | null; mark_price?: number | string | null; mid_price?: number | string | null; }; function readEnv(name: string): string | undefined { const viteValue = (import.meta as any).env?.[name]; if (viteValue != null && String(viteValue).trim()) return String(viteValue); const nodeValue = (globalThis as any)?.process?.env?.[name]; if (nodeValue != null && String(nodeValue).trim()) return String(nodeValue); return undefined; } function getHasuraUrl(): string { const v = readEnv('VITE_HASURA_URL'); return v ? String(v) : '/graphql'; } function getHasuraAuthHeaders(): Record | undefined { const token = readEnv('VITE_HASURA_AUTH_TOKEN'); if (token) return { authorization: `Bearer ${String(token)}` }; const secret = readEnv('VITE_HASURA_ADMIN_SECRET'); if (secret) return { 'x-hasura-admin-secret': String(secret) }; return undefined; } function parseTimeframeToSeconds(tf: string): number { const raw = String(tf || '').trim(); const match = raw.match(/^(\d+)([smhdSMHD])$/); if (!match) return 60; const value = Number(match[1]); const unit = match[2].toLowerCase(); if (!Number.isFinite(value) || value <= 0) return 60; if (unit === 's') return value; if (unit === 'm') return value * 60; if (unit === 'h') return value * 3600; if (unit === 'd') return value * 86400; return 60; } function toSeries(times: number[], values: Array) { return times.map((time, i) => ({ time, value: values[i] ?? null })); } function buildIndicators(candles: Candle[]): ChartIndicators { const times = candles.map((c) => c.time); const closes = candles.map((c) => c.close); const oracleValues = candles.map((c) => (c.oracle == null ? null : c.oracle)); const sma20 = sma(closes, 20); const ema20 = ema(closes, 20); const bb20 = bollingerBands(closes, 20, 2); const rsi14 = rsi(closes, 14); const macdOut = macd(closes, 12, 26, 9); return { oracle: toSeries(times, oracleValues), sma20: toSeries(times, sma20), ema20: toSeries(times, ema20), bb20: { upper: toSeries(times, bb20.upper), lower: toSeries(times, bb20.lower), mid: toSeries(times, bb20.mid), }, rsi14: toSeries(times, rsi14), macd: { macd: toSeries(times, macdOut.macd), signal: toSeries(times, macdOut.signal), }, }; } 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; } function aggregateRowsToCandles( rows: HasuraDerivedTsRow[], bucketSeconds: number, options?: { fillGaps?: boolean; limit?: number } ): Candle[] { const buckets = new Map(); const sortedRows = rows.slice().sort((a, b) => Date.parse(a.event_ts) - Date.parse(b.event_ts)); for (const row of sortedRows) { const tsSec = Math.floor(Date.parse(row.event_ts) / 1000); if (!Number.isFinite(tsSec)) continue; const bucket = tsSec - (tsSec % bucketSeconds); const oracle = toNum(row.oracle_price); const close = toNum(row.mark_price) ?? toNum(row.mid_price) ?? oracle; if (close == null) continue; const existing = buckets.get(bucket); if (!existing) { buckets.set(bucket, { time: bucket, open: close, high: close, low: close, close, volume: 1, oracle, }); continue; } existing.high = Math.max(existing.high, close); existing.low = Math.min(existing.low, close); existing.close = close; existing.volume = (existing.volume || 0) + 1; existing.oracle = oracle ?? existing.oracle ?? null; } const sorted = Array.from(buckets.values()).sort((a, b) => a.time - b.time); if (!sorted.length) return []; if (!options?.fillGaps) { return options?.limit ? sorted.slice(-options.limit) : sorted; } const tail = options?.limit ? sorted.slice(-options.limit) : sorted; const candles: Candle[] = []; const firstBucket = tail[0]!.time; const lastBucket = tail[tail.length - 1]!.time; const byTime = new Map(tail.map((c) => [c.time, c])); let prev = tail[0]!; for (let bucket = firstBucket; bucket <= lastBucket; bucket += bucketSeconds) { const current = byTime.get(bucket); if (current) { candles.push(current); prev = current; continue; } candles.push({ time: bucket, open: prev.close, high: prev.close, low: prev.close, close: prev.close, volume: 0, oracle: prev.oracle ?? null, }); } return candles; } export function mergeCandles(prev: Candle[], next: Candle[]): Candle[] { const map = new Map(); for (const candle of prev) map.set(candle.time, candle); for (const candle of next) map.set(candle.time, candle); return Array.from(map.values()).sort((a, b) => a.time - b.time); } export function applyLatestRowToCandles( candles: Candle[], row: HasuraLatestDlobRow, bucketSeconds: number ): Candle[] { const tsSec = Math.floor(Date.parse(String(row.updated_at || '')) / 1000); if (!Number.isFinite(tsSec)) return candles; const bucket = tsSec - (tsSec % bucketSeconds); const oracle = toNum(row.oracle_price); const close = toNum(row.mark_price) ?? toNum(row.mid_price) ?? oracle; if (close == null) return candles; const next = candles.slice(); const last = next[next.length - 1]; if (!last || bucket > last.time) { next.push({ time: bucket, open: last?.close ?? close, high: close, low: close, close, volume: 1, oracle, }); return next; } if (bucket < last.time) return candles; last.high = Math.max(last.high, close); last.low = Math.min(last.low, close); last.close = close; last.volume = (last.volume || 0) + 1; last.oracle = oracle ?? last.oracle ?? null; return next; } export function buildChartIndicators(candles: Candle[]): ChartIndicators { return buildIndicators(candles); } export function getChartLiveTable(marketName: string) { return getLatestDlobTable(marketName); } async function fetchChartFromHasura(params: { symbol: string; source?: string; tf: string; limit: number; beforeTime?: number; signal?: AbortSignal; }): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> { const bucketSeconds = parseTimeframeToSeconds(params.tf); const table = getChartSeriesTable(params.symbol); const maxPoints = Math.min(200_000, Math.max(5_000, params.limit * Math.max(1, bucketSeconds) * 3)); const sourceFilter = params.source?.trim(); const beforeTime = Number.isFinite(params.beforeTime) ? Number(params.beforeTime) : null; const beforeIso = beforeTime == null ? null : new Date(beforeTime * 1000).toISOString(); const query = ` query ChartSeries($symbol: String!, $limit: Int!${sourceFilter ? ', $source: String!' : ''}${beforeIso ? ', $before: timestamptz!' : ''}) { ${table}( limit: $limit order_by: {event_ts: desc} where: { market_name: {_eq: $symbol} is_indicative: {_eq: false} ${sourceFilter ? 'source: {_eq: $source}' : ''} ${beforeIso ? 'event_ts: {_lt: $before}' : ''} } ) { event_ts oracle_price mark_price mid_price } } `; const res = await fetch(getHasuraUrl(), { method: 'POST', credentials: 'same-origin', signal: params.signal, headers: { 'content-type': 'application/json', ...(getHasuraAuthHeaders() || {}), }, body: JSON.stringify({ query, variables: { symbol: params.symbol, limit: maxPoints, ...(sourceFilter ? { source: sourceFilter } : {}), ...(beforeIso ? { before: beforeIso } : {}), }, }), }); const text = await res.text(); if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`); const json = JSON.parse(text) as { data?: Record; errors?: GraphqlError[] }; if (Array.isArray(json.errors) && json.errors.length) { throw new Error(json.errors.map((e) => e.message).join(' | ')); } const rows = json.data?.[table] || []; const candles = aggregateRowsToCandles(rows, bucketSeconds, { fillGaps: beforeIso == null, limit: params.limit }); return { candles, indicators: buildIndicators(candles), meta: { tf: params.tf, bucketSeconds }, }; } export async function fetchChart(params: { symbol: string; source?: string; tf: string; limit: number; beforeTime?: number; signal?: AbortSignal; }): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> { return await fetchChartFromHasura(params); }