chore(snapshot): sync frontend workspace state
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import { getChartSeriesTable, getLatestDlobTable } from '../features/market/dlobSource';
|
||||
import { bollingerBands, ema, macd, rsi, sma } from './indicators';
|
||||
|
||||
export type Candle = {
|
||||
time: number; // unix seconds
|
||||
open: number;
|
||||
@@ -6,6 +9,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 = {
|
||||
@@ -22,21 +28,289 @@ export type ChartIndicators = {
|
||||
macd?: { macd: SeriesPoint[]; signal: SeriesPoint[] };
|
||||
};
|
||||
|
||||
export type ChartResponse = {
|
||||
ok: boolean;
|
||||
symbol?: string;
|
||||
source?: string | null;
|
||||
tf?: string;
|
||||
bucketSeconds?: number;
|
||||
candles?: Candle[];
|
||||
indicators?: ChartIndicators;
|
||||
error?: string;
|
||||
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 getApiBaseUrl(): string {
|
||||
const v = (import.meta as any).env?.VITE_API_URL;
|
||||
if (v) return String(v);
|
||||
return '/api';
|
||||
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: {
|
||||
@@ -44,33 +318,8 @@ export async function fetchChart(params: {
|
||||
source?: string;
|
||||
tf: string;
|
||||
limit: number;
|
||||
beforeTime?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
||||
const base = getApiBaseUrl();
|
||||
const u = new URL(base, window.location.origin);
|
||||
u.pathname = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') + '/v1/chart' : '/v1/chart';
|
||||
u.searchParams.set('symbol', params.symbol);
|
||||
u.searchParams.set('tf', params.tf);
|
||||
u.searchParams.set('limit', String(params.limit));
|
||||
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
||||
|
||||
const res = await fetch(u.toString());
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||
const json = JSON.parse(text) as ChartResponse;
|
||||
if (!json.ok) throw new Error(json.error || 'API: error');
|
||||
return {
|
||||
candles: (json.candles || []).map((c) => ({
|
||||
...c,
|
||||
time: Number(c.time),
|
||||
open: Number(c.open),
|
||||
high: Number(c.high),
|
||||
low: Number(c.low),
|
||||
close: Number(c.close),
|
||||
volume: c.volume == null ? undefined : Number(c.volume),
|
||||
oracle: c.oracle == null ? null : Number(c.oracle),
|
||||
})),
|
||||
indicators: json.indicators || {},
|
||||
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
||||
};
|
||||
return await fetchChartFromHasura(params);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user