chore: initial import

This commit is contained in:
u1
2026-01-06 12:33:47 +01:00
commit ed37565e25
38 changed files with 5707 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
export type Candle = {
time: number; // unix seconds
open: number;
high: number;
low: number;
close: number;
volume?: number;
oracle?: number | null;
};
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[] };
};
export type ChartResponse = {
ok: boolean;
symbol?: string;
source?: string | null;
tf?: string;
bucketSeconds?: number;
candles?: Candle[];
indicators?: ChartIndicators;
error?: string;
};
function getApiBaseUrl(): string {
const v = (import.meta as any).env?.VITE_API_URL;
if (v) return String(v);
return '/api';
}
export async function fetchChart(params: {
symbol: string;
source?: string;
tf: string;
limit: number;
}): 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) },
};
}

View File

@@ -0,0 +1,115 @@
export type DriftTick = {
ts: string;
market_index: number;
symbol: string;
oracle_price: number;
mark_price?: number | null;
oracle_slot?: number | null;
source?: string;
};
type GraphQLError = { message: string };
function getApiUrl(): string | undefined {
const v = (import.meta as any).env?.VITE_API_URL;
if (v) return String(v);
// Default to same-origin API proxy at /api (Vite dev server proxies /api -> trade-api).
return '/api';
}
function getHasuraUrl(): string {
return (import.meta as any).env?.VITE_HASURA_URL || 'http://localhost:8080/v1/graphql';
}
function getAuthToken(): string | undefined {
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
return v ? String(v) : undefined;
}
function getAdminSecret(): string | undefined {
const v = (import.meta as any).env?.VITE_HASURA_ADMIN_SECRET;
return v ? String(v) : undefined;
}
export async function hasuraRequest<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const headers: Record<string, string> = {
'content-type': 'application/json',
};
const token = getAuthToken();
if (token) {
headers.authorization = `Bearer ${token}`;
} else {
const secret = getAdminSecret();
if (secret) headers['x-hasura-admin-secret'] = secret;
}
const res = await fetch(getHasuraUrl(), {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
const text = await res.text();
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
const json = JSON.parse(text) as { data?: T; errors?: GraphQLError[] };
if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join(' | '));
if (!json.data) throw new Error('Hasura: empty response');
return json.data;
}
export async function fetchLatestTicks(symbol: string, limit: number, source?: string): Promise<DriftTick[]> {
const apiUrl = getApiUrl();
if (apiUrl) {
const u = new URL(apiUrl, window.location.origin);
u.pathname = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') + '/v1/ticks' : '/v1/ticks';
u.searchParams.set('symbol', symbol);
u.searchParams.set('limit', String(limit));
if (source && source.trim()) u.searchParams.set('source', 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 { ok?: boolean; ticks?: any[]; error?: string };
if (!json.ok) throw new Error(json.error || 'API: error');
return (json.ticks || []).map((t: any) => ({
ts: String(t.ts),
market_index: Number(t.market_index),
symbol: String(t.symbol),
oracle_price: Number(t.oracle_price),
mark_price: t.mark_price == null ? null : Number(t.mark_price),
oracle_slot: t.oracle_slot == null ? null : Number(t.oracle_slot),
source: t.source == null ? undefined : String(t.source),
}));
}
const where: any = { symbol: { _eq: symbol } };
if (source && source.trim()) where.source = { _eq: source.trim() };
const query = `
query LatestTicks($where: drift_ticks_bool_exp!, $limit: Int!) {
drift_ticks(where: $where, order_by: {ts: desc}, limit: $limit) {
ts
market_index
symbol
oracle_price
mark_price
oracle_slot
source
}
}
`;
const data = await hasuraRequest<{
drift_ticks: Array<Omit<DriftTick, 'oracle_price' | 'mark_price'> & { oracle_price: any; mark_price?: any }>;
}>(query, { where, limit });
return data.drift_ticks
.map((t) => ({
...t,
oracle_price: Number(t.oracle_price),
mark_price: t.mark_price == null ? null : Number(t.mark_price),
}))
.reverse();
}

View File

@@ -0,0 +1,136 @@
function mean(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((a, b) => a + b, 0) / values.length;
}
function stddev(values: number[]): number {
if (values.length === 0) return 0;
const m = mean(values);
const v = values.reduce((acc, x) => acc + (x - m) * (x - m), 0) / values.length;
return Math.sqrt(v);
}
export function sma(values: number[], period: number): Array<number | null> {
if (period <= 0) throw new Error('period must be > 0');
const out: Array<number | null> = new Array(values.length).fill(null);
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += values[i];
if (i >= period) sum -= values[i - period];
if (i >= period - 1) out[i] = sum / period;
}
return out;
}
export function ema(values: number[], period: number): Array<number | null> {
if (period <= 0) throw new Error('period must be > 0');
const out: Array<number | null> = new Array(values.length).fill(null);
const k = 2 / (period + 1);
if (values.length < period) return out;
const first = mean(values.slice(0, period));
out[period - 1] = first;
let prev = first;
for (let i = period; i < values.length; i++) {
const next = values[i] * k + prev * (1 - k);
out[i] = next;
prev = next;
}
return out;
}
export function rsi(values: number[], period: number): Array<number | null> {
if (period <= 0) throw new Error('period must be > 0');
const out: Array<number | null> = new Array(values.length).fill(null);
if (values.length <= period) return out;
let gains = 0;
let losses = 0;
for (let i = 1; i <= period; i++) {
const change = values[i] - values[i - 1];
if (change >= 0) gains += change;
else losses -= change;
}
let avgGain = gains / period;
let avgLoss = losses / period;
const rs = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
out[period] = 100 - 100 / (1 + rs);
for (let i = period + 1; i < values.length; i++) {
const change = values[i] - values[i - 1];
const gain = Math.max(change, 0);
const loss = Math.max(-change, 0);
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
const rs2 = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
out[i] = 100 - 100 / (1 + rs2);
}
return out;
}
export function bollingerBands(values: number[], period: number, stdDevMult: number) {
if (period <= 0) throw new Error('period must be > 0');
const upper: Array<number | null> = new Array(values.length).fill(null);
const lower: Array<number | null> = new Array(values.length).fill(null);
const mid = sma(values, period);
for (let i = period - 1; i < values.length; i++) {
const window = values.slice(i - period + 1, i + 1);
const sd = stddev(window);
const m = mid[i];
if (m == null) continue;
upper[i] = m + stdDevMult * sd;
lower[i] = m - stdDevMult * sd;
}
return { upper, lower, mid };
}
export function macd(values: number[], fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) {
const fast = ema(values, fastPeriod);
const slow = ema(values, slowPeriod);
const macdLine: Array<number | null> = values.map((_, i) => {
const f = fast[i];
const s = slow[i];
return f == null || s == null ? null : f - s;
});
// EMA over a nullable series, aligned by index.
const signal: Array<number | null> = new Array(values.length).fill(null);
const k = 2 / (signalPeriod + 1);
let seeded = false;
let prev = 0;
const buf: number[] = [];
for (let i = 0; i < macdLine.length; i++) {
const v = macdLine[i];
if (v == null) continue;
if (!seeded) {
buf.push(v);
if (buf.length === signalPeriod) {
const first = mean(buf);
signal[i] = first;
prev = first;
seeded = true;
}
continue;
}
const next = v * k + prev * (1 - k);
signal[i] = next;
prev = next;
}
return { macd: macdLine, signal };
}
export function lastNonNull(values: Array<number | null>): number | null {
for (let i = values.length - 1; i >= 0; i--) {
const v = values[i];
if (v != null && Number.isFinite(v)) return v;
}
return null;
}