Files
trade-frontend/apps/visualizer/src/lib/api.ts

326 lines
9.8 KiB
TypeScript

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<string, string> | 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<number | null>) {
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<number, Candle>();
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<number, Candle>();
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<string, HasuraDerivedTsRow[]>; 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);
}