goldenChat base source add
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* useAppSettings
|
||||
*
|
||||
* 앱 전역 차트 기본 설정을 DB 와 동기화하는 훅.
|
||||
*
|
||||
* 관리 항목 (하드코딩 대체):
|
||||
* - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol)
|
||||
* - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe)
|
||||
* - defaultChartType : 기본 차트 타입
|
||||
* - defaultTheme : 기본 테마
|
||||
* - defaultLogScale : 기본 로그 스케일
|
||||
* - defaultLayoutId : 기본 레이아웃 ID
|
||||
* - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE)
|
||||
* - syncOptions : 멀티차트 동기화 옵션 (DEFAULT_SYNC)
|
||||
*
|
||||
* 사용 예:
|
||||
* ```tsx
|
||||
* const { settings, isLoaded, save } = useAppSettings();
|
||||
*
|
||||
* // 기본 심볼 사용
|
||||
* const symbol = settings.defaultSymbol ?? 'KRW-BTC';
|
||||
*
|
||||
* // 테마 변경 시 DB 에 저장
|
||||
* save({ defaultTheme: newTheme });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
loadAppSettings,
|
||||
saveAppSettings,
|
||||
type AppSettingsDto,
|
||||
} from '../utils/backendApi';
|
||||
import type { Theme, ChartType, Timeframe, MainChartStyle } from '../types';
|
||||
import { resolveChartLegendOptions } from '../types/chartLegend';
|
||||
import type { ChartLegendVisibility } from '../types/chartLegend';
|
||||
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
||||
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
|
||||
|
||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||
let _cache: AppSettingsDto | null = null;
|
||||
let _loadPromise: Promise<AppSettingsDto> | null = null;
|
||||
|
||||
function ensureLoaded(): Promise<AppSettingsDto> {
|
||||
if (_cache !== null) return Promise.resolve(_cache);
|
||||
if (_loadPromise) return _loadPromise;
|
||||
_loadPromise = loadAppSettings().then(data => {
|
||||
_cache = data ?? {};
|
||||
_loadPromise = null;
|
||||
return _cache;
|
||||
});
|
||||
return _loadPromise;
|
||||
}
|
||||
|
||||
export function invalidateAppSettingsCache() {
|
||||
_cache = null;
|
||||
_loadPromise = null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** DB 또는 fallback 기본값 접근 헬퍼 */
|
||||
export function resolveAppDefaults(s: AppSettingsDto) {
|
||||
return {
|
||||
symbol: (s.defaultSymbol ?? 'KRW-BTC') as string,
|
||||
timeframe: (s.defaultTimeframe ?? '1D') as Timeframe,
|
||||
chartType: (s.defaultChartType ?? 'candlestick') as ChartType,
|
||||
theme: (s.defaultTheme ?? 'dark') as Theme,
|
||||
logScale: s.defaultLogScale ?? false,
|
||||
layoutId: s.defaultLayoutId ?? '1',
|
||||
mainChartStyle:(s.mainChartStyle as unknown as MainChartStyle | null) ?? DEFAULT_MAIN_CHART_STYLE,
|
||||
syncOptions: (s.syncOptions as unknown as SyncOptions | null) ?? DEFAULT_SYNC,
|
||||
btAutoPopup: s.btAutoPopup ?? true,
|
||||
btShowPrice: s.btShowPrice ?? true,
|
||||
chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true,
|
||||
chartVolumeVisible: s.chartVolumeVisible ?? true,
|
||||
chartLegendOptions: resolveChartLegendOptions(
|
||||
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
|
||||
),
|
||||
tradeAlertPopup: s.tradeAlertPopup ?? true,
|
||||
tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true,
|
||||
tradeAlertSound: s.tradeAlertSound ?? 'bell',
|
||||
tradeAlertPopupPosition: s.tradeAlertPopupPosition ?? 'right',
|
||||
tradeAlertPopupLayout: s.tradeAlertPopupLayout ?? 'stack',
|
||||
tradeAlertPopupGridCols: s.tradeAlertPopupGridCols ?? 2,
|
||||
liveStrategyCheck: s.liveStrategyCheck ?? false,
|
||||
liveStrategyId: s.liveStrategyId ?? null,
|
||||
liveExecutionType: s.liveExecutionType ?? 'CANDLE_CLOSE',
|
||||
livePositionMode: s.livePositionMode ?? 'LONG_ONLY',
|
||||
paperTradingEnabled: s.paperTradingEnabled ?? true,
|
||||
paperInitialCapital: s.paperInitialCapital ?? 10_000_000,
|
||||
paperFeeRatePct: s.paperFeeRatePct ?? 0.05,
|
||||
paperSlippagePct: s.paperSlippagePct ?? 0,
|
||||
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
|
||||
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
|
||||
paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95,
|
||||
tradingMode: s.tradingMode ?? 'PAPER',
|
||||
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
|
||||
hasUpbitKeys: s.hasUpbitKeys ?? false,
|
||||
upbitAccessKeyMasked: s.upbitAccessKeyMasked ?? null,
|
||||
chartRealtimeSource: s.chartRealtimeSource ?? 'BACKEND_STOMP',
|
||||
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
|
||||
fcmPushEnabled: s.fcmPushEnabled ?? false,
|
||||
displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */
|
||||
export function useAppSettings(sessionKey = 0) {
|
||||
const [settings, setSettings] = useState<AppSettingsDto>(_cache ?? {});
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
setIsLoaded(false);
|
||||
invalidateAppSettingsCache();
|
||||
ensureLoaded().then(data => {
|
||||
if (mountedRef.current) {
|
||||
setSettings(data);
|
||||
setDisplayTimezone(normalizeTimezone(data.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[useAppSettings] load failed', err);
|
||||
if (mountedRef.current) setIsLoaded(true);
|
||||
});
|
||||
return () => { mountedRef.current = false; };
|
||||
}, [sessionKey]);
|
||||
|
||||
/**
|
||||
* 특정 필드를 DB 에 저장하고 로컬 캐시를 업데이트.
|
||||
* 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영.
|
||||
*/
|
||||
const save = useCallback((patch: AppSettingsDto) => {
|
||||
const merged = { ...(_cache ?? {}), ...patch };
|
||||
_cache = merged;
|
||||
setSettings(merged);
|
||||
if (patch.displayTimezone != null) {
|
||||
setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
|
||||
}
|
||||
saveAppSettings(patch).then(updated => {
|
||||
if (updated && mountedRef.current) {
|
||||
_cache = { ...(_cache ?? {}), ...updated };
|
||||
setSettings(prev => ({ ...prev, ...updated }));
|
||||
}
|
||||
}).catch(err =>
|
||||
console.error('[useAppSettings] save failed', err)
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** 로드된 설정의 해석된 기본값 */
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
|
||||
return { settings, defaults, isLoaded, save };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 백테스팅 실행 훅.
|
||||
*
|
||||
* 사용법:
|
||||
* const { signals, stats, analysis, running, run, clear } = useBacktest();
|
||||
*
|
||||
* await run({ strategyId, bars, timeframe });
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { OHLCVBar } from '../types';
|
||||
import {
|
||||
runBacktest,
|
||||
type BacktestSignal,
|
||||
type BacktestStats,
|
||||
type BacktestAnalysis,
|
||||
type BacktestSettingsDto,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
export interface BacktestResult {
|
||||
signals: BacktestSignal[];
|
||||
stats: BacktestStats | null;
|
||||
analysis: BacktestAnalysis | null;
|
||||
resultId?: number;
|
||||
}
|
||||
|
||||
export function useBacktest() {
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = useCallback(async (opts: {
|
||||
strategyId?: number;
|
||||
buyCondition?: unknown;
|
||||
sellCondition?: unknown;
|
||||
bars: OHLCVBar[];
|
||||
timeframe: string;
|
||||
indicatorParams?: Record<string, Record<string, unknown>>;
|
||||
settings?: BacktestSettingsDto;
|
||||
symbol?: string;
|
||||
strategyName?: string;
|
||||
}) => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const req = {
|
||||
strategyId: opts.strategyId,
|
||||
buyCondition: opts.buyCondition,
|
||||
sellCondition: opts.sellCondition,
|
||||
bars: opts.bars.map(b => ({
|
||||
time: b.time,
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
volume: b.volume,
|
||||
})),
|
||||
timeframe: opts.timeframe,
|
||||
indicatorParams: opts.indicatorParams,
|
||||
settings: opts.settings,
|
||||
symbol: opts.symbol,
|
||||
strategyName: opts.strategyName,
|
||||
};
|
||||
const res = await runBacktest(req);
|
||||
if (!res) {
|
||||
setError('백테스팅 요청에 실패했습니다.');
|
||||
return null;
|
||||
}
|
||||
const r: BacktestResult = {
|
||||
signals: res.signals,
|
||||
stats: res.stats,
|
||||
analysis: res.analysis ?? null,
|
||||
resultId: res.resultId,
|
||||
};
|
||||
setResult(r);
|
||||
return r;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '알 수 없는 오류';
|
||||
setError(msg);
|
||||
return null;
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
signals: result?.signals ?? [],
|
||||
stats: result?.stats ?? null,
|
||||
analysis: result?.analysis ?? null,
|
||||
result,
|
||||
running,
|
||||
error,
|
||||
run,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Timeframe, OHLCVBar } from '../types';
|
||||
import { useUpbitData, type WsStatus } from './useUpbitData';
|
||||
import { useStompChartData } from './useStompChartData';
|
||||
|
||||
export type ChartRealtimeSource = 'BACKEND_STOMP' | 'UPBIT_DIRECT';
|
||||
|
||||
interface Options {
|
||||
onTickUpdate: (bar: OHLCVBar) => void;
|
||||
onNewCandle: (bar: OHLCVBar) => void;
|
||||
enabled?: boolean;
|
||||
deepObvHistory?: boolean;
|
||||
source?: ChartRealtimeSource;
|
||||
}
|
||||
|
||||
export function useChartRealtimeData(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
opts: Options,
|
||||
) {
|
||||
const useStomp = opts.source !== 'UPBIT_DIRECT';
|
||||
const stomp = useStompChartData(market, timeframe, {
|
||||
onTickUpdate: opts.onTickUpdate,
|
||||
onNewCandle: opts.onNewCandle,
|
||||
enabled: opts.enabled !== false && useStomp,
|
||||
});
|
||||
const upbit = useUpbitData(market, timeframe, {
|
||||
onTickUpdate: opts.onTickUpdate,
|
||||
onNewCandle: opts.onNewCandle,
|
||||
enabled: opts.enabled !== false && !useStomp,
|
||||
deepObvHistory: opts.deepObvHistory,
|
||||
});
|
||||
return useStomp ? stomp : upbit;
|
||||
}
|
||||
|
||||
export type { WsStatus };
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
|
||||
import { bindWindowDrag, clientXYFromReact, isPrimaryPointerButton } from '../utils/pointerDrag';
|
||||
|
||||
export interface DraggablePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface UseDraggablePanelOptions {
|
||||
/** 마운트 후 패널 크기 기준 화면 중앙 배치 */
|
||||
centerOnMount?: boolean;
|
||||
initialPosition?: DraggablePosition;
|
||||
panelRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const DEFAULT_POS: DraggablePosition = { x: 80, y: 72 };
|
||||
|
||||
/**
|
||||
* 팝업 패널 타이틀바 드래그 이동 (마우스·터치·펜)
|
||||
*/
|
||||
export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
|
||||
const {
|
||||
centerOnMount = false,
|
||||
initialPosition = DEFAULT_POS,
|
||||
panelRef: externalRef,
|
||||
} = options;
|
||||
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = (externalRef ?? internalRef) as RefObject<HTMLDivElement | null>;
|
||||
const [pos, setPos] = useState<DraggablePosition>(initialPosition);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragOrigin = useRef({ mx: 0, my: 0, px: 0, py: 0 });
|
||||
const centeredOnce = useRef(false);
|
||||
const unbindDragRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!centerOnMount || centeredOnce.current) return;
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const placeCenter = () => {
|
||||
const w = el.offsetWidth || 400;
|
||||
const h = el.offsetHeight || 320;
|
||||
setPos({
|
||||
x: Math.max(8, (window.innerWidth - w) / 2),
|
||||
y: Math.max(8, (window.innerHeight - h) / 2),
|
||||
});
|
||||
centeredOnce.current = true;
|
||||
};
|
||||
|
||||
placeCenter();
|
||||
const id = requestAnimationFrame(placeCenter);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [centerOnMount, panelRef]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
unbindDragRef.current?.();
|
||||
unbindDragRef.current = null;
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (!isPrimaryPointerButton(e)) return;
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
try {
|
||||
el.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const { x, y } = clientXYFromReact(e);
|
||||
setDragging(true);
|
||||
dragOrigin.current = { mx: x, my: y, px: pos.x, py: pos.y };
|
||||
|
||||
unbindDragRef.current?.();
|
||||
unbindDragRef.current = bindWindowDrag(
|
||||
({ x: cx, y: cy }) => {
|
||||
const o = dragOrigin.current;
|
||||
setPos({
|
||||
x: o.px + (cx - o.mx),
|
||||
y: o.py + (cy - o.my),
|
||||
});
|
||||
},
|
||||
endDrag,
|
||||
);
|
||||
}, [pos, endDrag]);
|
||||
|
||||
useEffect(() => () => {
|
||||
unbindDragRef.current?.();
|
||||
}, []);
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
};
|
||||
|
||||
return {
|
||||
panelRef: internalRef,
|
||||
pos,
|
||||
dragging,
|
||||
/** @deprecated onHeaderPointerDown 사용 */
|
||||
onHeaderMouseDown: onHeaderPointerDown,
|
||||
onHeaderPointerDown,
|
||||
panelStyle,
|
||||
headerCursor: dragging ? 'grabbing' : 'grab',
|
||||
headerTouchStyle: { touchAction: 'none' as const },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* useHistoryLoader
|
||||
*
|
||||
* 차트를 왼쪽으로 스크롤할 때 과거 캔들 데이터를 추가로 불러오는 훅.
|
||||
*
|
||||
* 데이터 소스 우선순위:
|
||||
* 1. 백엔드 GET /api/candles/history (Ta4jStorage + 업비트 REST 프록시 + RSI 포함)
|
||||
* 2. 백엔드 호출 실패 시 → 업비트 REST 직접 폴백
|
||||
*
|
||||
* 중복 방지 락(Lock):
|
||||
* loadingRef = true 인 동안에는 동일 타임스탬프 중복 요청을 원천 차단.
|
||||
*
|
||||
* 사용 방법:
|
||||
* 1. 훅에서 반환한 { isLoadingMore, loadMoreRef } 를 받는다.
|
||||
* 2. ChartManager.subscribeVisibleLogicalRange 콜백 안에서
|
||||
* `r.from < LOAD_MORE_TRIGGER` 조건 시 `loadMoreRef.current()` 를 호출한다.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { RefObject, MutableRefObject } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import { fetchUpbitCandlesBeforeCached } from '../utils/requestCache';
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
|
||||
/** 이 값(바 인덱스)보다 왼쪽에 가시 범위의 시작이 오면 추가 로드 트리거 */
|
||||
export const LOAD_MORE_TRIGGER = 30;
|
||||
|
||||
/** 한 번에 불러올 추가 캔들 수 */
|
||||
const LOAD_BATCH = 200;
|
||||
|
||||
/** 연속 스크롤 중 불필요한 요청을 막는 디바운스 (ms) */
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
/** 백엔드 API 기본 URL */
|
||||
import { API_BASE } from '../utils/backendApi';
|
||||
|
||||
interface UseHistoryLoaderOptions {
|
||||
symbol: string;
|
||||
timeframe: Timeframe;
|
||||
isUpbit: boolean;
|
||||
managerRef: RefObject<ChartManager | null>;
|
||||
}
|
||||
|
||||
interface UseHistoryLoaderReturn {
|
||||
isLoadingMore: boolean;
|
||||
/** onManagerReady 의 subscribeVisibleLogicalRange 콜백에서 직접 호출 */
|
||||
loadMoreRef: MutableRefObject<() => void>;
|
||||
}
|
||||
|
||||
// ── 백엔드 /api/candles/history 호출 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 백엔드에서 과거 캔들을 가져온다.
|
||||
* 응답에 rsi 필드가 포함되어 있으며, 시간 오름차순으로 정렬된다.
|
||||
*
|
||||
* @param market 업비트 마켓 코드 (e.g. "KRW-BTC")
|
||||
* @param type 캔들 타입 (e.g. "1m", "1d")
|
||||
* @param to 기준 시간 이전 ISO-8601 (e.g. "2026-05-20T10:00:00")
|
||||
* @param count 요청 캔들 수
|
||||
*/
|
||||
async function fetchHistoryFromBackend(
|
||||
market: string,
|
||||
type: string,
|
||||
to: string,
|
||||
count: number,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const params = new URLSearchParams({
|
||||
market,
|
||||
type,
|
||||
to,
|
||||
count: String(count),
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: any[] = await res.json();
|
||||
// 백엔드 CandleBarDto → OHLCVBar 변환 (rsi 는 extra 필드로 보존)
|
||||
return data.map(d => ({
|
||||
time: d.time as number,
|
||||
open: d.open as number,
|
||||
high: d.high as number,
|
||||
low: d.low as number,
|
||||
close: d.close as number,
|
||||
volume: d.volume as number,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 훅 본체 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useHistoryLoader({
|
||||
symbol,
|
||||
timeframe,
|
||||
isUpbit,
|
||||
managerRef,
|
||||
}: UseHistoryLoaderOptions): UseHistoryLoaderReturn {
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
/**
|
||||
* 중복 요청 방지 락(Lock):
|
||||
* ref 를 사용하여 re-render 없이 동기적으로 상태를 업데이트한다.
|
||||
* true 인 동안에는 스크롤 이벤트가 아무리 발생해도 API 를 재호출하지 않는다.
|
||||
*/
|
||||
const loadingRef = useRef(false);
|
||||
/** false 가 되면 더 이상 API 를 호출하지 않음 */
|
||||
const hasMoreRef = useRef(true);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 심볼·타임프레임이 바뀌면 상태 초기화
|
||||
useEffect(() => {
|
||||
hasMoreRef.current = true;
|
||||
loadingRef.current = false;
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [symbol, timeframe]);
|
||||
|
||||
const doLoadMore = useCallback(async () => {
|
||||
if (!isUpbit) return; // 시뮬레이션 모드는 건너뜀
|
||||
if (loadingRef.current) return; // ★ 중복 방지 락: 이미 로딩 중이면 즉시 리턴
|
||||
if (!hasMoreRef.current) return; // 더 이상 데이터 없음
|
||||
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || mgr.getRawBarsLength() === 0) return;
|
||||
|
||||
const oldestTime = mgr.getOldestBarTime();
|
||||
if (oldestTime === null) return;
|
||||
|
||||
// Unix 타임스탬프(초) → ISO 8601 UTC ('Z' 없이 전달, 백엔드에서 처리)
|
||||
const beforeIso = new Date(oldestTime * 1000)
|
||||
.toISOString()
|
||||
.replace('.000Z', ''); // '2026-05-20T10:00:00'
|
||||
|
||||
// ★ 락 설정: 이 시점부터 동일 타임스탬프 중복 요청 차단
|
||||
loadingRef.current = true;
|
||||
setIsLoadingMore(true);
|
||||
|
||||
try {
|
||||
let olderBars: OHLCVBar[];
|
||||
|
||||
// ── 1. 백엔드 /api/candles/history 우선 시도 ──────────────────────────
|
||||
try {
|
||||
olderBars = await fetchHistoryFromBackend(symbol, timeframe, beforeIso, LOAD_BATCH);
|
||||
console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신`);
|
||||
} catch (backendErr) {
|
||||
// ── 2. 백엔드 실패 시 업비트 REST 직접 폴백 ───────────────────────
|
||||
console.warn('[HistoryLoader] 백엔드 실패, 업비트 직접 폴백:', backendErr);
|
||||
olderBars = await fetchUpbitCandlesBeforeCached(
|
||||
symbol, timeframe, LOAD_BATCH, beforeIso,
|
||||
);
|
||||
}
|
||||
|
||||
if (olderBars.length === 0) {
|
||||
hasMoreRef.current = false; // 더 이상 데이터 없음
|
||||
} else {
|
||||
// ── 3. 차트 앞부분에 부드럽게 병합 (Prepend) ──────────────────────
|
||||
await mgr.prependBars(olderBars);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[HistoryLoader] 과거 데이터 로드 실패:', e);
|
||||
} finally {
|
||||
// ★ 락 해제: 다음 스크롤 이벤트부터 재요청 허용
|
||||
loadingRef.current = false;
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [symbol, timeframe, isUpbit, managerRef]);
|
||||
|
||||
/**
|
||||
* 디바운스 래퍼 — subscribeVisibleLogicalRange 는 스크롤 중 매 프레임 호출되므로
|
||||
* 사용자가 잠시 멈췄을 때 한 번만 실제 API 요청이 나가도록 한다.
|
||||
*/
|
||||
const loadMoreRef = useRef<() => void>(() => {});
|
||||
loadMoreRef.current = useCallback(() => {
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
debounceTimer.current = null;
|
||||
doLoadMore();
|
||||
}, DEBOUNCE_MS);
|
||||
}, [doLoadMore]);
|
||||
|
||||
return { isLoadingMore, loadMoreRef };
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* useIndicatorSettings
|
||||
*
|
||||
* 전역 지표 파라미터 및 시각 설정(색상·선굵기·수평선)을 DB와 동기화하는 훅.
|
||||
*
|
||||
* - 앱 시작 시 DB에서 로드 → indicatorRegistry.ts 하드코딩 기본값을 완전 대체
|
||||
* - 지표 파라미터 또는 시각 설정이 변경되면 DB에 저장
|
||||
* - 백엔드 Ta4j 계산 시에도 동일한 파라미터 값 사용
|
||||
*
|
||||
* 사용 예:
|
||||
* ```tsx
|
||||
* const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||
*
|
||||
* // 지표 추가 시: DB 저장값 우선 적용 (파라미터 + 색상)
|
||||
* const params = getParams(type, def.defaultParams);
|
||||
* const { plots, hlines } = getVisualConfig(type, def.plots, def.hlines);
|
||||
*
|
||||
* // 지표 설정 저장 시: 파라미터 + 시각 설정 모두 DB에 반영
|
||||
* saveParams(updated.type, updated.params);
|
||||
* saveVisual(updated.type, updated.plots, updated.hlines);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { getIndicatorDef, mergePlotDefs, PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||
import { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig';
|
||||
import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig';
|
||||
import {
|
||||
DEFAULT_ICHIMOKU_CLOUD_COLORS,
|
||||
type IchimokuCloudColors,
|
||||
resolveIchimokuCloudColors,
|
||||
} from '../utils/ichimokuConfig';
|
||||
import {
|
||||
loadIndicatorSettings,
|
||||
saveIndicatorSettings,
|
||||
saveAllIndicatorSettings,
|
||||
loadIndicatorVisualSettings,
|
||||
saveIndicatorVisualSettings,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
type ParamMap = Record<string, unknown>;
|
||||
type AllSettings = Record<string, ParamMap>;
|
||||
|
||||
export interface IndicatorVisual {
|
||||
plots?: PlotDef[];
|
||||
hlines?: HLineDef[];
|
||||
cloudColors?: IchimokuCloudColors;
|
||||
}
|
||||
type VisualCache = Record<string, IndicatorVisual>;
|
||||
|
||||
// ── 전역 싱글톤 캐시 — 여러 슬롯에서 공유하여 중복 네트워크 요청 방지 ──
|
||||
let _cache: AllSettings | null = null;
|
||||
let _loadPromise: Promise<AllSettings> | null = null;
|
||||
|
||||
let _visualCache: VisualCache | null = null;
|
||||
let _visualLoadPromise: Promise<VisualCache> | null = null;
|
||||
|
||||
function ensureLoaded(): Promise<AllSettings> {
|
||||
if (_cache !== null) return Promise.resolve(_cache);
|
||||
if (_loadPromise) return _loadPromise;
|
||||
_loadPromise = loadIndicatorSettings().then(data => {
|
||||
_cache = (data as AllSettings) ?? {};
|
||||
_loadPromise = null;
|
||||
return _cache;
|
||||
});
|
||||
return _loadPromise;
|
||||
}
|
||||
|
||||
function ensureVisualLoaded(): Promise<VisualCache> {
|
||||
if (_visualCache !== null) return Promise.resolve(_visualCache);
|
||||
if (_visualLoadPromise) return _visualLoadPromise;
|
||||
_visualLoadPromise = loadIndicatorVisualSettings().then(data => {
|
||||
_visualCache = (data as VisualCache) ?? {};
|
||||
_visualLoadPromise = null;
|
||||
return _visualCache;
|
||||
});
|
||||
return _visualLoadPromise;
|
||||
}
|
||||
|
||||
/** 캐시를 강제로 무효화 (테스트 또는 로그아웃 시) */
|
||||
export function invalidateIndicatorSettingsCache() {
|
||||
_cache = null;
|
||||
_loadPromise = null;
|
||||
_visualCache = null;
|
||||
_visualLoadPromise = null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useIndicatorSettings(sessionKey = 0) {
|
||||
// 로그인/로그아웃 시 지표 설정 재로드
|
||||
useEffect(() => {
|
||||
invalidateIndicatorSettingsCache();
|
||||
ensureLoaded().catch(err =>
|
||||
console.error('[useIndicatorSettings] params load failed', err)
|
||||
);
|
||||
ensureVisualLoaded().catch(err =>
|
||||
console.error('[useIndicatorSettings] visual load failed', err)
|
||||
);
|
||||
}, [sessionKey]);
|
||||
|
||||
// ── 파라미터 ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 지표 타입에 대한 파라미터를 반환.
|
||||
* DB 저장값 → defaultParams 순으로 우선순위 적용.
|
||||
*/
|
||||
const getParams = useCallback(
|
||||
(
|
||||
type: string,
|
||||
defaults?: Record<string, number | string | boolean>
|
||||
): Record<string, number | string | boolean> => {
|
||||
const dbParams = _cache?.[type] ?? {};
|
||||
const fallback = defaults ?? getIndicatorDef(type)?.defaultParams ?? {};
|
||||
const merged = { ...fallback, ...dbParams } as Record<string, number | string | boolean>;
|
||||
if (type === 'SMA') {
|
||||
return normalizeSmaConfig({ id: '', type: 'SMA', params: merged }).params;
|
||||
}
|
||||
if (type === 'IchimokuCloud') {
|
||||
return normalizeIchimokuConfig({ id: '', type: 'IchimokuCloud', params: merged }).params;
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** 특정 지표 파라미터를 DB에 저장하고 캐시를 업데이트 */
|
||||
const saveParams = useCallback(
|
||||
(type: string, params: Record<string, number | string | boolean>) => {
|
||||
if (_cache) {
|
||||
_cache = { ..._cache, [type]: params as ParamMap };
|
||||
} else {
|
||||
_cache = { [type]: params as ParamMap };
|
||||
}
|
||||
saveIndicatorSettings(type, params as ParamMap).catch(err =>
|
||||
console.error(`[useIndicatorSettings] saveParams failed for ${type}`, err)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** 현재 활성 지표 목록 전체를 DB에 저장 */
|
||||
const saveAll = useCallback(
|
||||
(allParams: AllSettings) => {
|
||||
_cache = { ...allParams };
|
||||
saveAllIndicatorSettings(allParams).catch(err =>
|
||||
console.error('[useIndicatorSettings] saveAll failed', err)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ── 시각 설정 (색상·선굵기·수평선) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 지표의 시각 설정을 반환.
|
||||
* DB 저장값 → registry defaults 순으로 우선순위 적용.
|
||||
*
|
||||
* @param type 지표 타입 (e.g. "RSI")
|
||||
* @param defaultPlots 없을 경우 사용할 기본 플롯 (indicatorRegistry.plots)
|
||||
* @param defaultHlines 없을 경우 사용할 기본 수평선 (indicatorRegistry.hlines)
|
||||
*/
|
||||
const getVisualConfig = useCallback(
|
||||
(
|
||||
type: string,
|
||||
defaultPlots?: PlotDef[],
|
||||
defaultHlines?: HLineDef[]
|
||||
): { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors } => {
|
||||
const saved = _visualCache?.[type];
|
||||
const def = getIndicatorDef(type);
|
||||
const registryPlots = defaultPlots ?? def?.plots ?? [];
|
||||
let plots = mergePlotDefs(saved?.plots as PlotDef[] | undefined, registryPlots);
|
||||
const hlines = (saved?.hlines as HLineDef[] | undefined) ?? defaultHlines ?? def?.hlines ?? [];
|
||||
|
||||
if (type === 'SMA') {
|
||||
plots = normalizeSmaConfig({
|
||||
id: '',
|
||||
type: 'SMA',
|
||||
params: createDefaultSmaParams(),
|
||||
plots,
|
||||
}).plots ?? createDefaultSmaPlots();
|
||||
}
|
||||
|
||||
if (type === 'IchimokuCloud') {
|
||||
plots = mergeIchimokuPlots(plots, registryPlots);
|
||||
}
|
||||
|
||||
const cloudColors = type === 'IchimokuCloud'
|
||||
? resolveIchimokuCloudColors(saved?.cloudColors ?? DEFAULT_ICHIMOKU_CLOUD_COLORS)
|
||||
: undefined;
|
||||
|
||||
// 깊은 복사: 여러 슬롯이 같은 참조를 공유하지 않도록
|
||||
return {
|
||||
plots: plots.map(p => ({ ...p })),
|
||||
hlines: hlines.map(h => ({ ...h })),
|
||||
cloudColors: cloudColors ? { ...cloudColors } : undefined,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 지표의 시각 설정을 DB에 저장하고 캐시를 업데이트.
|
||||
* IndicatorSettingsModal 저장 콜백에서 호출.
|
||||
*/
|
||||
const saveVisual = useCallback(
|
||||
(
|
||||
type: string,
|
||||
plots?: PlotDef[],
|
||||
hlines?: HLineDef[],
|
||||
cloudColors?: IchimokuCloudColors,
|
||||
) => {
|
||||
const visual: IndicatorVisual = { plots, hlines };
|
||||
if (cloudColors) visual.cloudColors = cloudColors;
|
||||
if (_visualCache) {
|
||||
_visualCache = { ..._visualCache, [type]: visual };
|
||||
} else {
|
||||
_visualCache = { [type]: visual };
|
||||
}
|
||||
saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }).catch(err =>
|
||||
console.error(`[useIndicatorSettings] saveVisual failed for ${type}`, err)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { getParams, saveParams, saveAll, getVisualConfig, saveVisual };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* useLiveStrategyMarkers
|
||||
*
|
||||
* STOMP WebSocket 으로 /sub/charts/{market}/{candleType} 구독:
|
||||
* - signal 필드(BUY/SELL) 수신 시 마커 누적 (종목별)
|
||||
* - 동일 종목·타임스탬프·시그널 중복 방지
|
||||
* - activeMarket 차트에만 마커 렌더링, 알림 팝업은 모든 모니터링 종목에 대해 발생
|
||||
*/
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { parseStompJson } from '../utils/stompMessage';
|
||||
import { subscribeStompTopic } from '../utils/stompChartBroker';
|
||||
|
||||
export interface LiveMarker {
|
||||
time: number;
|
||||
signal: 'BUY' | 'SELL';
|
||||
price: number;
|
||||
market: string;
|
||||
}
|
||||
|
||||
export interface MarketCandleSub {
|
||||
market: string;
|
||||
candleType: string;
|
||||
}
|
||||
|
||||
interface UseLiveStrategyMarkersOptions {
|
||||
/** 실시간 체크 ON 인 종목 목록 */
|
||||
markets: string[];
|
||||
/** 종목별 STOMP 분봉 (없으면 candleType 기본값 사용) */
|
||||
subscriptions?: MarketCandleSub[];
|
||||
activeMarket: string;
|
||||
candleType?: string;
|
||||
enabled: boolean;
|
||||
onMarkersChange: (markers: LiveMarker[]) => void;
|
||||
onSignal?: (marker: LiveMarker) => void;
|
||||
}
|
||||
|
||||
export function useLiveStrategyMarkers({
|
||||
markets,
|
||||
subscriptions,
|
||||
activeMarket,
|
||||
candleType = '1m',
|
||||
enabled,
|
||||
onMarkersChange,
|
||||
onSignal,
|
||||
}: UseLiveStrategyMarkersOptions) {
|
||||
const [liveMarkers, setLiveMarkers] = useState<LiveMarker[]>([]);
|
||||
|
||||
const markersByMarketRef = useRef<Record<string, LiveMarker[]>>({});
|
||||
const seenRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const onMarkersChangeRef = useRef(onMarkersChange);
|
||||
onMarkersChangeRef.current = onMarkersChange;
|
||||
|
||||
const onSignalRef = useRef(onSignal);
|
||||
onSignalRef.current = onSignal;
|
||||
|
||||
const pushMarker = useCallback((market: string, data: {
|
||||
time: number; close: number; signal: string;
|
||||
}) => {
|
||||
if (data.signal !== 'BUY' && data.signal !== 'SELL') return;
|
||||
|
||||
const key = `${market}:${data.time}:${data.signal}`;
|
||||
if (seenRef.current.has(key)) return;
|
||||
seenRef.current.add(key);
|
||||
|
||||
const newMarker: LiveMarker = {
|
||||
time: data.time,
|
||||
signal: data.signal as 'BUY' | 'SELL',
|
||||
price: data.close,
|
||||
market,
|
||||
};
|
||||
|
||||
const prev = markersByMarketRef.current[market] ?? [];
|
||||
markersByMarketRef.current[market] = [...prev, newMarker];
|
||||
|
||||
if (market === activeMarket) {
|
||||
const active = markersByMarketRef.current[activeMarket] ?? [];
|
||||
setLiveMarkers(active);
|
||||
onMarkersChangeRef.current(active);
|
||||
}
|
||||
|
||||
onSignalRef.current?.(newMarker);
|
||||
}, [activeMarket]);
|
||||
|
||||
const clearMarkers = useCallback(() => {
|
||||
seenRef.current.clear();
|
||||
markersByMarketRef.current = {};
|
||||
setLiveMarkers([]);
|
||||
onMarkersChangeRef.current([]);
|
||||
}, []);
|
||||
|
||||
/** 활성 차트 종목 변경 시 해당 종목 마커만 차트에 반영 */
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const active = markersByMarketRef.current[activeMarket] ?? [];
|
||||
setLiveMarkers(active);
|
||||
onMarkersChangeRef.current(active);
|
||||
}, [activeMarket, enabled]);
|
||||
|
||||
const marketsKey = markets.join(',');
|
||||
const subsKey = subscriptions?.map(s => `${s.market}:${s.candleType}`).join(',') ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || markets.length === 0) {
|
||||
clearMarkers();
|
||||
return;
|
||||
}
|
||||
|
||||
const subs: MarketCandleSub[] = subscriptions?.length
|
||||
? subscriptions
|
||||
: markets.map(m => ({ market: m, candleType }));
|
||||
|
||||
const unsubs = subs.map(({ market, candleType: ct }) => {
|
||||
const topic = `/sub/charts/${market}/${ct}`;
|
||||
return subscribeStompTopic(topic, msg => {
|
||||
const data = parseStompJson<{
|
||||
time: number;
|
||||
close: number;
|
||||
signal?: string;
|
||||
}>(msg);
|
||||
if (!data?.signal || data.signal === 'NONE') return;
|
||||
pushMarker(market, { time: data.time, close: data.close, signal: data.signal });
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubs.forEach(u => u());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [marketsKey, subsKey, candleType, enabled]);
|
||||
|
||||
return { liveMarkers, clearMarkers };
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 업비트 마켓 목록 + 실시간 시세 훅
|
||||
*
|
||||
* 동작:
|
||||
* 1) 앱 마운트 시 /v1/market/all 로 전체 마켓 목록 + 한글명 로드
|
||||
* 2) /v1/ticker?markets=... 로 초기 시세 로드 (100개 단위 분할)
|
||||
* 3) WebSocket ticker 구독으로 실시간 시세 수신 (배치 업데이트 500ms)
|
||||
* 4) REST 폴링 30초마다 보조 갱신 (WebSocket 누락 방지)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { setMarketNames } from '../utils/marketNameCache';
|
||||
|
||||
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
|
||||
|
||||
export interface TickerData {
|
||||
market: string;
|
||||
koreanName: string;
|
||||
tradePrice: number | null;
|
||||
changeRate: number | null; // 전일대비 등락률 (0.01 = +1%)
|
||||
changePrice: number | null; // 전일대비 등락금액
|
||||
accTradePrice24: number; // 24h 누적 거래대금 (KRW) – 정렬용 (0 fallback)
|
||||
accTradeVolume24: number | null; // 24h 누적 거래량 (코인)
|
||||
openingPrice: number | null;
|
||||
highPrice: number | null;
|
||||
lowPrice: number | null;
|
||||
change: ChangeDir; // RISE / FALL / EVEN
|
||||
}
|
||||
|
||||
export interface MarketInfo {
|
||||
market: string;
|
||||
koreanName: string;
|
||||
englishName: string;
|
||||
}
|
||||
|
||||
const REST_POLL_INTERVAL = 30_000; // 30초 보조 폴링
|
||||
const WS_BATCH_MS = 400; // WebSocket 업데이트 배치 간격
|
||||
|
||||
// ── 내부 API 타입 ─────────────────────────────────────────────────────────
|
||||
|
||||
function toChangeDir(raw: UpbitTickerRaw): ChangeDir {
|
||||
if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change;
|
||||
const r = raw.signed_change_rate ?? 0;
|
||||
return r > 0 ? 'RISE' : r < 0 ? 'FALL' : 'EVEN';
|
||||
}
|
||||
|
||||
interface UpbitMarketAll {
|
||||
market: string;
|
||||
korean_name: string;
|
||||
english_name: string;
|
||||
}
|
||||
|
||||
interface UpbitTickerRaw {
|
||||
market?: string; // REST
|
||||
code?: string; // WebSocket
|
||||
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';
|
||||
}
|
||||
|
||||
function getMarketCode(raw: UpbitTickerRaw): string {
|
||||
return (raw.code ?? raw.market ?? '') as string;
|
||||
}
|
||||
|
||||
async function fetchAllMarkets(): Promise<MarketInfo[]> {
|
||||
const res = await fetch('/upbit-api/v1/market/all?isDetails=true');
|
||||
if (!res.ok) throw new Error(`market/all ${res.status}`);
|
||||
const data: UpbitMarketAll[] = await res.json();
|
||||
const krw = data.filter(m => m.market.startsWith('KRW-'));
|
||||
const nameMap: Record<string, string> = {};
|
||||
krw.forEach(m => { nameMap[m.market] = m.korean_name; });
|
||||
setMarketNames(nameMap);
|
||||
return krw.map(m => ({
|
||||
market: m.market,
|
||||
koreanName: m.korean_name,
|
||||
englishName: m.english_name,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchAllTickers(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||
const results: UpbitTickerRaw[] = [];
|
||||
for (let i = 0; i < markets.length; i += 100) {
|
||||
const chunk = markets.slice(i, i + 100);
|
||||
try {
|
||||
const res = await fetch(`/upbit-api/v1/ticker?markets=${chunk.join(',')}`);
|
||||
if (res.ok) results.push(...(await res.json()));
|
||||
} catch { /* 부분 실패 무시 */ }
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
|
||||
return {
|
||||
market: getMarketCode(raw),
|
||||
koreanName,
|
||||
tradePrice: raw.trade_price ?? null,
|
||||
changeRate: raw.signed_change_rate ?? null,
|
||||
changePrice: raw.signed_change_price ?? null,
|
||||
accTradePrice24: raw.acc_trade_price_24h ?? 0,
|
||||
accTradeVolume24: raw.acc_trade_volume_24h ?? null,
|
||||
openingPrice: raw.opening_price ?? null,
|
||||
highPrice: raw.high_price ?? null,
|
||||
lowPrice: raw.low_price ?? null,
|
||||
change: toChangeDir(raw),
|
||||
};
|
||||
}
|
||||
|
||||
// ── WebSocket 연결 관리 ───────────────────────────────────────────────────
|
||||
function createTickerWs(
|
||||
markets: string[],
|
||||
onBatch: (rawList: UpbitTickerRaw[]) => void,
|
||||
): () => void {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
let ws: WebSocket | null = null;
|
||||
let destroyed = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 배치 버퍼: 너무 빈번한 state 업데이트 방지 (500ms 단위로 모아서 전달)
|
||||
const batch: Map<string, UpbitTickerRaw> = new Map();
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function flushBatch() {
|
||||
batchTimer = null;
|
||||
if (batch.size === 0) return;
|
||||
onBatch(Array.from(batch.values()));
|
||||
batch.clear();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return;
|
||||
ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
ws?.send(JSON.stringify([
|
||||
{ ticket: 'market-panel-ticker' },
|
||||
{ type: 'ticker', codes: markets },
|
||||
]));
|
||||
};
|
||||
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = typeof e.data === 'string'
|
||||
? e.data
|
||||
: decoder.decode(e.data as ArrayBuffer);
|
||||
const msg: UpbitTickerRaw & { type?: string } = JSON.parse(text);
|
||||
if (msg.type !== 'ticker' && !msg.code) return;
|
||||
const code = getMarketCode(msg);
|
||||
if (!code) return;
|
||||
batch.set(code, msg);
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
ws.onerror = () => { /* ignore – onclose will handle */ };
|
||||
ws.onclose = () => {
|
||||
if (!destroyed) {
|
||||
reconnectTimer = setTimeout(connect, 5_000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
if (batchTimer) { clearTimeout(batchTimer); flushBatch(); }
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
// ── 훅 ──────────────────────────────────────────────────────────────────
|
||||
export function useMarketTicker() {
|
||||
const [tickers, setTickers] = useState<Map<string, TickerData>>(new Map());
|
||||
const [marketInfos, setMarketInfos] = useState<MarketInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [usdRate, setUsdRate] = useState(1_300);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const marketListRef = useRef<string[]>([]);
|
||||
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
|
||||
|
||||
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
setTickers(prev => {
|
||||
const next = new Map(prev);
|
||||
rawList.forEach(raw => {
|
||||
const code = getMarketCode(raw);
|
||||
if (!code) return;
|
||||
const info = marketInfoMapRef.current.get(code);
|
||||
const name = info?.koreanName ?? next.get(code)?.koreanName ?? code.replace(/^KRW-/, '');
|
||||
next.set(code, rawToTicker(raw, name));
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
const usdt = rawList.find(t => getMarketCode(t) === 'KRW-USDT');
|
||||
if (usdt?.trade_price) setUsdRate(usdt.trade_price);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let destroyWs: (() => void) | null = null;
|
||||
|
||||
(async () => {
|
||||
// 1) 마켓 목록
|
||||
let infos: MarketInfo[] = [];
|
||||
try { infos = await fetchAllMarkets(); }
|
||||
catch { /* 실패 시 빈 목록 */ }
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
infos.forEach(m => marketInfoMapRef.current.set(m.market, m));
|
||||
setMarketInfos(infos);
|
||||
|
||||
const markets = infos.map(m => m.market);
|
||||
if (!markets.includes('KRW-USDT')) markets.push('KRW-USDT');
|
||||
marketListRef.current = markets;
|
||||
|
||||
// 2) 초기 시세 (REST)
|
||||
try {
|
||||
const rawList = await fetchAllTickers(markets);
|
||||
if (mountedRef.current) applyRawList(rawList);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(false);
|
||||
|
||||
// 3) WebSocket 실시간 구독
|
||||
destroyWs = createTickerWs(markets, applyRawList);
|
||||
|
||||
// 4) REST 보조 폴링 (30초 – WebSocket 누락 보완)
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const rawList = await fetchAllTickers(marketListRef.current);
|
||||
applyRawList(rawList);
|
||||
} catch { /* ignore */ }
|
||||
}, REST_POLL_INTERVAL);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
destroyWs?.();
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
};
|
||||
}, [applyRawList]);
|
||||
|
||||
return { tickers, marketInfos, loading, usdRate };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** CSS 미디어 쿼리 매칭 여부 (모바일·태블릿 분기용) */
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query);
|
||||
const onChange = () => setMatches(mq.matches);
|
||||
onChange();
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** 차트 메인 레이아웃 모바일 분기 (≤768px) */
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 768px)');
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { fetchMenuPermissions } from '../utils/backendApi';
|
||||
import {
|
||||
type MenuPermissionsDto,
|
||||
type UserRole,
|
||||
canAccessMenu,
|
||||
normalizeRole,
|
||||
} from '../utils/permissions';
|
||||
|
||||
let _cache: MenuPermissionsDto | null = null;
|
||||
let _promise: Promise<MenuPermissionsDto> | null = null;
|
||||
|
||||
export function invalidateMenuPermissionsCache() {
|
||||
_cache = null;
|
||||
_promise = null;
|
||||
}
|
||||
|
||||
function loadPermissions(): Promise<MenuPermissionsDto> {
|
||||
if (_cache) return Promise.resolve(_cache);
|
||||
if (_promise) return _promise;
|
||||
_promise = fetchMenuPermissions().then(data => {
|
||||
const normalized: MenuPermissionsDto = {
|
||||
role: normalizeRole(data.role),
|
||||
permissions: data.permissions ?? {},
|
||||
};
|
||||
_cache = normalized;
|
||||
_promise = null;
|
||||
return normalized;
|
||||
}).catch(err => {
|
||||
console.error('[useMenuPermissions] load failed', err);
|
||||
_promise = null;
|
||||
return { role: 'GUEST' as UserRole, permissions: { chart: true, settings: true } };
|
||||
});
|
||||
return _promise;
|
||||
}
|
||||
|
||||
export function useMenuPermissions(sessionKey = 0) {
|
||||
const [data, setData] = useState<MenuPermissionsDto>(_cache ?? { role: 'GUEST', permissions: {} });
|
||||
const [loaded, setLoaded] = useState(_cache !== null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
invalidateMenuPermissionsCache();
|
||||
loadPermissions().then(d => {
|
||||
setData(d);
|
||||
setLoaded(true);
|
||||
});
|
||||
}, [sessionKey]);
|
||||
|
||||
const can = useCallback(
|
||||
(menuId: string) => canAccessMenu(data.permissions, menuId),
|
||||
[data.permissions],
|
||||
);
|
||||
|
||||
return { role: data.role, permissions: data.permissions, isLoaded: loaded, can };
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 백엔드 STOMP /sub/charts/{market}/{candleType} 실시간 캔들 (Blueprint 중앙 집중 경로)
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
import { parseStompJson } from '../utils/stompMessage';
|
||||
import { subscribeStompTopic } from '../utils/stompChartBroker';
|
||||
import { timeframeToCandleType } from '../utils/chartCandleType';
|
||||
import { getCandleStart, TF_SECONDS } from '../utils/upbitApi';
|
||||
import { DISPLAY_COUNT, WARMUP_COUNT } from './useUpbitData';
|
||||
import type { WsStatus } from './useUpbitData';
|
||||
import { API_BASE } from '../utils/backendApi';
|
||||
|
||||
/** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */
|
||||
const STOMP_TICK_CANDLE_TYPE = '1m';
|
||||
|
||||
/** 표시 분봉 버킷 — 히스토리 마지막 봉과 STOMP time 불일치 시 마지막 봉 갱신용 */
|
||||
function resolveChartBarTime(
|
||||
bucketTime: number,
|
||||
displayTf: Timeframe,
|
||||
lastChartTime: number | undefined,
|
||||
): number {
|
||||
if (lastChartTime == null || bucketTime >= lastChartTime) return bucketTime;
|
||||
const tfSec = TF_SECONDS[displayTf] ?? 60;
|
||||
if (lastChartTime - bucketTime < tfSec) return lastChartTime;
|
||||
return bucketTime;
|
||||
}
|
||||
|
||||
async function pinChartWatch(market: string, candleType: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
/* watch 실패해도 STOMP 구독은 계속 */
|
||||
}
|
||||
}
|
||||
|
||||
interface StompCandlePayload {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
onTickUpdate: (bar: OHLCVBar) => void;
|
||||
onNewCandle: (bar: OHLCVBar) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface Return {
|
||||
bars: OHLCVBar[];
|
||||
latestBar: OHLCVBar | null;
|
||||
wsStatus: WsStatus;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function fetchInitialBars(market: string, candleType: string, count: number): Promise<OHLCVBar[]> {
|
||||
const params = new URLSearchParams({
|
||||
market,
|
||||
type: candleType,
|
||||
count: String(count),
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/candles/history?${params}`);
|
||||
if (!res.ok) throw new Error(`history ${res.status}`);
|
||||
const data = (await res.json()) as StompCandlePayload[];
|
||||
return data.map(b => ({
|
||||
time: b.time,
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
volume: b.volume,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchInitialBarsWithRetry(
|
||||
market: string,
|
||||
candleType: string,
|
||||
count: number,
|
||||
attempts = 3,
|
||||
): Promise<OHLCVBar[]> {
|
||||
let lastErr: Error | null = null;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await fetchInitialBars(market, candleType, count);
|
||||
} catch (e) {
|
||||
lastErr = e as Error;
|
||||
if (i < attempts - 1) {
|
||||
await new Promise(r => setTimeout(r, 400 * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error('history fetch failed');
|
||||
}
|
||||
|
||||
export function useStompChartData(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
opts: Options,
|
||||
): Return {
|
||||
const candleType = timeframeToCandleType(timeframe);
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
|
||||
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const barsRef = useRef<OHLCVBar[]>([]);
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
/** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */
|
||||
const last1mVolRef = useRef<number>(0);
|
||||
const last1mTimeRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (opts.enabled === false) return;
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setBars([]);
|
||||
barsRef.current = [];
|
||||
last1mVolRef.current = 0;
|
||||
last1mTimeRef.current = 0;
|
||||
lastTimeRef.current = 0;
|
||||
|
||||
const count = DISPLAY_COUNT + WARMUP_COUNT;
|
||||
fetchInitialBarsWithRetry(market, candleType, count)
|
||||
.then(newBars => {
|
||||
if (cancelled) return;
|
||||
barsRef.current = newBars;
|
||||
setBars(newBars);
|
||||
setLatestBar(newBars[newBars.length - 1] ?? null);
|
||||
lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0;
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setError(`STOMP 초기 로딩 실패: ${(err as Error).message}`);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, candleType, opts.enabled]);
|
||||
|
||||
const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => {
|
||||
const oneMin: OHLCVBar = {
|
||||
time: data.time,
|
||||
open: data.open,
|
||||
high: data.high,
|
||||
low: data.low,
|
||||
close: data.close,
|
||||
volume: data.volume,
|
||||
};
|
||||
|
||||
const pushBar = (bar: OHLCVBar) => {
|
||||
const current = barsRef.current;
|
||||
if (current.length === 0) {
|
||||
barsRef.current = [bar];
|
||||
setLatestBar(bar);
|
||||
optsRef.current.onNewCandle(bar);
|
||||
lastTimeRef.current = bar.time;
|
||||
return;
|
||||
}
|
||||
const last = current[current.length - 1];
|
||||
if (bar.time === last.time) {
|
||||
barsRef.current[current.length - 1] = bar;
|
||||
setLatestBar(bar);
|
||||
optsRef.current.onTickUpdate(bar);
|
||||
} else if (bar.time > last.time) {
|
||||
barsRef.current = [...current, bar];
|
||||
setLatestBar(bar);
|
||||
optsRef.current.onNewCandle(bar);
|
||||
} else {
|
||||
const patched = { ...bar, time: last.time };
|
||||
barsRef.current[current.length - 1] = patched;
|
||||
setLatestBar(patched);
|
||||
optsRef.current.onTickUpdate(patched);
|
||||
}
|
||||
lastTimeRef.current = bar.time;
|
||||
};
|
||||
|
||||
const lastChart = barsRef.current[barsRef.current.length - 1];
|
||||
|
||||
// 1m 차트: STOMP 페이로드를 그대로 사용
|
||||
if (displayTf === '1m') {
|
||||
const t = resolveChartBarTime(oneMin.time, '1m', lastChart?.time);
|
||||
pushBar({ ...oneMin, time: t });
|
||||
return;
|
||||
}
|
||||
|
||||
// 상위 분봉: 1m 틱을 useUpbitData 와 동일하게 집계
|
||||
const candleStart = resolveChartBarTime(
|
||||
getCandleStart(oneMin.time * 1000, displayTf),
|
||||
displayTf,
|
||||
lastChart?.time,
|
||||
);
|
||||
const current = barsRef.current;
|
||||
if (current.length === 0) {
|
||||
pushBar({
|
||||
time: candleStart,
|
||||
open: oneMin.open,
|
||||
high: oneMin.high,
|
||||
low: oneMin.low,
|
||||
close: oneMin.close,
|
||||
volume: oneMin.volume,
|
||||
});
|
||||
last1mTimeRef.current = oneMin.time;
|
||||
last1mVolRef.current = oneMin.volume;
|
||||
return;
|
||||
}
|
||||
|
||||
let volDelta = oneMin.volume;
|
||||
if (oneMin.time === last1mTimeRef.current) {
|
||||
volDelta = oneMin.volume - last1mVolRef.current;
|
||||
} else {
|
||||
last1mTimeRef.current = oneMin.time;
|
||||
}
|
||||
last1mVolRef.current = oneMin.volume;
|
||||
|
||||
const last = current[current.length - 1];
|
||||
if (last.time === candleStart) {
|
||||
const updated: OHLCVBar = {
|
||||
time: candleStart,
|
||||
open: last.open,
|
||||
high: Math.max(last.high, oneMin.high),
|
||||
low: Math.min(last.low, oneMin.low),
|
||||
close: oneMin.close,
|
||||
volume: Math.max(0, last.volume + volDelta),
|
||||
};
|
||||
pushBar(updated);
|
||||
} else if (candleStart > last.time) {
|
||||
pushBar({
|
||||
time: candleStart,
|
||||
open: oneMin.open,
|
||||
high: oneMin.high,
|
||||
low: oneMin.low,
|
||||
close: oneMin.close,
|
||||
volume: oneMin.volume,
|
||||
});
|
||||
last1mTimeRef.current = oneMin.time;
|
||||
last1mVolRef.current = oneMin.volume;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (opts.enabled === false) {
|
||||
setWsStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
setWsStatus('connecting');
|
||||
setError(null);
|
||||
last1mVolRef.current = 0;
|
||||
last1mTimeRef.current = 0;
|
||||
void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE);
|
||||
|
||||
const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`;
|
||||
const unsub = subscribeStompTopic(topic, msg => {
|
||||
setWsStatus('connected');
|
||||
const data = parseStompJson<StompCandlePayload>(msg);
|
||||
if (data?.time == null || typeof data.time !== 'number') return;
|
||||
applyCandle(data, timeframe);
|
||||
});
|
||||
|
||||
const t = window.setTimeout(() => setWsStatus('connected'), 800);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
unsub();
|
||||
setWsStatus('disconnected');
|
||||
};
|
||||
}, [market, timeframe, opts.enabled, applyCandle]);
|
||||
|
||||
return { bars, latestBar, wsStatus, isLoading, error };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
type TradeAlertPopupLayout,
|
||||
type TradeAlertPopupPosition,
|
||||
normalizeTradeAlertGridCols,
|
||||
} from '../utils/tradeAlertPopupLayout';
|
||||
|
||||
export interface ToastPageCapacity {
|
||||
pageSize: number;
|
||||
effectiveCols: number;
|
||||
effectiveRows: number;
|
||||
}
|
||||
|
||||
const MENUBAR_H = 52;
|
||||
const EDGE_PAD = 32;
|
||||
const TOOLBAR_RESERVE = 88;
|
||||
const CARD_GAP = 10;
|
||||
/** 우측 ‹ › 슬라이드 버튼 + 간격 */
|
||||
const NAV_BTN_RESERVE = 80;
|
||||
|
||||
const CARD = {
|
||||
full: { w: 380, h: 158 },
|
||||
compact:{ w: 200, h: 86 },
|
||||
strip: { w: 280, h: 86 },
|
||||
} as const;
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
export function computeToastPageCapacity(
|
||||
layout: TradeAlertPopupLayout,
|
||||
position: TradeAlertPopupPosition,
|
||||
gridCols: number,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): ToastPageCapacity {
|
||||
const availW = position === 'bottom'
|
||||
? viewportW - EDGE_PAD - NAV_BTN_RESERVE
|
||||
: Math.min(420, viewportW - EDGE_PAD - NAV_BTN_RESERVE);
|
||||
const availH = position === 'bottom'
|
||||
? Math.min(viewportH * 0.52, 440) - TOOLBAR_RESERVE
|
||||
: viewportH - MENUBAR_H - EDGE_PAD - TOOLBAR_RESERVE;
|
||||
|
||||
if (layout === 'single') {
|
||||
return { pageSize: 1, effectiveCols: 1, effectiveRows: 1 };
|
||||
}
|
||||
|
||||
if (layout === 'strip') {
|
||||
const cols = clamp(
|
||||
Math.floor((availW + CARD_GAP) / (CARD.strip.w + CARD_GAP)),
|
||||
1,
|
||||
8,
|
||||
);
|
||||
return { pageSize: cols, effectiveCols: cols, effectiveRows: 1 };
|
||||
}
|
||||
|
||||
if (layout === 'grid') {
|
||||
const configured = normalizeTradeAlertGridCols(gridCols);
|
||||
const maxCols = clamp(
|
||||
Math.floor((availW + CARD_GAP) / (CARD.compact.w + CARD_GAP)),
|
||||
1,
|
||||
4,
|
||||
);
|
||||
const cols = Math.min(configured, maxCols);
|
||||
const rows = clamp(
|
||||
Math.floor((availH + CARD_GAP) / (CARD.compact.h + CARD_GAP)),
|
||||
1,
|
||||
6,
|
||||
);
|
||||
return {
|
||||
pageSize: clamp(cols * rows, 1, 24),
|
||||
effectiveCols: cols,
|
||||
effectiveRows: rows,
|
||||
};
|
||||
}
|
||||
|
||||
// stack
|
||||
const rows = clamp(
|
||||
Math.floor((availH + CARD_GAP) / (CARD.full.h + CARD_GAP)),
|
||||
1,
|
||||
8,
|
||||
);
|
||||
return { pageSize: rows, effectiveCols: 1, effectiveRows: rows };
|
||||
}
|
||||
|
||||
export function useToastPageCapacity(
|
||||
layout: TradeAlertPopupLayout,
|
||||
position: TradeAlertPopupPosition,
|
||||
gridCols: number,
|
||||
): ToastPageCapacity {
|
||||
const [capacity, setCapacity] = useState<ToastPageCapacity>(() =>
|
||||
computeToastPageCapacity(
|
||||
layout,
|
||||
position,
|
||||
gridCols,
|
||||
typeof window !== 'undefined' ? window.innerWidth : 1280,
|
||||
typeof window !== 'undefined' ? window.innerHeight : 800,
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setCapacity(computeToastPageCapacity(
|
||||
layout,
|
||||
position,
|
||||
gridCols,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
));
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [layout, position, gridCols]);
|
||||
|
||||
return capacity;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
import {
|
||||
UpbitWebSocketClient,
|
||||
getCandleStart,
|
||||
type UpbitTrade,
|
||||
type UpbitMessage,
|
||||
} from '../utils/upbitApi';
|
||||
import { OBV_DEEP_HISTORY } from '../utils/upbitApi';
|
||||
import { fetchUpbitCandlesCached, invalidateMarketCache } from '../utils/requestCache';
|
||||
|
||||
export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
|
||||
interface UpbitDataOptions {
|
||||
/**
|
||||
* 같은 캔들 내 틱 수신 시 콜백.
|
||||
* React state 변경 없이 차트를 직접 업데이트한다.
|
||||
*/
|
||||
onTickUpdate: (bar: OHLCVBar) => void;
|
||||
/**
|
||||
* 새 캔들이 시작될 때 콜백 (완성된 신규 bar 전달).
|
||||
* React state 변경 없이 차트에 바를 추가한다.
|
||||
*/
|
||||
onNewCandle: (newBar: OHLCVBar) => void;
|
||||
/** false면 REST/WebSocket 모두 비활성화 (시뮬레이션 모드용) */
|
||||
enabled?: boolean;
|
||||
/** OBV 등 누적 지표: 상장 이력에 가깝게 장기 캔들 로드 */
|
||||
deepObvHistory?: boolean;
|
||||
}
|
||||
|
||||
interface UseUpbitDataReturn {
|
||||
/** 초기 200개 캔들 (심볼/타임프레임 변경 시에만 갱신) */
|
||||
bars: OHLCVBar[];
|
||||
/** 현재 진행 중인 캔들의 최신 스냅샷 (UI 표시용) */
|
||||
latestBar: OHLCVBar | null;
|
||||
wsStatus: WsStatus;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 화면에 표시할 캔들 수 */
|
||||
export const DISPLAY_COUNT = 200;
|
||||
/**
|
||||
* 지표 워밍업 기간 (안전 마진 포함)
|
||||
* - 일목균형표 Span B: 52바
|
||||
* - OBV 누적: 과거 봉이 많을수록 업비트 절대값에 근접 → 600바 워밍업
|
||||
*/
|
||||
export const WARMUP_COUNT = 600;
|
||||
/** OBV 사용 시 초기 페치 봉 수 */
|
||||
export { OBV_DEEP_HISTORY };
|
||||
|
||||
export function useUpbitData(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
opts: UpbitDataOptions,
|
||||
): UseUpbitDataReturn {
|
||||
// bars = 초기 FETCH_COUNT개 (REST), 실시간 수신 시 변경 안 함 → TradingChart setData 재호출 방지
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
|
||||
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const barsRef = useRef<OHLCVBar[]>([]);
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
|
||||
// ── 1. REST API: 초기 캔들 로딩 (표시용 + 워밍업 합산) ─────────────────
|
||||
// 이전 market 값을 기억해 종목 변경 시에만 캐시를 무효화한다.
|
||||
const prevMarketRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opts.enabled === false) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setBars([]);
|
||||
setLatestBar(null);
|
||||
barsRef.current = [];
|
||||
|
||||
// 종목(market)이 변경된 경우에는 캐시를 무효화해 신규 요청을 강제한다.
|
||||
// 타임프레임만 바뀐 경우도 캐시 키가 다르므로 자동으로 새 요청이 발생한다.
|
||||
const marketChanged = prevMarketRef.current !== '' && prevMarketRef.current !== market;
|
||||
prevMarketRef.current = market;
|
||||
|
||||
const fetchCount = opts.deepObvHistory
|
||||
? OBV_DEEP_HISTORY
|
||||
: DISPLAY_COUNT + WARMUP_COUNT;
|
||||
|
||||
fetchUpbitCandlesCached(market, timeframe, fetchCount, marketChanged)
|
||||
.then(newBars => {
|
||||
if (cancelled) return;
|
||||
barsRef.current = newBars;
|
||||
setBars(newBars);
|
||||
setLatestBar(newBars[newBars.length - 1] ?? null);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setError(`데이터 로딩 실패: ${(err as Error).message}`);
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe, opts.enabled, opts.deepObvHistory]);
|
||||
|
||||
// ── 2. WebSocket: 실시간 체결 구독 ────────────────────────────────────
|
||||
const handleMessage = useCallback((msg: UpbitMessage) => {
|
||||
if (msg.type !== 'trade') return;
|
||||
const trade = msg as UpbitTrade;
|
||||
|
||||
const currentBars = barsRef.current;
|
||||
if (currentBars.length === 0) return;
|
||||
|
||||
const candleStart = getCandleStart(trade.trade_timestamp, timeframe);
|
||||
const lastBar = currentBars[currentBars.length - 1];
|
||||
|
||||
if (lastBar.time === candleStart) {
|
||||
// ── 동일 캔들: close/high/low/volume 갱신 ──────────────────────────
|
||||
const updated: OHLCVBar = {
|
||||
...lastBar,
|
||||
close: trade.trade_price,
|
||||
high: Math.max(lastBar.high, trade.trade_price),
|
||||
low: Math.min(lastBar.low, trade.trade_price),
|
||||
volume: lastBar.volume + trade.trade_volume,
|
||||
};
|
||||
barsRef.current[currentBars.length - 1] = updated;
|
||||
|
||||
// UI 표시용 latestBar만 갱신 (bars state는 변경하지 않음)
|
||||
setLatestBar(updated);
|
||||
|
||||
// 차트 직접 업데이트 (React re-render 없음)
|
||||
optsRef.current.onTickUpdate(updated);
|
||||
|
||||
} else if (candleStart > lastBar.time) {
|
||||
// ── 새 캔들: barsRef에만 추가, bars state는 건드리지 않음 ──────────
|
||||
const newBar: OHLCVBar = {
|
||||
time: candleStart,
|
||||
open: trade.trade_price,
|
||||
high: trade.trade_price,
|
||||
low: trade.trade_price,
|
||||
close: trade.trade_price,
|
||||
volume: trade.trade_volume,
|
||||
};
|
||||
barsRef.current = [...barsRef.current, newBar];
|
||||
|
||||
setLatestBar(newBar);
|
||||
|
||||
// 차트에 새 바만 추가 + 인디케이터 last point 업데이트 (React re-render 없음)
|
||||
optsRef.current.onNewCandle(newBar);
|
||||
}
|
||||
}, [timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opts.enabled === false) return;
|
||||
|
||||
setWsStatus('connecting');
|
||||
setError(null); // 재연결 시 기존 오류 초기화
|
||||
|
||||
const client = new UpbitWebSocketClient({
|
||||
market,
|
||||
onMessage: handleMessage,
|
||||
onConnect: () => { setWsStatus('connected'); setError(null); },
|
||||
onDisconnect: () => setWsStatus('disconnected'),
|
||||
// 연결 끊김은 자동 재연결되므로 에러 표시만 (상태는 disconnected 유지)
|
||||
onError: (e) => { console.warn('[UpbitWS]', e); },
|
||||
});
|
||||
return () => client.destroy();
|
||||
}, [market, timeframe, handleMessage, opts.enabled]);
|
||||
|
||||
return { bars, latestBar, wsStatus, isLoading, error };
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 업비트 실시간 호가 훅
|
||||
*
|
||||
* frontend_golden/src/hooks/useUpbitOrderbook.ts 를 현재 프로젝트에 맞게 변환:
|
||||
* - WebSocket: /upbit-ws (nginx 프록시 → wss://api.upbit.com/websocket/v1)
|
||||
* - REST: /upbit-api/v1/orderbook
|
||||
* - console.log 제거, 재연결 로직 강화
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
|
||||
// ── 타입 ────────────────────────────────────────────────────────────────────
|
||||
export interface OrderbookUnit {
|
||||
ask_price: number;
|
||||
bid_price: number;
|
||||
ask_size: number;
|
||||
bid_size: number;
|
||||
}
|
||||
|
||||
export interface OrderbookDisplayUnit {
|
||||
price: number;
|
||||
size: number;
|
||||
percentage: number; // 최대 잔량 대비 비율 (0–100), 배경 바 넓이
|
||||
type: 'ask' | 'bid';
|
||||
}
|
||||
|
||||
export interface OrderbookState {
|
||||
asks: OrderbookDisplayUnit[];
|
||||
bids: OrderbookDisplayUnit[];
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
timestamp: number;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
|
||||
interface UpbitObWsMessage {
|
||||
type: string;
|
||||
code: string;
|
||||
total_ask_size: number;
|
||||
total_bid_size: number;
|
||||
orderbook_units: OrderbookUnit[];
|
||||
timestamp: number;
|
||||
stream_type?: string;
|
||||
}
|
||||
|
||||
// ── 상수 ────────────────────────────────────────────────────────────────────
|
||||
const RECONNECT_DELAY = 3_000;
|
||||
const INITIAL_STATE: OrderbookState = {
|
||||
asks: [], bids: [], totalAskSize: 0, totalBidSize: 0, timestamp: 0, connected: false,
|
||||
};
|
||||
|
||||
// ── 훅 ──────────────────────────────────────────────────────────────────────
|
||||
export function useUpbitOrderbook(market: string) {
|
||||
const [orderbook, setOrderbook] = useState<OrderbookState>(INITIAL_STATE);
|
||||
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
// ── 데이터 파싱 → state ─────────────────────────────────────────────────
|
||||
const processData = useCallback((msg: UpbitObWsMessage) => {
|
||||
const units = msg.orderbook_units;
|
||||
if (!units || units.length === 0) return;
|
||||
|
||||
const maxSize = Math.max(
|
||||
...units.map(u => u.ask_size),
|
||||
...units.map(u => u.bid_size),
|
||||
);
|
||||
|
||||
// 매도: 높은 가격 → 낮은 가격 (역순)
|
||||
const asks: OrderbookDisplayUnit[] = units
|
||||
.map(u => ({
|
||||
price: u.ask_price,
|
||||
size: u.ask_size,
|
||||
percentage: maxSize > 0 ? (u.ask_size / maxSize) * 100 : 0,
|
||||
type: 'ask' as const,
|
||||
}))
|
||||
.reverse();
|
||||
|
||||
// 매수: 높은 가격 → 낮은 가격 (정순 – 1번 unit이 best bid)
|
||||
const bids: OrderbookDisplayUnit[] = units.map(u => ({
|
||||
price: u.bid_price,
|
||||
size: u.bid_size,
|
||||
percentage: maxSize > 0 ? (u.bid_size / maxSize) * 100 : 0,
|
||||
type: 'bid' as const,
|
||||
}));
|
||||
|
||||
setOrderbook({
|
||||
asks,
|
||||
bids,
|
||||
totalAskSize: msg.total_ask_size,
|
||||
totalBidSize: msg.total_bid_size,
|
||||
timestamp: msg.timestamp,
|
||||
connected: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── REST 초기 스냅샷 ────────────────────────────────────────────────────
|
||||
const fetchSnapshot = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/upbit-api/v1/orderbook?markets=${market}`);
|
||||
if (!res.ok) return;
|
||||
const data: Array<{
|
||||
market: string;
|
||||
timestamp: number;
|
||||
total_ask_size: number;
|
||||
total_bid_size: number;
|
||||
orderbook_units: OrderbookUnit[];
|
||||
}> = await res.json();
|
||||
if (data && data[0]) {
|
||||
const d = data[0];
|
||||
processData({
|
||||
type: 'orderbook',
|
||||
code: d.market,
|
||||
total_ask_size: d.total_ask_size,
|
||||
total_bid_size: d.total_bid_size,
|
||||
orderbook_units: d.orderbook_units,
|
||||
timestamp: d.timestamp,
|
||||
stream_type: 'SNAPSHOT',
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [market, processData]);
|
||||
|
||||
// ── WebSocket 연결 ──────────────────────────────────────────────────────
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
if (isConnectingRef.current) return;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
isConnectingRef.current = true;
|
||||
setWsStatus('connecting');
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) { ws.close(); return; }
|
||||
isConnectingRef.current = false;
|
||||
setWsStatus('connected');
|
||||
ws.send(JSON.stringify([
|
||||
{ ticket: `ob-${market}-${Date.now()}` },
|
||||
{ type: 'orderbook', codes: [market] },
|
||||
]));
|
||||
};
|
||||
|
||||
ws.onmessage = async (e: MessageEvent) => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
let text: string;
|
||||
if (typeof e.data === 'string') {
|
||||
text = e.data;
|
||||
} else if (e.data instanceof ArrayBuffer) {
|
||||
text = new TextDecoder('utf-8').decode(e.data);
|
||||
} else if (e.data instanceof Blob) {
|
||||
text = await (e.data as Blob).text();
|
||||
} else return;
|
||||
|
||||
const msg: UpbitObWsMessage = JSON.parse(text);
|
||||
if (msg.type === 'orderbook' && msg.code === market) {
|
||||
processData(msg);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
isConnectingRef.current = false;
|
||||
if (mountedRef.current) setWsStatus('error');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
isConnectingRef.current = false;
|
||||
if (!mountedRef.current) return;
|
||||
setWsStatus('disconnected');
|
||||
setOrderbook(prev => ({ ...prev, connected: false }));
|
||||
// 자동 재연결
|
||||
reconnTimerRef.current = setTimeout(connect, RECONNECT_DELAY);
|
||||
};
|
||||
}, [market, processData]);
|
||||
|
||||
// ── 연결 해제 ────────────────────────────────────────────────────────────
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnTimerRef.current) { clearTimeout(reconnTimerRef.current); reconnTimerRef.current = null; }
|
||||
if (wsRef.current) {
|
||||
const ws = wsRef.current;
|
||||
ws.onopen = null; ws.onmessage = null; ws.onerror = null; ws.onclose = null;
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
isConnectingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// ── 라이프사이클 ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
setOrderbook(INITIAL_STATE);
|
||||
fetchSnapshot();
|
||||
connect();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
disconnect();
|
||||
};
|
||||
}, [market]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── 스프레드 계산 ────────────────────────────────────────────────────────
|
||||
const spread = useMemo(() => {
|
||||
const { asks, bids } = orderbook;
|
||||
if (!asks.length || !bids.length) return { bestAsk: 0, bestBid: 0, spread: 0, pct: 0 };
|
||||
const bestAsk = asks[asks.length - 1].price; // asks 배열 마지막 = 가장 낮은 매도가
|
||||
const bestBid = bids[0].price; // bids 배열 첫번째 = 가장 높은 매수가
|
||||
const diff = bestAsk - bestBid;
|
||||
return { bestAsk, bestBid, spread: diff, pct: bestBid > 0 ? (diff / bestBid) * 100 : 0 };
|
||||
}, [orderbook.asks, orderbook.bids]);
|
||||
|
||||
return { orderbook, wsStatus, spread };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 업비트 최근 체결 (호가창 좌측 체결 목록용)
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface RecentTrade {
|
||||
price: number;
|
||||
volume: number;
|
||||
side: 'ask' | 'bid'; // ask=매도체결, bid=매수체결
|
||||
time: number;
|
||||
}
|
||||
|
||||
const MAX_TRADES = 12;
|
||||
|
||||
interface UpbitTradeRaw {
|
||||
market: string;
|
||||
trade_price: number;
|
||||
trade_volume: number;
|
||||
ask_bid: 'ASK' | 'BID';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface UpbitTradeWs {
|
||||
type: string;
|
||||
code: string;
|
||||
trade_price: number;
|
||||
trade_volume: number;
|
||||
ask_bid: 'ASK' | 'BID';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function useUpbitRecentTrades(market: string) {
|
||||
const [trades, setTrades] = useState<RecentTrade[]>([]);
|
||||
const [strength, setStrength] = useState<number | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const pushTrade = useCallback((t: RecentTrade) => {
|
||||
setTrades(prev => {
|
||||
const next = [t, ...prev].slice(0, MAX_TRADES);
|
||||
const buyVol = next.filter(x => x.side === 'bid').reduce((s, x) => s + x.volume, 0);
|
||||
const sellVol = next.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
|
||||
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
|
||||
else if (buyVol > 0) setStrength(100);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchSnapshot = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/upbit-api/v1/trades/ticks?market=${market}&count=${MAX_TRADES}`);
|
||||
if (!res.ok) return;
|
||||
const data: UpbitTradeRaw[] = await res.json();
|
||||
const list: RecentTrade[] = data.map(d => ({
|
||||
price: d.trade_price,
|
||||
volume: d.trade_volume,
|
||||
side: d.ask_bid === 'ASK' ? 'ask' : 'bid',
|
||||
time: d.timestamp,
|
||||
}));
|
||||
setTrades(list);
|
||||
const buyVol = list.filter(x => x.side === 'bid').reduce((s, x) => s + x.volume, 0);
|
||||
const sellVol = list.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
|
||||
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
|
||||
} catch { /* ignore */ }
|
||||
}, [market]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
setTrades([]);
|
||||
setStrength(null);
|
||||
fetchSnapshot();
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify([
|
||||
{ ticket: `tr-${market}-${Date.now()}` },
|
||||
{ type: 'trade', codes: [market] },
|
||||
]));
|
||||
};
|
||||
|
||||
ws.onmessage = async (e: MessageEvent) => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
let text: string;
|
||||
if (typeof e.data === 'string') text = e.data;
|
||||
else if (e.data instanceof ArrayBuffer) text = new TextDecoder().decode(e.data);
|
||||
else if (e.data instanceof Blob) text = await e.data.text();
|
||||
else return;
|
||||
const msg: UpbitTradeWs = JSON.parse(text);
|
||||
if (msg.type === 'trade' && msg.code === market) {
|
||||
pushTrade({
|
||||
price: msg.trade_price,
|
||||
volume: msg.trade_volume,
|
||||
side: msg.ask_bid === 'ASK' ? 'ask' : 'bid',
|
||||
time: msg.timestamp,
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [market, fetchSnapshot, pushTrade]);
|
||||
|
||||
return { trades, strength };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 차트 워크스페이스 설정을 DB(백엔드)에 자동 저장·복원하는 훅.
|
||||
*
|
||||
* 사용법:
|
||||
* const { isLoaded, saveNow } = useWorkspacePersist({ layoutId, syncOptions, slots, setLayoutId, setSlots });
|
||||
*
|
||||
* - 마운트 시 DB에서 설정을 로드하여 상태를 복원한다.
|
||||
* - 설정이 변경될 때마다 debounce(2초) 후 자동 저장한다.
|
||||
* - saveNow() 를 호출하면 즉시 저장한다.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
loadWorkspace,
|
||||
saveWorkspace,
|
||||
loadWatchlist,
|
||||
loadHoldings,
|
||||
WatchlistItem,
|
||||
HoldingsItem,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
export interface WorkspaceState {
|
||||
layoutId: string;
|
||||
syncOptions: unknown;
|
||||
slots: unknown[];
|
||||
}
|
||||
|
||||
interface UseWorkspacePersistOptions {
|
||||
/** 로그인/로그아웃 시 증가 → DB에서 워크스페이스 재로드 */
|
||||
sessionKey?: number;
|
||||
/** 현재 워크스페이스 상태 */
|
||||
state: WorkspaceState;
|
||||
/** DB 로드 후 상태를 반영하는 콜백 */
|
||||
onLoad: (data: WorkspaceState) => void;
|
||||
/** 관심종목 로드 콜백 */
|
||||
onWatchlistLoad?: (items: WatchlistItem[]) => void;
|
||||
/** 보유종목 로드 콜백 */
|
||||
onHoldingsLoad?: (items: HoldingsItem[]) => void;
|
||||
/** 자동 저장 debounce ms (기본 2000) */
|
||||
debounceMs?: number;
|
||||
/** 자동 저장 비활성화 */
|
||||
noAutoSave?: boolean;
|
||||
}
|
||||
|
||||
export function useWorkspacePersist({
|
||||
sessionKey = 0,
|
||||
state,
|
||||
onLoad,
|
||||
onWatchlistLoad,
|
||||
onHoldingsLoad,
|
||||
debounceMs = 2000,
|
||||
noAutoSave = false,
|
||||
}: UseWorkspacePersistOptions) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// ── 초기 로드 (세션 변경 시 재로드) ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoaded(false);
|
||||
isFirstRender.current = true;
|
||||
|
||||
(async () => {
|
||||
const [ws, watchlist, holdings] = await Promise.all([
|
||||
loadWorkspace(),
|
||||
onWatchlistLoad ? loadWatchlist() : Promise.resolve(null),
|
||||
onHoldingsLoad ? loadHoldings() : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (ws) {
|
||||
onLoad({
|
||||
layoutId: ws.layoutId,
|
||||
syncOptions: ws.syncOptions,
|
||||
slots: ws.slots,
|
||||
});
|
||||
}
|
||||
if (watchlist && onWatchlistLoad) onWatchlistLoad(watchlist);
|
||||
if (holdings && onHoldingsLoad) onHoldingsLoad(holdings);
|
||||
|
||||
setIsLoaded(true);
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionKey]);
|
||||
|
||||
// ── 자동 저장 (debounce) ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isLoaded || noAutoSave) return;
|
||||
if (isFirstRender.current) { isFirstRender.current = false; return; }
|
||||
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveWorkspace(state as Parameters<typeof saveWorkspace>[0]);
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
}, [state, isLoaded, noAutoSave, debounceMs]);
|
||||
|
||||
// ── 즉시 저장 ──────────────────────────────────────────────────────────────
|
||||
const saveNow = useCallback(() => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
return saveWorkspace(state as Parameters<typeof saveWorkspace>[0]);
|
||||
}, [state]);
|
||||
|
||||
return { isLoaded, saveNow };
|
||||
}
|
||||
Reference in New Issue
Block a user