217 lines
6.5 KiB
TypeScript
217 lines
6.5 KiB
TypeScript
/**
|
|
* KRW ticker — 백엔드 /api/markets/tickers 우선, 메모리 캐시·in-flight 합치기.
|
|
* 빈 배열(200)은 Upbit 직접 호출하지 않음(429 폭주 방지). /upbit-api 폴백은 직렬·쿨다운.
|
|
*/
|
|
import { API_BASE } from './backendApi';
|
|
import { UPBIT_API } from './upbitApi';
|
|
|
|
export interface UpbitTickerRaw {
|
|
market?: string;
|
|
code?: string;
|
|
trade_price: number;
|
|
signed_change_rate: number;
|
|
signed_change_price: number;
|
|
acc_trade_price_24h: number;
|
|
acc_trade_volume_24h?: number;
|
|
opening_price: number;
|
|
high_price: number;
|
|
low_price: number;
|
|
change?: 'RISE' | 'FALL' | 'EVEN';
|
|
}
|
|
|
|
const CHUNK_SIZE = 40;
|
|
const CHUNK_DELAY_MS = 400;
|
|
const MAX_CHUNK_ATTEMPTS = 2;
|
|
const CACHE_TTL_MS = 12_000;
|
|
const ALL_CACHE_TTL_MS = 15_000;
|
|
const UPBIT_COOLDOWN_MS = 45_000;
|
|
const UPBIT_MIN_GAP_MS = 600;
|
|
/** 프로덕션: 브라우저→업비트 직접 호출 금지(IP 429). 개발만 폴백 허용 */
|
|
const ALLOW_UPBIT_DIRECT = import.meta.env.DEV;
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(r => setTimeout(r, ms));
|
|
}
|
|
|
|
function tickerCode(t: UpbitTickerRaw): string {
|
|
return (t.market ?? t.code ?? '') as string;
|
|
}
|
|
|
|
function cacheKey(markets: string[]): string {
|
|
if (markets.length === 0) return '__all__';
|
|
return [...markets].sort().join(',');
|
|
}
|
|
|
|
type CacheEntry = { data: UpbitTickerRaw[]; at: number };
|
|
|
|
const memoryCache = new Map<string, CacheEntry>();
|
|
const inflight = new Map<string, Promise<UpbitTickerRaw[]>>();
|
|
let upbitCooldownUntil = 0;
|
|
let upbitQueue: Promise<void> = Promise.resolve();
|
|
let lastUpbitAt = 0;
|
|
|
|
function readCache(key: string, ttlMs: number): UpbitTickerRaw[] | null {
|
|
const hit = memoryCache.get(key);
|
|
if (!hit) return null;
|
|
if (Date.now() - hit.at > ttlMs) return null;
|
|
return hit.data;
|
|
}
|
|
|
|
function writeCache(key: string, data: UpbitTickerRaw[]): void {
|
|
if (data.length === 0) return;
|
|
memoryCache.set(key, { data, at: Date.now() });
|
|
}
|
|
|
|
function mergeStale(want: Set<string>): UpbitTickerRaw[] {
|
|
const merged = new Map<string, UpbitTickerRaw>();
|
|
const now = Date.now();
|
|
for (const entry of memoryCache.values()) {
|
|
if (now - entry.at > ALL_CACHE_TTL_MS * 3) continue;
|
|
for (const t of entry.data) {
|
|
const c = tickerCode(t);
|
|
if (!c) continue;
|
|
if (want.size > 0 && !want.has(c)) continue;
|
|
merged.set(c, t);
|
|
}
|
|
}
|
|
return [...merged.values()];
|
|
}
|
|
|
|
/** 백엔드 일괄 ticker */
|
|
async function fetchTickersFromBackend(markets: string[]): Promise<UpbitTickerRaw[] | null> {
|
|
try {
|
|
const qs = markets.length > 0
|
|
? `?markets=${encodeURIComponent(markets.join(','))}`
|
|
: '';
|
|
const url = `${API_BASE}/markets/tickers${qs}`;
|
|
let res = await fetch(url);
|
|
if (res.status === 429) {
|
|
await sleep(2000);
|
|
res = await fetch(url);
|
|
}
|
|
if (!res.ok) return null;
|
|
const data = (await res.json()) as UpbitTickerRaw[];
|
|
return Array.isArray(data) ? data : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function runUpbitDirectQueued(markets: string[]): Promise<UpbitTickerRaw[]> {
|
|
if (Date.now() < upbitCooldownUntil) return mergeStale(new Set(markets));
|
|
|
|
const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))];
|
|
if (unique.length === 0) return [];
|
|
|
|
const run = async (): Promise<UpbitTickerRaw[]> => {
|
|
const gap = UPBIT_MIN_GAP_MS - (Date.now() - lastUpbitAt);
|
|
if (gap > 0) await sleep(gap);
|
|
|
|
const results: UpbitTickerRaw[] = [];
|
|
for (let i = 0; i < unique.length; i += CHUNK_SIZE) {
|
|
const chunk = unique.slice(i, i + CHUNK_SIZE);
|
|
let chunkOk = false;
|
|
for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS && !chunkOk; attempt++) {
|
|
lastUpbitAt = Date.now();
|
|
try {
|
|
const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`);
|
|
if (res.status === 429) {
|
|
upbitCooldownUntil = Date.now() + UPBIT_COOLDOWN_MS;
|
|
await sleep(1200 * (attempt + 1));
|
|
continue;
|
|
}
|
|
if (res.ok) {
|
|
const data = (await res.json()) as UpbitTickerRaw[];
|
|
if (Array.isArray(data)) results.push(...data);
|
|
}
|
|
chunkOk = true;
|
|
} catch {
|
|
await sleep(500 * (attempt + 1));
|
|
}
|
|
}
|
|
if (Date.now() < upbitCooldownUntil) break;
|
|
if (i + CHUNK_SIZE < unique.length) await sleep(CHUNK_DELAY_MS);
|
|
}
|
|
return results;
|
|
};
|
|
|
|
const p = upbitQueue.then(run, run);
|
|
upbitQueue = p.then(() => {}, () => {});
|
|
return p;
|
|
}
|
|
|
|
async function fetchTickersCore(markets: string[]): Promise<UpbitTickerRaw[]> {
|
|
const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))];
|
|
const key = cacheKey(unique);
|
|
const want = new Set(unique);
|
|
const ttl = unique.length === 0 ? ALL_CACHE_TTL_MS : CACHE_TTL_MS;
|
|
|
|
const cached = readCache(key, ttl);
|
|
if (cached && cached.length > 0) return cached;
|
|
|
|
const fromBackend = await fetchTickersFromBackend(unique);
|
|
if (fromBackend != null) {
|
|
if (fromBackend.length > 0) {
|
|
writeCache(key, fromBackend);
|
|
if (unique.length === 0) writeCache('__all__', fromBackend);
|
|
return fromBackend;
|
|
}
|
|
const stale = mergeStale(want);
|
|
if (stale.length > 0) return stale;
|
|
return [];
|
|
}
|
|
|
|
if (unique.length === 0) {
|
|
const allBackend = await fetchTickersFromBackend([]);
|
|
if (allBackend != null) {
|
|
if (allBackend.length > 0) {
|
|
writeCache('__all__', allBackend);
|
|
return allBackend;
|
|
}
|
|
const stale = mergeStale(want);
|
|
if (stale.length > 0) return stale;
|
|
return [];
|
|
}
|
|
}
|
|
|
|
if (!ALLOW_UPBIT_DIRECT) {
|
|
const stale = mergeStale(want);
|
|
return stale.length > 0 ? stale : [];
|
|
}
|
|
|
|
if (Date.now() < upbitCooldownUntil) {
|
|
const stale = mergeStale(want);
|
|
if (stale.length > 0) return stale;
|
|
return [];
|
|
}
|
|
|
|
const direct = await runUpbitDirectQueued(unique);
|
|
if (direct.length > 0) {
|
|
writeCache(key, direct);
|
|
return direct;
|
|
}
|
|
|
|
const stale = mergeStale(want);
|
|
return stale.length > 0 ? stale : [];
|
|
}
|
|
|
|
/**
|
|
* ticker 조회 — 동일 markets 동시 요청 1회로 합침, 캐시·백엔드 우선.
|
|
*/
|
|
export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTickerRaw[]> {
|
|
const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))];
|
|
const key = cacheKey(unique);
|
|
|
|
const cached = readCache(key, unique.length === 0 ? ALL_CACHE_TTL_MS : CACHE_TTL_MS);
|
|
if (cached && cached.length > 0) return cached;
|
|
|
|
const pending = inflight.get(key);
|
|
if (pending) return pending;
|
|
|
|
const promise = fetchTickersCore(unique).finally(() => {
|
|
inflight.delete(key);
|
|
});
|
|
inflight.set(key, promise);
|
|
return promise;
|
|
}
|