326 lines
9.8 KiB
TypeScript
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);
|
|
}
|