goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+159
View File
@@ -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 };
}
+100
View File
@@ -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 };
+111
View File
@@ -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 },
};
}
+181
View File
@@ -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 };
}
+228
View File
@@ -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 };
}
+261
View File
@@ -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 };
}
+24
View File
@@ -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)');
}
+56
View File
@@ -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 };
}
+280
View File
@@ -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 };
}
+118
View File
@@ -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;
}
+173
View File
@@ -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 };
}
+222
View File
@@ -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 };
}
+119
View File
@@ -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 };
}
+112
View File
@@ -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 };
}