보조지표 기준값 변경시 전략편집기 연동

This commit is contained in:
Macbook
2026-05-27 09:53:19 +09:00
parent 8876dbd752
commit cd5502d302
13 changed files with 288 additions and 110 deletions
@@ -5,7 +5,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import { buildMainIndicatorConfig } from '../utils/indicatorMainConfig';
import { indicatorTypesOnChart } from '../utils/indicatorMainConfig';
import { indicatorDefaultsFingerprint } from '../utils/indicatorDefaultsFingerprint';
import IndicatorSettingsForm from './IndicatorSettingsForm';
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
@@ -18,6 +18,13 @@ import IndicatorSettingsListSearch from './IndicatorSettingsListSearch';
import IndicatorSettingsListRow from './IndicatorSettingsListRow';
import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab';
export interface IndicatorSaveUiState {
save: () => Promise<void>;
dirty: boolean;
saving: boolean;
saveMessage: string | null;
}
export interface IndicatorMainDefaultsPanelProps {
chartIndicators: IndicatorConfig[];
onAddToChart: (type: string, config?: IndicatorConfig) => void;
@@ -35,32 +42,65 @@ export interface IndicatorMainDefaultsPanelProps {
hlines?: HLineDef[],
cloudColors?: IchimokuCloudColors,
) => void;
}
function persistDefaults(
updated: IndicatorConfig,
saveParams: IndicatorMainDefaultsPanelProps['saveParams'],
saveVisual: IndicatorMainDefaultsPanelProps['saveVisual'],
) {
saveParams(updated.type, updated.params);
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
/** DB에 보조지표 기본값 일괄 저장 */
saveAllDefaults?: (configs: Record<string, IndicatorConfig>) => Promise<void>;
/** 상단 저장 버튼 상태 (SettingsPage 헤더 연동) */
onSaveUiState?: (state: IndicatorSaveUiState | null) => void;
/** DB 로드 완료·저장 후 재로드 트리거 */
settingsRevision?: number;
}
const ALL_TYPES = getSettingsIndicatorTypes();
function buildDefaultsMap(
getParams: IndicatorMainDefaultsPanelProps['getParams'],
getVisualConfig: IndicatorMainDefaultsPanelProps['getVisualConfig'],
): Record<string, IndicatorConfig> {
const next: Record<string, IndicatorConfig> = {};
for (const type of ALL_TYPES) {
const cfg = buildMainIndicatorConfig(type, [], getParams, getVisualConfig);
if (cfg) next[type] = cfg;
}
return next;
}
function fingerprintMap(map: Record<string, IndicatorConfig>): string {
return ALL_TYPES.map(t => map[t] ? indicatorDefaultsFingerprint(map[t]) : '').join('|');
}
const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
chartIndicators,
onAddToChart,
onRemoveFromChart,
getParams,
saveParams,
getVisualConfig,
saveVisual,
saveAllDefaults,
onSaveUiState,
settingsRevision = 0,
}) => {
const onChartTypes = useMemo(() => indicatorTypesOnChart(chartIndicators), [chartIndicators]);
const onChartTypes = useMemo(() => new Set(chartIndicators.map(i => i.type)), [chartIndicators]);
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(() => new Set());
const [configs, setConfigs] = useState<Record<string, IndicatorConfig>>({});
const [baselineFp, setBaselineFp] = useState('');
const [search, setSearch] = useState('');
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const reloadFromDb = useCallback(() => {
const next = buildDefaultsMap(getParams, getVisualConfig);
setConfigs(next);
setBaselineFp(fingerprintMap(next));
setSaveMessage(null);
}, [getParams, getVisualConfig]);
useEffect(() => {
reloadFromDb();
}, [reloadFromDb, settingsRevision]);
const dirty = useMemo(
() => baselineFp !== '' && fingerprintMap(configs) !== baselineFp,
[configs, baselineFp],
);
const filtered = useMemo(
() => filterSettingsIndicatorTypes(ALL_TYPES, search),
@@ -71,15 +111,6 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
[filtered],
);
useEffect(() => {
const next: Record<string, IndicatorConfig> = {};
for (const type of ALL_TYPES) {
const cfg = buildMainIndicatorConfig(type, chartIndicators, getParams, getVisualConfig);
if (cfg) next[type] = cfg;
}
setConfigs(next);
}, [chartIndicators, getParams, getVisualConfig]);
const toggleExpand = useCallback((type: string) => {
setExpandedTypes(prev => {
const next = new Set(prev);
@@ -91,6 +122,7 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
const handleConfigChange = useCallback((type: string, updated: IndicatorConfig) => {
setConfigs(prev => ({ ...prev, [type]: updated }));
setSaveMessage(null);
}, []);
const handleToggleChart = useCallback((type: string, enabled: boolean) => {
@@ -105,22 +137,52 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
const base = configs[type];
if (!base) return;
const reset = resetConfigToDefaults(base);
persistDefaults(reset, saveParams, saveVisual);
handleConfigChange(type, reset);
}, [configs, saveParams, saveVisual, handleConfigChange]);
}, [configs, handleConfigChange]);
const handleResetAll = useCallback(() => {
if (!window.confirm('목록에 있는 모든 보조지표 기본값을 초기화할까요?')) return;
if (!window.confirm('목록에 있는 모든 보조지표 기본값을 초기화할까요? (저장 버튼을 눌러야 DB에 반영됩니다)')) return;
const next: Record<string, IndicatorConfig> = {};
for (const type of ALL_TYPES) {
const base = configs[type] ?? buildMainIndicatorConfig(type, [], getParams, getVisualConfig);
if (!base) continue;
const reset = resetConfigToDefaults(base);
next[type] = reset;
persistDefaults(reset, saveParams, saveVisual);
next[type] = resetConfigToDefaults(base);
}
setConfigs(next);
}, [configs, getParams, getVisualConfig, saveParams, saveVisual]);
setSaveMessage(null);
}, [configs, getParams, getVisualConfig]);
const handleSaveAll = useCallback(async () => {
if (!saveAllDefaults) {
setSaveMessage('저장 API를 사용할 수 없습니다.');
return;
}
if (!dirty) return;
setSaving(true);
setSaveMessage(null);
try {
await saveAllDefaults(configs);
const fp = fingerprintMap(configs);
setBaselineFp(fp);
setSaveMessage('저장되었습니다.');
} catch (err) {
console.error('[IndicatorMainDefaultsPanel] save failed', err);
setSaveMessage('저장에 실패했습니다. 다시 시도해 주세요.');
} finally {
setSaving(false);
}
}, [saveAllDefaults, configs, dirty]);
useEffect(() => {
if (!onSaveUiState) return;
onSaveUiState({
save: handleSaveAll,
dirty,
saving,
saveMessage,
});
return () => onSaveUiState(null);
}, [onSaveUiState, handleSaveAll, dirty, saving, saveMessage]);
const renderRows = (types: string[]) => types.map(type => {
const cfg = configs[type];
@@ -135,10 +197,7 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
variant="settings"
onToggleExpand={() => toggleExpand(type)}
onToggleEnabled={on => handleToggleChart(type, on)}
onChange={updated => {
persistDefaults(updated, saveParams, saveVisual);
handleConfigChange(type, updated);
}}
onChange={updated => handleConfigChange(type, updated)}
onRowDefaults={() => handleRowDefaults(type)}
/>
);
@@ -149,7 +208,7 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
<div className="stg-ind-intro">
<p>
<strong>Main</strong> {MAIN_INDICATOR_TYPES.length} , .
· , · DB .
· . · <strong> </strong> DB에 .
</p>
<button type="button" className="stg-ind-reset-all" onClick={handleResetAll}>
+40 -7
View File
@@ -9,7 +9,9 @@ import {
type LiveStrategySettingsDto,
} from '../utils/backendApi';
import { BacktestSettingsPanel } from './BacktestSettingsPanel';
import IndicatorMainDefaultsPanel from './IndicatorMainDefaultsPanel';
import IndicatorMainDefaultsPanel, {
type IndicatorSaveUiState,
} from './IndicatorMainDefaultsPanel';
import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
@@ -90,6 +92,10 @@ interface SettingsPageProps {
hlines?: HLineDef[],
cloudColors?: IchimokuCloudColors,
) => void;
/** 설정 화면 — 보조지표 기본값 일괄 DB 저장 */
saveAllIndicatorDefaults?: (configs: Record<string, IndicatorConfig>) => Promise<void>;
/** 보조지표 DB 캐시 갱신 revision */
indicatorSettingsRevision?: number;
/** 설정 화면에서 차트 on/off 연동 */
chartIndicators?: IndicatorConfig[];
onAddIndicatorToChart?: (type: string, config?: IndicatorConfig) => void;
@@ -1395,6 +1401,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
saveIndicatorParams,
getIndicatorVisual,
saveIndicatorVisual,
saveAllIndicatorDefaults,
indicatorSettingsRevision = 0,
chartIndicators = [],
onAddIndicatorToChart,
onRemoveIndicatorFromChart,
@@ -1455,6 +1463,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
}) => {
const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>('general');
const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null);
useEffect(() => {
if (categories.length === 0) return;
@@ -1463,6 +1472,10 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
}
}, [categories, active]);
useEffect(() => {
if (active !== 'indicators') setIndicatorSaveUi(null);
}, [active]);
const activeCat = categories.find(c => c.id === active) ?? categories[0];
if (!activeCat) {
@@ -1494,6 +1507,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
case 'indicators':
if (
!getIndicatorParams || !saveIndicatorParams || !getIndicatorVisual || !saveIndicatorVisual
|| !saveAllIndicatorDefaults
|| !onAddIndicatorToChart || !onRemoveIndicatorFromChart
) {
return (
@@ -1509,6 +1523,9 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
saveParams={saveIndicatorParams}
getVisualConfig={getIndicatorVisual}
saveVisual={saveIndicatorVisual}
saveAllDefaults={saveAllIndicatorDefaults}
onSaveUiState={setIndicatorSaveUi}
settingsRevision={indicatorSettingsRevision}
/>
);
case 'backtest': return (
@@ -1631,10 +1648,31 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
{/* 헤더 */}
<div className="stg-content-header">
<div className="stg-content-icon">{activeCat.icon}</div>
<div>
<div className="stg-content-header-text">
<h2 className="stg-content-title">{activeCat.label}</h2>
<p className="stg-content-desc">{activeCat.desc}</p>
</div>
{active === 'indicators' && indicatorSaveUi && (
<div className="stg-content-header-actions">
{indicatorSaveUi.saveMessage && (
<span
className={`stg-ind-save-msg stg-ind-save-msg--inline${
indicatorSaveUi.saveMessage.includes('실패') ? ' stg-ind-save-msg--err' : ''
}`}
>
{indicatorSaveUi.saveMessage}
</span>
)}
<button
type="button"
className="stg-btn-save"
disabled={!indicatorSaveUi.dirty || indicatorSaveUi.saving}
onClick={() => void indicatorSaveUi.save()}
>
{indicatorSaveUi.saving ? '저장 중…' : '저장'}
</button>
</div>
)}
</div>
{/* 설정 패널 */}
@@ -1649,11 +1687,6 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
<button className="stg-btn-save"></button>
</div>
)}
{active === 'indicators' && (
<div className="stg-footer stg-footer--hint">
<span className="stg-footer-hint"> .</span>
</div>
)}
</main>
</div>
);
+26 -30
View File
@@ -254,7 +254,29 @@ export default function StrategyEditorPage({ theme }: Props) {
const orphanTotal = buyOrphans.length + sellOrphans.length;
/** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드 기준값 동기화 */
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
const persistFlowLayout = useCallback((strategyKey: string) => {
saveStrategyFlowLayout(strategyKey, {
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
});
}, [buyOrphans, sellOrphans]);
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
persistLayoutTimerRef.current = window.setTimeout(() => {
persistLayoutTimerRef.current = null;
persistFlowLayout(strategyKey);
}, 150);
}, [persistFlowLayout]);
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${strategyKey}:${tab}:${layoutRevisionRef.current}`);
}, []);
/** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드·캔버스 기준값 동기화 */
useEffect(() => {
if (settingsRevision <= settingsSyncRef.current) return;
settingsSyncRef.current = settingsRevision;
@@ -282,29 +304,8 @@ export default function StrategyEditorPage({ theme }: Props) {
...prev,
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
}));
}, [settingsRevision, DEF]);
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
const persistFlowLayout = useCallback((strategyKey: string) => {
saveStrategyFlowLayout(strategyKey, {
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
});
}, [buyOrphans, sellOrphans]);
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
persistLayoutTimerRef.current = window.setTimeout(() => {
persistLayoutTimerRef.current = null;
persistFlowLayout(strategyKey);
}, 150);
}, [persistFlowLayout]);
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${strategyKey}:${tab}:${layoutRevisionRef.current}`);
}, []);
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const emptyBuy = emptySignalFlowLayout();
@@ -738,12 +739,7 @@ export default function StrategyEditorPage({ theme }: Props) {
const applyPaletteItem = useCallback((item: PaletteItem) => {
const composite = item.kind === 'composite';
const newNode = makeNode('indicator', item.value, signalTab, DEF, {
composite,
period: item.period,
leftPeriod: item.shortPeriod,
rightPeriod: item.longPeriod,
});
const newNode = makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined);
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
@@ -12,14 +12,12 @@ import {
type PaletteItemKind,
} from '../../utils/strategyPaletteStorage';
/** 팔레트 카드 기간 표시 — 항상 보조지표 설정(DEF) 기준 */
function periodLabel(item: PaletteItem, def: DefType): string {
if (item.kind === 'composite') {
const d = getCompositeDefaultPeriods(item.value, def);
const s = item.shortPeriod ?? d.short;
const l = item.longPeriod ?? d.long;
return `${s} / ${l}`;
return `${d.short} / ${d.long}`;
}
if (item.period != null) return String(item.period);
return getIndicatorPeriodLabel(item.value, def) || String(getDefaultIndicatorPeriod(item.value, def));
}
@@ -39,7 +39,7 @@ export default function PaletteItemModal({
setLabel(initial.label);
setDesc(initial.desc);
if (kind === 'auxiliary') {
setPeriod(initial.period ?? getDefaultIndicatorPeriod(initial.value, def));
setPeriod(getDefaultIndicatorPeriod(initial.value, def));
} else {
const d = getCompositeDefaultPeriods(initial.value, def);
setShortPeriod(initial.shortPeriod ?? d.short);