307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/**
|
|
* 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, useState } from 'react';
|
|
import { getIndicatorDef, mergePlotDefs, normalizeHLines, 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 type { IndicatorConfig } from '../types';
|
|
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;
|
|
notifyIndicatorSettingsChanged();
|
|
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;
|
|
notifyIndicatorSettingsChanged();
|
|
return _visualCache;
|
|
});
|
|
return _visualLoadPromise;
|
|
}
|
|
|
|
type SettingsListener = () => void;
|
|
const _settingsListeners = new Set<SettingsListener>();
|
|
|
|
function notifyIndicatorSettingsChanged() {
|
|
_settingsListeners.forEach(fn => fn());
|
|
}
|
|
|
|
/** 보조지표 설정 변경 구독 (전략편집기 DEF 동기화 등) */
|
|
export function subscribeIndicatorSettings(listener: SettingsListener): () => void {
|
|
_settingsListeners.add(listener);
|
|
return () => { _settingsListeners.delete(listener); };
|
|
}
|
|
|
|
/** 캐시를 강제로 무효화 (테스트 또는 로그아웃 시) */
|
|
export function invalidateIndicatorSettingsCache() {
|
|
_cache = null;
|
|
_loadPromise = null;
|
|
_visualCache = null;
|
|
_visualLoadPromise = null;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function useIndicatorSettings(sessionKey = 0) {
|
|
const [settingsRevision, setSettingsRevision] = useState(0);
|
|
|
|
// 로그인/로그아웃 시 지표 설정 재로드
|
|
useEffect(() => {
|
|
invalidateIndicatorSettingsCache();
|
|
const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1));
|
|
Promise.all([
|
|
ensureLoaded().catch(err =>
|
|
console.error('[useIndicatorSettings] params load failed', err),
|
|
),
|
|
ensureVisualLoaded().catch(err =>
|
|
console.error('[useIndicatorSettings] visual load failed', err),
|
|
),
|
|
]).then(() => setSettingsRevision(r => r + 1));
|
|
return unsub;
|
|
}, [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)
|
|
);
|
|
notifyIndicatorSettingsChanged();
|
|
},
|
|
[]
|
|
);
|
|
|
|
/** 현재 활성 지표 목록 전체를 DB에 저장 */
|
|
const saveAll = useCallback(
|
|
(allParams: AllSettings) => {
|
|
_cache = { ...allParams };
|
|
saveAllIndicatorSettings(allParams).catch(err =>
|
|
console.error('[useIndicatorSettings] saveAll failed', err)
|
|
);
|
|
notifyIndicatorSettingsChanged();
|
|
},
|
|
[]
|
|
);
|
|
|
|
/**
|
|
* 설정 화면 — 보조지표 기본값 일괄 저장 (파라미터 PUT + 시각 PATCH).
|
|
* 저장 완료 후 캐시 갱신 및 구독자(settingsRevision) 알림.
|
|
*/
|
|
const saveAllIndicatorDefaults = useCallback(
|
|
async (configs: Record<string, IndicatorConfig>) => {
|
|
const entries = Object.values(configs);
|
|
if (entries.length === 0) return;
|
|
|
|
const allParams: AllSettings = { ...(_cache ?? {}) };
|
|
for (const cfg of entries) {
|
|
allParams[cfg.type] = cfg.params as ParamMap;
|
|
}
|
|
|
|
await saveAllIndicatorSettings(allParams);
|
|
_cache = { ...allParams };
|
|
|
|
const visualEntries = entries.map(cfg => {
|
|
const visual: IndicatorVisual = {
|
|
plots: cfg.plots,
|
|
hlines: cfg.hlines,
|
|
};
|
|
if (cfg.cloudColors) visual.cloudColors = cfg.cloudColors;
|
|
return { type: cfg.type, visual };
|
|
});
|
|
|
|
await Promise.all(
|
|
visualEntries.map(({ type, visual }) =>
|
|
saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }),
|
|
),
|
|
);
|
|
|
|
const nextVisual: VisualCache = { ...(_visualCache ?? {}) };
|
|
for (const { type, visual } of visualEntries) {
|
|
nextVisual[type] = visual;
|
|
}
|
|
_visualCache = nextVisual;
|
|
|
|
notifyIndicatorSettingsChanged();
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ── 시각 설정 (색상·선굵기·수평선) ──────────────────────────────────────
|
|
|
|
/**
|
|
* 지표의 시각 설정을 반환.
|
|
* 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 registryHlines = defaultHlines ?? def?.hlines ?? [];
|
|
const rawHlines = (saved?.hlines as HLineDef[] | undefined) ?? registryHlines;
|
|
const hlines = normalizeHLines(rawHlines, type, registryHlines);
|
|
|
|
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)
|
|
);
|
|
notifyIndicatorSettingsChanged();
|
|
},
|
|
[]
|
|
);
|
|
|
|
return {
|
|
getParams,
|
|
saveParams,
|
|
saveAll,
|
|
saveAllIndicatorDefaults,
|
|
getVisualConfig,
|
|
saveVisual,
|
|
settingsRevision,
|
|
};
|
|
}
|