Files
goldenChart/frontend/src/utils/strategyToChartIndicators.ts
T
2026-06-12 14:39:17 +09:00

375 lines
12 KiB
TypeScript

/**
* 전략 DSL → 차트 IndicatorConfig[] 변환 (가상투자 차트 팝업)
*/
import type { StrategyDto } from './backendApi';
import type { IndicatorConfig, Timeframe } from '../types';
import type { LogicNode } from './strategyTypes';
import {
enrichIndicatorConfig,
getIndicatorDef,
getHLineLabel,
mergePlotDefs,
normalizeObvParams,
type HLineDef,
} from './indicatorRegistry';
import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor';
import { mergeGlobalDefaultsOntoChartIndicators } from './indicatorMainConfig';
import { buildMainIndicatorConfig } from './indicatorMainConfig';
import { normalizeSmaConfig } from './smaConfig';
import { normalizeIchimokuConfig } from './ichimokuConfig';
import type { EditorConditionState } from './strategyConditionSerde';
import { collectUiEvaluationTimeframes } from './strategyTimeframeSync';
const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI',
MACD: 'MACD',
MA: 'SMA',
EMA: 'EMA',
BOLLINGER: 'BollingerBands',
STOCHASTIC: 'Stochastic',
WILLIAMS_R: 'WilliamsPercentRange',
CCI: 'CCI',
ADX: 'ADX',
DMI: 'DMI',
OBV: 'OBV',
TRIX: 'TRIX',
VOLUME_OSC: 'VolumeOscillator',
VR: 'VR',
DISPARITY: 'Disparity',
PSYCHOLOGICAL: 'Psychological',
NEW_PSYCHOLOGICAL: 'NewPsychological',
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
BWI: 'BBBandWidth',
DONCHIAN: 'DonchianChannels',
NEW_HIGH: 'DonchianChannels',
NEW_LOW: 'DonchianChannels',
ICHIMOKU: 'IchimokuCloud',
ATR: 'ATR',
MFI: 'MFI',
};
interface IndicatorRef {
dslType: string;
registryType: string;
period?: number;
leftPeriod?: number;
rightPeriod?: number;
targetValue?: number | null;
}
type ParamRecord = Record<string, number | string | boolean>;
type GetParams = (type: string, defaults?: ParamRecord) => ParamRecord;
type GetVisual = (
type: string,
plots: import('./indicatorRegistry').PlotDef[],
hlines: HLineDef[],
) => {
plots: import('./indicatorRegistry').PlotDef[];
hlines: HLineDef[];
cloudColors?: import('./ichimokuConfig').IchimokuCloudColors;
};
/** 알림·가상투자 등 전략에서 생성된 차트 지표 id (워크스페이스 ind_* 와 구분) */
export function isStrategyChartIndicatorId(id: string): boolean {
return id.startsWith('strat_');
}
/** 전략 DSL 기준 안정 id — getVisualConfig/settingsRevision 변경 시 id 재발급 방지 */
export function stableStrategyIndicatorId(ref: IndicatorRef): string {
const parts = [ref.registryType];
if (ref.period != null) parts.push(`p${ref.period}`);
if (ref.leftPeriod != null) parts.push(`l${ref.leftPeriod}`);
if (ref.rightPeriod != null) parts.push(`r${ref.rightPeriod}`);
if (ref.targetValue != null) parts.push(`t${ref.targetValue}`);
return `strat_${parts.join('_')}`;
}
export function candleTypeToTimeframe(candleType: string): Timeframe {
const c = (candleType ?? '1m').trim().toLowerCase();
if (c === '1d') return '1D';
if (c === '1w') return '1W';
if (c === '1h') return '1h';
if (c === '4h') return '4h';
if (['1m', '3m', '5m', '10m', '15m', '30m'].includes(c)) return c as Timeframe;
return '1m';
}
const TIMEFRAME_SORT_ORDER: Timeframe[] = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1D', '1W', '1M'];
function sortStrategyTimeframes(tfs: Timeframe[]): Timeframe[] {
return [...tfs].sort(
(a, b) => TIMEFRAME_SORT_ORDER.indexOf(a) - TIMEFRAME_SORT_ORDER.indexOf(b),
);
}
/** 편집기 START·flowLayout·저장 DSL에서 전략 평가 시간봉 수집 */
export function resolveStrategyTimeframes(
strategy: StrategyDto | undefined,
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
): Timeframe[] {
const candleTypes = collectUiEvaluationTimeframes(strategy?.id ?? 0, strategy ?? null, editorState);
if (candleTypes.length === 0) return ['1m'];
const set = new Set<Timeframe>();
for (const ct of candleTypes) {
set.add(candleTypeToTimeframe(ct));
}
return sortStrategyTimeframes([...set]);
}
export function resolveStrategyPrimaryTimeframe(
strategy: StrategyDto | undefined,
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
): Timeframe {
const tfs = resolveStrategyTimeframes(strategy, editorState);
return tfs[0] ?? '1m';
}
function walkIndicatorRefs(
node: LogicNode | null | undefined,
out: IndicatorRef[],
seen: Set<string>,
): void {
if (!node) return;
if (node.type === 'TIMEFRAME') {
node.children?.forEach(c => walkIndicatorRefs(c, out, seen));
return;
}
if (node.type === 'CONDITION' && node.condition) {
const c = node.condition;
const dslType = c.indicatorType;
const registryType = DSL_TO_REGISTRY[dslType] ?? dslType;
const key = `${registryType}:${c.period ?? ''}:${c.leftPeriod ?? ''}:${c.rightPeriod ?? ''}:${c.targetValue ?? ''}`;
if (seen.has(key)) return;
seen.add(key);
out.push({
dslType,
registryType,
period: c.period,
leftPeriod: c.leftPeriod,
rightPeriod: c.rightPeriod,
targetValue: c.targetValue ?? c.compareValue ?? null,
});
return;
}
node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen));
}
function collectIndicatorRefs(strategy: StrategyDto, side?: 'BUY' | 'SELL'): IndicatorRef[] {
const out: IndicatorRef[] = [];
const seen = new Set<string>();
if (!side || side === 'BUY') {
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
}
if (!side || side === 'SELL') {
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
}
return out;
}
/** 전략 DSL에 포함된 보조지표 registry type 목록 (중복 제거) */
export function collectStrategyRegistryTypes(strategy: StrategyDto | null | undefined): string[] {
if (!strategy) return [];
const refs = collectIndicatorRefs(strategy);
return [...new Set(refs.map(r => r.registryType))];
}
function applyTargetHlines(
hlines: HLineDef[],
target: number | null | undefined,
): HLineDef[] {
if (target == null || Number.isNaN(target)) return hlines;
const next = hlines.map(h => ({ ...h }));
const prices = next.map(h => h.price);
const idx = next.findIndex(h => Math.abs(h.price - target) < 1e-6);
if (idx >= 0) {
next[idx] = { ...next[idx], visible: true, price: target };
return next;
}
next.push({
price: target,
color: '#ffb300',
label: getHLineLabel(target, [...prices, target]),
visible: true,
lineStyle: 'dashed',
lineWidth: 1,
});
return next;
}
function buildOneIndicator(
ref: IndicatorRef,
getParams: GetParams,
getVisual: GetVisual,
stableId?: string,
): IndicatorConfig | null {
const def = getIndicatorDef(ref.registryType);
if (!def) return null;
const params = getParams(ref.registryType, def.defaultParams as ParamRecord);
const { plots, hlines, cloudColors } = getVisual(ref.registryType, def.plots, def.hlines ?? []);
let cfg: IndicatorConfig = {
id: stableId ?? stableStrategyIndicatorId(ref),
type: def.type,
params: { ...params },
plots,
hlines,
...(cloudColors ? { cloudColors } : {}),
};
/**
* 모든 보조지표의 기간(period)은 반드시 지표 설정(getParams, DB 저장값)을 사용한다.
* 전략 DSL 조건의 period/leftPeriod/rightPeriod 로 덮어쓰지 않는다.
* 조건의 기간 필드는 부정확(미설정 시 1 등)할 수 있어 SMA(1)=본선 겹침 등
* 잘못된 그래프를 만든다. 설정에 저장된 값(예: OBV 신호선 9일)이 유일한 기준.
* cfg.params 는 이미 getParams(저장 설정값) 결과이므로 그대로 사용.
*/
if (ref.registryType === 'SMA') {
const mainSma = buildMainIndicatorConfig('SMA', [], getParams, getVisual as Parameters<typeof buildMainIndicatorConfig>[3]);
cfg = normalizeSmaConfig({
...cfg,
params: mainSma?.params ?? cfg.params,
plots: mainSma?.plots ?? cfg.plots,
plotVisibility: mainSma?.plotVisibility ?? cfg.plotVisibility,
});
} else if (ref.registryType === 'OBV') {
cfg = {
...cfg,
params: normalizeObvParams({ ...cfg.params }),
};
}
if (ref.registryType === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg);
}
if (ref.targetValue != null) {
cfg = { ...cfg, hlines: applyTargetHlines(cfg.hlines ?? [], ref.targetValue) };
}
return initializeIndicatorConfigForEditor(
cfg,
getParams,
(type, defaultPlots, defaultHlines) => getVisual(type, defaultPlots, defaultHlines ?? []),
);
}
/** 전략에 포함된 보조지표를 차트용 IndicatorConfig 로 변환 (중복 type 제거) */
export function buildStrategyChartIndicators(
strategy: StrategyDto | undefined,
getParams: GetParams,
getVisual: GetVisual,
workspaceIndicators?: IndicatorConfig[],
side?: 'BUY' | 'SELL',
): IndicatorConfig[] {
if (!strategy) return [];
const refs = collectIndicatorRefs(strategy, side);
const byType = new Map<string, IndicatorRef>();
for (const ref of refs) {
const existing = byType.get(ref.registryType);
if (!existing) {
byType.set(ref.registryType, ref);
continue;
}
byType.set(ref.registryType, {
...existing,
period: existing.period ?? ref.period,
leftPeriod: existing.leftPeriod ?? ref.leftPeriod,
rightPeriod: existing.rightPeriod ?? ref.rightPeriod,
targetValue: existing.targetValue ?? ref.targetValue,
});
}
const result: IndicatorConfig[] = [];
for (const ref of byType.values()) {
const cfg = buildOneIndicator(ref, getParams, getVisual);
if (cfg) result.push(cfg);
}
return mergeGlobalDefaultsOntoChartIndicators(
result,
getParams,
(type, defaultPlots, defaultHlines) => getVisual(type, defaultPlots ?? [], defaultHlines ?? []),
workspaceIndicators,
);
}
const MA_OVERLAY_TYPES = new Set(['SMA', 'EMA']);
/**
* 캔들 영역 기본 오버레이 — 이동평균(SMA)·일목균형표·볼린저밴드
* (전략에 없어도 표시, 우측 상단 토글로 표시/숨김)
*/
export function ensurePaperChartOverlays(
indicators: IndicatorConfig[],
getParams: GetParams,
getVisual: GetVisual,
): IndicatorConfig[] {
const hasMa = indicators.some(i => MA_OVERLAY_TYPES.has(i.type));
const hasIchimoku = indicators.some(i => i.type === 'IchimokuCloud');
const hasBb = indicators.some(i => i.type === 'BollingerBands');
const prepend: IndicatorConfig[] = [];
if (!hasMa) {
const sma = buildOneIndicator(
{ dslType: 'MA', registryType: 'SMA' },
getParams,
getVisual,
'strat_overlay_SMA',
);
if (sma) prepend.push(sma);
}
if (!hasIchimoku) {
const ichimoku = buildOneIndicator(
{ dslType: 'ICHIMOKU', registryType: 'IchimokuCloud' },
getParams,
getVisual,
'strat_overlay_IchimokuCloud',
);
if (ichimoku) prepend.push(ichimoku);
}
if (!hasBb) {
const bb = buildOneIndicator(
{ dslType: 'BOLLINGER', registryType: 'BollingerBands' },
getParams,
getVisual,
'strat_overlay_BollingerBands',
);
if (bb) prepend.push(bb);
}
if (prepend.length === 0) return indicators;
return [...prepend, ...indicators];
}
/** 가상투자 차트 — 전략 지표 + 기본 일목균형표 */
export function buildVirtualTradingChartIndicators(
strategy: StrategyDto | undefined,
getParams: GetParams,
getVisual: GetVisual,
): IndicatorConfig[] {
const strategyIndicators = buildStrategyChartIndicators(strategy, getParams, getVisual);
if (strategyIndicators.some(ind => ind.type === 'IchimokuCloud')) {
return strategyIndicators;
}
const defaultIchimoku = buildOneIndicator(
{ dslType: 'ICHIMOKU', registryType: 'IchimokuCloud' },
getParams,
getVisual,
);
if (!defaultIchimoku) return strategyIndicators;
return [defaultIchimoku, ...strategyIndicators];
}
/** 가상매매 카드 인라인 차트 — 캔들 영역 확보(일목·다중 지표 pane 제외, 전략 지표 최대 1개) */
export function buildVirtualTradingCardChartIndicators(
strategy: StrategyDto | undefined,
getParams: GetParams,
getVisual: GetVisual,
): IndicatorConfig[] {
return buildStrategyChartIndicators(strategy, getParams, getVisual).slice(0, 1);
}