315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
/**
|
|
* useAppSettings
|
|
*
|
|
* 앱 전역 차트 기본 설정을 DB 와 동기화하는 훅.
|
|
*
|
|
* 관리 항목 (하드코딩 대체):
|
|
* - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol)
|
|
* - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe)
|
|
* - defaultChartType : 기본 차트 타입
|
|
* - (테마는 DB 미사용 — localStorage, see localThemeStorage)
|
|
* - defaultLogScale : 기본 로그 스케일
|
|
* - defaultLayoutId : 기본 레이아웃 ID
|
|
* - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE)
|
|
* - syncOptions : 멀티차트 동기화 옵션 (DEFAULT_SYNC)
|
|
*
|
|
* 사용 예:
|
|
* ```tsx
|
|
* const { settings, isLoaded, save } = useAppSettings();
|
|
*
|
|
* // 기본 심볼 사용
|
|
* const symbol = settings.defaultSymbol ?? 'KRW-BTC';
|
|
*
|
|
* // 테마 변경 시 saveLocalTheme() (App.tsx)
|
|
* ```
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
hasRegisteredUser,
|
|
loadAppSettings,
|
|
loadStrategies,
|
|
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_CHART_TIME_FORMAT,
|
|
normalizeChartTimeFormat,
|
|
setChartTimeFormat,
|
|
} from '../utils/chartTimeFormat';
|
|
import {
|
|
DEFAULT_TRADE_ALERT_TIME_FORMAT,
|
|
setTradeAlertTimeFormat,
|
|
} from '../utils/tradeAlertTimeFormat';
|
|
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
|
|
import {
|
|
clampVirtualTargetMax,
|
|
DEFAULT_VIRTUAL_TARGET_MAX,
|
|
} from '../utils/virtualTargetLimits';
|
|
import {
|
|
resolveTrendSearchAppSettings,
|
|
type TrendSearchAppSettings,
|
|
} from '../utils/trendSearchAppSettings';
|
|
import {
|
|
resolveChartPaneSeparatorOptions,
|
|
type ChartPaneSeparatorOptions,
|
|
} from '../types/chartPaneSeparator';
|
|
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
|
|
import { loadLocalTheme } from '../utils/localThemeStorage';
|
|
|
|
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
|
let _cache: AppSettingsDto | null = null;
|
|
let _loadPromise: Promise<AppSettingsDto> | null = null;
|
|
/** invalidate 이후 완료된 로드만 캐시에 반영 */
|
|
let _loadGeneration = 0;
|
|
type AppSettingsListener = () => void;
|
|
const _listeners = new Set<AppSettingsListener>();
|
|
|
|
function notifyAppSettingsListeners() {
|
|
_listeners.forEach(fn => fn());
|
|
}
|
|
|
|
/** 훅 외부에서 최신 캐시 읽기 (추세검색 설정 병합 등) */
|
|
export function getAppSettingsCache(): AppSettingsDto {
|
|
return _cache ?? {};
|
|
}
|
|
|
|
/** 낙관적 캐시 패치 — _cache 가 null 일 때도 다음 로드 전까지 임시 보관 */
|
|
export function patchAppSettingsCache(patch: Partial<AppSettingsDto>): void {
|
|
if (_cache !== null) {
|
|
Object.assign(_cache, patch);
|
|
}
|
|
// _cache 가 null 이면 현재 로드 중이므로 건너뜀 (로드 완료 후 DB 저장값으로 덮어씌워짐)
|
|
}
|
|
|
|
export function subscribeAppSettings(listener: AppSettingsListener): () => void {
|
|
_listeners.add(listener);
|
|
return () => _listeners.delete(listener);
|
|
}
|
|
|
|
function ensureLoaded(): Promise<AppSettingsDto> {
|
|
if (_cache !== null) return Promise.resolve(_cache);
|
|
if (_loadPromise) return _loadPromise;
|
|
const generation = _loadGeneration;
|
|
_loadPromise = loadAppSettings().then(async data => {
|
|
if (generation !== _loadGeneration) {
|
|
_loadPromise = null;
|
|
return _cache ?? {};
|
|
}
|
|
_cache = data ?? {};
|
|
const migrated = migrateLocalStorageToUiPreferences(_cache);
|
|
if (migrated?.changed) {
|
|
try {
|
|
const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences });
|
|
if (generation === _loadGeneration) {
|
|
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
|
|
}
|
|
} catch (e) {
|
|
console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e);
|
|
if (generation === _loadGeneration) {
|
|
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
|
|
}
|
|
}
|
|
}
|
|
if (hasRegisteredUser()) {
|
|
try {
|
|
await loadStrategies();
|
|
} catch (e) {
|
|
console.warn('[useAppSettings] strategies preload failed', e);
|
|
}
|
|
}
|
|
_loadPromise = null;
|
|
if (generation === _loadGeneration) {
|
|
notifyAppSettingsListeners();
|
|
}
|
|
// loadStrategies() 등 await 중 invalidateAppSettingsCache()가 호출되면
|
|
// _cache 가 null 로 초기화될 수 있으므로 ?? {} 로 null 반환 방지
|
|
return _cache ?? {};
|
|
});
|
|
return _loadPromise;
|
|
}
|
|
|
|
export function invalidateAppSettingsCache() {
|
|
_cache = null;
|
|
_loadPromise = null;
|
|
_loadGeneration += 1;
|
|
}
|
|
|
|
/** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */
|
|
export function reloadAppSettingsCache(): Promise<AppSettingsDto> {
|
|
invalidateAppSettingsCache();
|
|
return ensureLoaded();
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** 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: loadLocalTheme(),
|
|
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,
|
|
chartCandleAreaPriceLabels: s.chartCandleAreaPriceLabels ?? s.chartSeriesPriceLabels ?? true,
|
|
chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true,
|
|
chartVolumeVisible: s.chartVolumeVisible ?? true,
|
|
chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true,
|
|
chartCrosshairInfoVisible: s.chartCrosshairInfoVisible ?? true,
|
|
chartHoverToolbarVisible: s.chartHoverToolbarVisible ?? true,
|
|
chartLegendOptions: resolveChartLegendOptions(
|
|
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
|
|
),
|
|
chartPaneSeparator: resolveChartPaneSeparatorOptions(
|
|
s.chartPaneSeparator as Partial<ChartPaneSeparatorOptions> | null | undefined,
|
|
loadLocalTheme(),
|
|
),
|
|
tradeAlertPopup: s.tradeAlertPopup ?? true,
|
|
tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true,
|
|
tradeAlertSound: s.tradeAlertSound ?? 'bell',
|
|
tradeAlertPopupPosition: s.tradeAlertPopupPosition ?? 'right',
|
|
tradeAlertPopupLayout: s.tradeAlertPopupLayout ?? 'stack',
|
|
tradeAlertPopupGridCols: s.tradeAlertPopupGridCols ?? 2,
|
|
verificationIssueNotify: s.verificationIssueNotify ?? true,
|
|
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,
|
|
paperOrderFillMode: (s.paperOrderFillMode ?? 'LIMIT_ONLY') as 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL',
|
|
paperOrderAutoCancelPct: s.paperOrderAutoCancelPct ?? 1.0,
|
|
virtualTargetMaxCount: clampVirtualTargetMax(s.virtualTargetMaxCount ?? DEFAULT_VIRTUAL_TARGET_MAX),
|
|
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),
|
|
chartTimeFormat: normalizeChartTimeFormat(s.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT),
|
|
tradeAlertTimeFormat: normalizeChartTimeFormat(s.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT),
|
|
trendSearchSettings: resolveTrendSearchAppSettings(
|
|
s.trendSearchSettings as Partial<TrendSearchAppSettings> | null | undefined,
|
|
),
|
|
};
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */
|
|
export function useAppSettings(sessionKey = 0) {
|
|
const [settings, setSettings] = useState<AppSettingsDto>(_cache ?? {});
|
|
const [isLoaded, setIsLoaded] = useState(_cache !== null);
|
|
const mountedRef = useRef(true);
|
|
const prevSessionKeyRef = useRef(sessionKey);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
const sessionChanged = prevSessionKeyRef.current !== sessionKey;
|
|
prevSessionKeyRef.current = sessionKey;
|
|
|
|
if (sessionChanged) {
|
|
if (_cache !== null) {
|
|
setSettings(_cache);
|
|
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
|
setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
|
setTradeAlertTimeFormat(normalizeChartTimeFormat(_cache.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT));
|
|
setIsLoaded(true);
|
|
} else {
|
|
setIsLoaded(false);
|
|
invalidateAppSettingsCache();
|
|
}
|
|
} else if (_cache !== null) {
|
|
setSettings(_cache);
|
|
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
|
setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
|
setTradeAlertTimeFormat(normalizeChartTimeFormat(_cache.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT));
|
|
setIsLoaded(true);
|
|
} else {
|
|
setIsLoaded(false);
|
|
}
|
|
|
|
ensureLoaded().then(data => {
|
|
const safe = data ?? {};
|
|
if (mountedRef.current) {
|
|
setSettings(safe);
|
|
setDisplayTimezone(normalizeTimezone(safe.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
|
setChartTimeFormat(normalizeChartTimeFormat(safe.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
|
setTradeAlertTimeFormat(normalizeChartTimeFormat(safe.tradeAlertTimeFormat ?? DEFAULT_TRADE_ALERT_TIME_FORMAT));
|
|
setIsLoaded(true);
|
|
}
|
|
}).catch(err => {
|
|
console.error('[useAppSettings] load failed', err);
|
|
if (mountedRef.current) setIsLoaded(true);
|
|
});
|
|
const unsub = subscribeAppSettings(() => {
|
|
if (mountedRef.current) setSettings({ ...(getAppSettingsCache()) });
|
|
});
|
|
return () => {
|
|
mountedRef.current = false;
|
|
unsub();
|
|
};
|
|
}, [sessionKey]);
|
|
|
|
/**
|
|
* 특정 필드를 DB 에 저장하고 로컬 캐시를 업데이트.
|
|
* 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영.
|
|
*/
|
|
const save = useCallback((patch: AppSettingsDto) => {
|
|
const { defaultTheme: _omitTheme, ...patchWithoutTheme } = patch;
|
|
const apiPatch = 'defaultTheme' in patch ? patchWithoutTheme : patch;
|
|
const merged = { ...(_cache ?? {}), ...apiPatch };
|
|
_cache = merged;
|
|
notifyAppSettingsListeners();
|
|
setSettings(merged);
|
|
if (patch.displayTimezone != null) {
|
|
setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
|
|
}
|
|
if (patch.chartTimeFormat != null) {
|
|
setChartTimeFormat(normalizeChartTimeFormat(patch.chartTimeFormat));
|
|
}
|
|
if (patch.tradeAlertTimeFormat != null) {
|
|
setTradeAlertTimeFormat(normalizeChartTimeFormat(patch.tradeAlertTimeFormat));
|
|
}
|
|
saveAppSettings(apiPatch).then(updated => {
|
|
if (updated) {
|
|
_cache = { ...(_cache ?? {}), ...updated };
|
|
notifyAppSettingsListeners();
|
|
if (mountedRef.current) {
|
|
setSettings(prev => ({ ...prev, ...updated }));
|
|
}
|
|
}
|
|
}).catch(err =>
|
|
console.error('[useAppSettings] save failed', err)
|
|
);
|
|
}, []);
|
|
|
|
/** 로드된 설정의 해석된 기본값 */
|
|
const defaults = resolveAppDefaults(settings);
|
|
|
|
return { settings, defaults, isLoaded, save };
|
|
}
|
|
|
|
/** 앱 설정 캐시 기준 투자대상 최대 개수 (훅 외부·추가 API 등) */
|
|
export function getVirtualTargetMaxCount(): number {
|
|
const n = _cache?.virtualTargetMaxCount;
|
|
return clampVirtualTargetMax(n ?? DEFAULT_VIRTUAL_TARGET_MAX);
|
|
}
|