일목균형표 수정
This commit is contained in:
@@ -39,8 +39,15 @@ import {
|
||||
getIchimokuPlotTitle,
|
||||
isIchimokuCloudVisible,
|
||||
resolveIchimokuCloudColors,
|
||||
resolveIchimokuDisplacements,
|
||||
} from './ichimokuConfig';
|
||||
import type { PlotDef, HLineStyle } from './indicatorRegistry';
|
||||
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
|
||||
import {
|
||||
applyPaneSeparatorToChartOptions,
|
||||
resolveChartPaneSeparatorOptions,
|
||||
type ChartPaneSeparatorOptions,
|
||||
} from '../types/chartPaneSeparator';
|
||||
|
||||
function plotSeriesLineStyle(style?: HLineStyle): number {
|
||||
if (style === 'dashed') return LineStyle.Dashed;
|
||||
@@ -217,6 +224,10 @@ export class ChartManager {
|
||||
/** 차트 하단 거래량 pane 표시 */
|
||||
private _volumeVisible = true;
|
||||
|
||||
/** pane 간 구분선 (설정 화면) */
|
||||
private _paneSeparatorOptions: ChartPaneSeparatorOptions =
|
||||
resolveChartPaneSeparatorOptions(null, 'dark');
|
||||
|
||||
/** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
|
||||
private _lastLayoutAvailableHeight?: number;
|
||||
|
||||
@@ -231,7 +242,7 @@ export class ChartManager {
|
||||
background: { type: ColorType.Solid, color: t.bgColor },
|
||||
textColor: t.textColor,
|
||||
fontSize: 12,
|
||||
panes: { enableResize: false },
|
||||
panes: { enableResize: false, separatorColor: 'transparent', separatorHoverColor: 'transparent' },
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: t.gridColor },
|
||||
@@ -258,6 +269,8 @@ export class ChartManager {
|
||||
});
|
||||
|
||||
this._applyDisplayTimezoneOptions();
|
||||
this._paneSeparatorOptions = resolveChartPaneSeparatorOptions(null, theme);
|
||||
this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions));
|
||||
|
||||
// 크로스헤어 이동 시 hover 중인 시리즈 추적 (오버레이 클릭 감지에 사용)
|
||||
this.chart.subscribeCrosshairMove((params: MouseEventParams<Time>) => {
|
||||
@@ -470,7 +483,11 @@ export class ChartManager {
|
||||
this.currentTheme = theme;
|
||||
const t = getTheme(theme);
|
||||
this.chart.applyOptions({
|
||||
layout: { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor },
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: t.bgColor },
|
||||
textColor: t.textColor,
|
||||
panes: applyPaneSeparatorToChartOptions(this._paneSeparatorOptions).layout.panes,
|
||||
},
|
||||
grid: { vertLines: { color: t.gridColor }, horzLines: { color: t.gridColor } },
|
||||
crosshair: { vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' }, horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' } },
|
||||
timeScale: { borderColor: t.borderColor },
|
||||
@@ -480,6 +497,20 @@ export class ChartManager {
|
||||
this._applyVolumeSeriesTheme(t);
|
||||
}
|
||||
|
||||
getContainer(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
getPaneSeparatorOptions(): ChartPaneSeparatorOptions {
|
||||
return this._paneSeparatorOptions;
|
||||
}
|
||||
|
||||
setPaneSeparatorOptions(opts: ChartPaneSeparatorOptions): void {
|
||||
this._paneSeparatorOptions = opts;
|
||||
this.chart.applyOptions(applyPaneSeparatorToChartOptions(opts));
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
private _applyMainSeriesTheme(t: ThemeTokens): void {
|
||||
if (!this.mainSeries) return;
|
||||
switch (this.currentChartType) {
|
||||
@@ -582,14 +613,7 @@ export class ChartManager {
|
||||
let series: ISeriesApi<SeriesType>;
|
||||
if (plotDef.type === 'histogram') {
|
||||
series = this.chart.addSeries(HistogramSeries, { color: plotDef.color }, pane);
|
||||
const colorData = filtered.map((p, i, arr) => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (i > 0 && p.value < arr[i - 1].value
|
||||
? '#ef535088'
|
||||
: p.value < 0 ? '#ef535088' : `${plotDef.color}88`),
|
||||
}));
|
||||
series.setData(colorData);
|
||||
series.setData(mapHistogramSeriesData(filtered, plotDef));
|
||||
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
|
||||
} else {
|
||||
const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false;
|
||||
@@ -743,6 +767,8 @@ export class ChartManager {
|
||||
await this.addIndicator(ind);
|
||||
}
|
||||
|
||||
if (this._indicatorLoadStale(loadGen)) return;
|
||||
|
||||
// 빈 pane stretch 정리 + 캔들 pane 비율 복구
|
||||
this.resetPaneHeights();
|
||||
this._notifyPaneLayout();
|
||||
@@ -907,7 +933,7 @@ export class ChartManager {
|
||||
_config: IndicatorConfig,
|
||||
seriesList: ISeriesApi<SeriesType>[],
|
||||
): Promise<{ cloudPlugin: IchimokuCloudPlugin; seriesMeta: IndicatorEntry['seriesMeta'] }> {
|
||||
const displacement = (_config.params?.displacement as number) ?? 26;
|
||||
const { senkou: displacement } = resolveIchimokuDisplacements(_config.params);
|
||||
const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52;
|
||||
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
|
||||
|
||||
@@ -1233,7 +1259,7 @@ export class ChartManager {
|
||||
// ── IchimokuCloud: SpanA/SpanB 미래 프로젝션 갱신 ──────────────────
|
||||
if (updateFutureSpans && entry.type === 'IchimokuCloud') {
|
||||
const config = entry.config;
|
||||
const disp = (config.params?.displacement as number) ?? 26;
|
||||
const { senkou: disp } = resolveIchimokuDisplacements(config.params);
|
||||
const lagPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
|
||||
const convPlot = (plots['plot0'] as PlotData) ?? [];
|
||||
const basePlot = (plots['plot1'] as PlotData) ?? [];
|
||||
@@ -1268,11 +1294,12 @@ export class ChartManager {
|
||||
|
||||
// ── Histogram: 색상 재계산 ──────────────────────────────────────────
|
||||
if (meta.isHistogram) {
|
||||
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
|
||||
const isDown = prevVal !== null && (curVal as number) < (prevVal as number);
|
||||
const isNeg = (curVal as number) < 0;
|
||||
const barColor = lastPoint.color
|
||||
?? (isDown || isNeg ? '#ef535088' : `${meta.color}88`);
|
||||
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
|
||||
.find(p => p.id === meta.plotId);
|
||||
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
|
||||
const barColor = plotDef
|
||||
? histogramBarColor(curVal as number, prevVal, plotDef, lastPoint.color)
|
||||
: (lastPoint.color ?? '#26A69A88');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(entry.seriesList[i] as ISeriesApi<any>).update({
|
||||
time: lastPoint.time as Time,
|
||||
@@ -1294,6 +1321,29 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** histogram 막대 색상(상승/하락) 변경 후 전체 재색칠 */
|
||||
private async _repaintHistogramSeries(indicatorId: string, config: IndicatorConfig): Promise<void> {
|
||||
const entry = this.indicators.get(indicatorId);
|
||||
if (!entry || this.rawBars.length === 0) return;
|
||||
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? [];
|
||||
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
||||
try {
|
||||
const { plots } = await calculateIndicator(config.type, this.rawBars.slice(), config.params ?? {});
|
||||
for (let i = 0; i < entry.seriesList.length; i++) {
|
||||
const meta = entry.seriesMeta[i];
|
||||
if (!meta?.isHistogram) continue;
|
||||
const plot = plotById[meta.plotId];
|
||||
if (!plot) continue;
|
||||
const plotData = plots[meta.plotId] as PlotData | undefined;
|
||||
if (!plotData?.length) continue;
|
||||
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (filtered.length === 0) continue;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(entry.seriesList[i] as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plot));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 일목균형표 구름 플러그인 — 색상·가시성·데이터 동기화 */
|
||||
private _applyIchimokuCloudPlugin(
|
||||
plugin: IchimokuCloudPlugin,
|
||||
@@ -1302,6 +1352,7 @@ export class ChartManager {
|
||||
): void {
|
||||
const colors = resolveIchimokuCloudColors(config.cloudColors);
|
||||
plugin.setColors(colors.bullishColor, colors.bearishColor);
|
||||
plugin.setVisibility(colors.bullishVisible !== false, colors.bearishVisible !== false);
|
||||
|
||||
if (!isIchimokuCloudVisible(config)) {
|
||||
plugin.setCloudData([]);
|
||||
@@ -1314,7 +1365,7 @@ export class ChartManager {
|
||||
const basePlot = plots['plot1'] as PlotData ?? [];
|
||||
if (!spanAPlot || !spanBPlot) return;
|
||||
|
||||
const displacement = (config.params?.displacement as number) ?? 26;
|
||||
const { senkou: displacement } = resolveIchimokuDisplacements(config.params);
|
||||
const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
|
||||
|
||||
const cloud = this._buildIchimokuCloudData(
|
||||
@@ -1990,6 +2041,12 @@ export class ChartManager {
|
||||
}
|
||||
|
||||
entry.config = config;
|
||||
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
||||
void this._repaintHistogramSeries(config.id, config);
|
||||
}
|
||||
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
||||
void this._repaintHistogramSeries(config.id, config);
|
||||
}
|
||||
|
||||
if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) {
|
||||
this._hideSubPaneIndicator(entry);
|
||||
@@ -2773,18 +2830,10 @@ export class ChartManager {
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
if (meta.isHistogram) {
|
||||
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
|
||||
.find(p => p.id === meta.plotId) ?? { id: meta.plotId, title: '', color: meta.color, type: 'histogram' as const };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(series as ISeriesApi<any>).setData(
|
||||
filtered.map((p, idx, arr) => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (
|
||||
idx > 0 && p.value < arr[idx - 1].value
|
||||
? '#ef535088'
|
||||
: p.value < 0 ? '#ef535088' : `${meta.color}88`
|
||||
),
|
||||
}))
|
||||
);
|
||||
(series as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plotDef));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(series as ISeriesApi<any>).setData(
|
||||
@@ -2809,7 +2858,7 @@ export class ChartManager {
|
||||
entry: IndicatorEntry,
|
||||
plots: Record<string, PlotData>,
|
||||
): void {
|
||||
const displacement = (entry.config?.params?.displacement as number) ?? 26;
|
||||
const { senkou: displacement } = resolveIchimokuDisplacements(entry.config?.params);
|
||||
const laggingPeriod = (entry.config?.params?.laggingSpan2Periods as number) ?? 52;
|
||||
const convPlot = (plots['plot0'] as PlotData) ?? [];
|
||||
const basePlot = (plots['plot1'] as PlotData) ?? [];
|
||||
|
||||
@@ -43,6 +43,8 @@ class CloudRenderer implements IPrimitivePaneRenderer {
|
||||
private _data: IchimokuCloudPoint[];
|
||||
private _bullishColor: string;
|
||||
private _bearishColor: string;
|
||||
private _bullishVisible: boolean;
|
||||
private _bearishVisible: boolean;
|
||||
|
||||
constructor(
|
||||
chart: IChartApiBase<Time>,
|
||||
@@ -50,12 +52,16 @@ class CloudRenderer implements IPrimitivePaneRenderer {
|
||||
data: IchimokuCloudPoint[],
|
||||
bullishColor: string,
|
||||
bearishColor: string,
|
||||
bullishVisible: boolean,
|
||||
bearishVisible: boolean,
|
||||
) {
|
||||
this._chart = chart;
|
||||
this._series = series;
|
||||
this._data = data;
|
||||
this._bullishColor = bullishColor;
|
||||
this._bearishColor = bearishColor;
|
||||
this._bullishVisible = bullishVisible;
|
||||
this._bearishVisible = bearishVisible;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -81,6 +87,8 @@ class CloudRenderer implements IPrimitivePaneRenderer {
|
||||
|
||||
// 한국 관례: SpanA > SpanB → 양운(빨강), SpanA <= SpanB → 음운(청록)
|
||||
const bullish = (a.spanA + b.spanA) > (a.spanB + b.spanB);
|
||||
if (bullish && !this._bullishVisible) continue;
|
||||
if (!bullish && !this._bearishVisible) continue;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
@@ -112,6 +120,8 @@ class CloudPaneView implements IPrimitivePaneView {
|
||||
this._plugin.getCloudData(),
|
||||
this._plugin.getBullishColor(),
|
||||
this._plugin.getBearishColor(),
|
||||
this._plugin.getBullishVisible(),
|
||||
this._plugin.getBearishVisible(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +133,8 @@ export class IchimokuCloudPlugin implements ISeriesPrimitive<Time> {
|
||||
private _data: IchimokuCloudPoint[] = [];
|
||||
private _bullishColor = 'rgba(239, 83, 80, 0.2)';
|
||||
private _bearishColor = 'rgba(38, 166, 154, 0.2)';
|
||||
private _bullishVisible = true;
|
||||
private _bearishVisible = true;
|
||||
private _updateFn: (() => void) | null = null;
|
||||
|
||||
attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
|
||||
@@ -147,8 +159,16 @@ export class IchimokuCloudPlugin implements ISeriesPrimitive<Time> {
|
||||
this._updateFn?.();
|
||||
}
|
||||
|
||||
setVisibility(bullishVisible: boolean, bearishVisible: boolean): void {
|
||||
this._bullishVisible = bullishVisible;
|
||||
this._bearishVisible = bearishVisible;
|
||||
this._updateFn?.();
|
||||
}
|
||||
|
||||
getBullishColor() { return this._bullishColor; }
|
||||
getBearishColor() { return this._bearishColor; }
|
||||
getBullishVisible() { return this._bullishVisible; }
|
||||
getBearishVisible() { return this._bearishVisible; }
|
||||
|
||||
paneViews(): readonly IPrimitivePaneView[] {
|
||||
if (!this._chart || !this._series) return [];
|
||||
|
||||
@@ -392,6 +392,13 @@ export interface AppSettingsDto {
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
||||
chartLegendOptions?: Record<string, boolean> | null;
|
||||
/** 차트 pane(캔들·거래량·보조지표) 구분선 */
|
||||
chartPaneSeparator?: {
|
||||
visible?: boolean;
|
||||
color?: string;
|
||||
width?: number;
|
||||
lineStyle?: string;
|
||||
} | null;
|
||||
/** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */
|
||||
tradeAlertPopup?: boolean;
|
||||
/** 매매 시그널 알림 사운드 ON/OFF */
|
||||
|
||||
@@ -6,7 +6,8 @@ export function timeframeToCandleType(tf: Timeframe): string {
|
||||
case '1m': case '3m': case '5m': case '10m': case '15m': case '30m': case '1h': case '4h':
|
||||
return tf;
|
||||
case '1D': return '1d';
|
||||
case '1W': case '1M': return '1d';
|
||||
case '1W': return '1w';
|
||||
case '1M': return '1d';
|
||||
default: return '1m';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ export function getIchimokuPlotTitle(plotId: string): string {
|
||||
export const ICHIMOKU_LINE_ROWS: IchimokuLineRowDef[] = [
|
||||
{ plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' },
|
||||
{ plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' },
|
||||
{ plotIndex: 2, plotId: 'plot2', paramKey: 'displacement' },
|
||||
{ plotIndex: 3, plotId: 'plot3' },
|
||||
{ plotIndex: 2, plotId: 'plot2', paramKey: 'chikouDisplacement' },
|
||||
{ plotIndex: 3, plotId: 'plot3', paramKey: 'senkouDisplacement' },
|
||||
{ plotIndex: 4, plotId: 'plot4', paramKey: 'laggingSpan2Periods' },
|
||||
];
|
||||
|
||||
@@ -37,9 +37,28 @@ export const ICHIMOKU_DEFAULT_PARAMS: Record<string, number> = {
|
||||
conversionPeriods: 9,
|
||||
basePeriods: 26,
|
||||
laggingSpan2Periods: 52,
|
||||
/** @deprecated senkouDisplacement 우선 — API·구버전 호환 */
|
||||
displacement: 26,
|
||||
chikouDisplacement: 26,
|
||||
senkouDisplacement: 26,
|
||||
};
|
||||
|
||||
/** 후행·선행 이동 기간 (구 displacement 단일 키 마이그레이션) */
|
||||
export function resolveIchimokuDisplacements(
|
||||
params: Record<string, number | string | boolean> | undefined,
|
||||
): { chikou: number; senkou: number } {
|
||||
const legacy = typeof params?.displacement === 'number' && params.displacement >= 1
|
||||
? params.displacement
|
||||
: 26;
|
||||
const chikou = typeof params?.chikouDisplacement === 'number' && params.chikouDisplacement >= 1
|
||||
? params.chikouDisplacement
|
||||
: legacy;
|
||||
const senkou = typeof params?.senkouDisplacement === 'number' && params.senkouDisplacement >= 1
|
||||
? params.senkouDisplacement
|
||||
: legacy;
|
||||
return { chikou, senkou };
|
||||
}
|
||||
|
||||
export function createDefaultIchimokuParams(): Record<string, number> {
|
||||
return { ...ICHIMOKU_DEFAULT_PARAMS };
|
||||
}
|
||||
@@ -55,12 +74,18 @@ export interface IchimokuCloudColors {
|
||||
bullishColor: string;
|
||||
/** 선행스팬1 < 선행스팬2 (하락 구름) */
|
||||
bearishColor: string;
|
||||
/** 상승 구름 영역 표시 */
|
||||
bullishVisible?: boolean;
|
||||
/** 하락 구름 영역 표시 */
|
||||
bearishVisible?: boolean;
|
||||
}
|
||||
|
||||
/** #RRGGBBAA — 상승 구름 빨강 20%, 하락 구름 청록 20% */
|
||||
export const DEFAULT_ICHIMOKU_CLOUD_COLORS: IchimokuCloudColors = {
|
||||
bullishColor: '#EF535033',
|
||||
bearishColor: '#26A69A33',
|
||||
bullishVisible: true,
|
||||
bearishVisible: true,
|
||||
};
|
||||
|
||||
/** registry plot ID → lightweight-charts-indicators plot ID */
|
||||
@@ -80,19 +105,29 @@ export const ICHIMOKU_LIB_TO_REGISTRY: Record<string, string> = {
|
||||
plot4: 'plot3',
|
||||
};
|
||||
|
||||
export function isIchimokuCloudVisible(config: IndicatorConfig): boolean {
|
||||
/** 선행스팬1·2 라인이 켜져 있어 구름 계산이 가능한지 */
|
||||
export function areIchimokuSpanLinesVisible(config: IndicatorConfig): boolean {
|
||||
return (
|
||||
config.plotVisibility?.['plot2'] !== false &&
|
||||
config.plotVisibility?.['plot3'] !== false
|
||||
config.plotVisibility?.['plot3'] !== false &&
|
||||
config.plotVisibility?.['plot4'] !== false
|
||||
);
|
||||
}
|
||||
|
||||
/** 구름 영역을 그릴 수 있는지 (선행스팬 + 상승/하락 구름 중 하나 이상 on) */
|
||||
export function isIchimokuCloudVisible(config: IndicatorConfig): boolean {
|
||||
if (!areIchimokuSpanLinesVisible(config)) return false;
|
||||
const colors = resolveIchimokuCloudColors(config.cloudColors);
|
||||
return colors.bullishVisible !== false || colors.bearishVisible !== false;
|
||||
}
|
||||
|
||||
export function resolveIchimokuCloudColors(
|
||||
colors?: Partial<IchimokuCloudColors>,
|
||||
): IchimokuCloudColors {
|
||||
return {
|
||||
bullishColor: colors?.bullishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bullishColor,
|
||||
bearishColor: colors?.bearishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bearishColor,
|
||||
bullishVisible: colors?.bullishVisible !== false,
|
||||
bearishVisible: colors?.bearishVisible !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +145,11 @@ export function normalizeIchimokuConfig(config: IndicatorConfig): IndicatorConfi
|
||||
}
|
||||
}
|
||||
|
||||
const { chikou, senkou } = resolveIchimokuDisplacements(params);
|
||||
params.chikouDisplacement = chikou;
|
||||
params.senkouDisplacement = senkou;
|
||||
params.displacement = senkou;
|
||||
|
||||
const plotVisibility: Record<string, boolean> = {
|
||||
...createDefaultIchimokuPlotVisibility(),
|
||||
...config.plotVisibility,
|
||||
|
||||
@@ -26,6 +26,8 @@ const PARAM_LABELS: Record<string, LabelPair> = {
|
||||
factor: { ko: '계수', en: 'Factor' },
|
||||
atrPeriod: { ko: 'ATR 기간', en: 'ATR Period' },
|
||||
displacement: { ko: '이동 기간', en: 'Displacement' },
|
||||
chikouDisplacement: { ko: '후행 기간', en: 'Chikou Displacement' },
|
||||
senkouDisplacement: { ko: '선행 기간', en: 'Senkou Displacement' },
|
||||
conversionPeriods: { ko: '전환선 기간', en: 'Conversion Periods' },
|
||||
basePeriods: { ko: '기준선 기간', en: 'Base Periods' },
|
||||
laggingSpan2Periods: { ko: '선행스팬2 기간', en: 'Lagging Span 2 Periods' },
|
||||
|
||||
@@ -48,7 +48,7 @@ function mergeOptions(
|
||||
}
|
||||
|
||||
function isPeriodKey(key: string): boolean {
|
||||
return /^(length|len|period\d*|kLength|dSmoothing|smooth|adxSmoothing|diLength|ccilength|turbolen|signalLength|fastLength|slowLength|longLength|shortLength|maLength|ultraLength|midLength|longLength|displacement|conversionPeriods|basePeriods|laggingSpan2Periods|length1|length2|length3|lengthRSI|lengthStoch|rsiLength|upDownLength|rocLength|stdevLength|atrLength|p|q|x)$/i.test(key);
|
||||
return /^(length|len|period\d*|kLength|dSmoothing|smooth|adxSmoothing|diLength|ccilength|turbolen|signalLength|fastLength|slowLength|longLength|shortLength|maLength|ultraLength|midLength|longLength|displacement|chikouDisplacement|senkouDisplacement|conversionPeriods|basePeriods|laggingSpan2Periods|length1|length2|length3|lengthRSI|lengthStoch|rsiLength|upDownLength|rocLength|stdevLength|atrLength|p|q|x)$/i.test(key);
|
||||
}
|
||||
|
||||
function isMultKey(key: string): boolean {
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
calculateSmaMulti,
|
||||
normalizeSmaConfig,
|
||||
} from './smaConfig';
|
||||
import { normalizeIchimokuConfig, mergeIchimokuPlots } from './ichimokuConfig';
|
||||
import {
|
||||
normalizeIchimokuConfig,
|
||||
mergeIchimokuPlots,
|
||||
resolveIchimokuDisplacements,
|
||||
} from './ichimokuConfig';
|
||||
import {
|
||||
normalizePsychologicalParams,
|
||||
resolvePsychologicalIndicatorType,
|
||||
@@ -37,6 +41,10 @@ export interface PlotDef {
|
||||
lineWidth?: number;
|
||||
/** 라인 시리즈 선 유형 (기본 solid) */
|
||||
lineStyle?: HLineStyle;
|
||||
/** histogram — 이전 봉 대비 상승·0선 위 막대 색 */
|
||||
histogramUpColor?: string;
|
||||
/** histogram — 이전 봉 대비 하락 또는 0선 아래 막대 색 */
|
||||
histogramDownColor?: string;
|
||||
}
|
||||
|
||||
export type HLineStyle = 'solid' | 'dashed' | 'dotted';
|
||||
@@ -303,7 +311,7 @@ export const INDICATOR_REGISTRY: IndicatorDef[] = [
|
||||
{ type:'ChopZone', name:'Chop Zone', koreanName:'변동성구분지표', shortName:'CZ', category:'Oscillators', overlay:false, defaultParams:{length:30, atrLength:1}, plots:[{id:'plot0',title:'CZ', color:'#FF9800',type:'histogram'}], hlines:[], description:'변동성 vs 추세 구분 지표' },
|
||||
|
||||
// ── Momentum ──────────────────────────────────────────────────────────────
|
||||
{ type:'MACD', name:'MACD', koreanName:'이동평균수렴발산', shortName:'MACD', category:'Momentum', overlay:false, defaultParams:{fastLength:12, slowLength:26, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'Histogram',color:'#26A69A',type:'histogram'},{id:'plot1',title:'MACD',color:'#2962FF',type:'line',lineWidth:1},{id:'plot2',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'이동평균 수렴·발산' },
|
||||
{ type:'MACD', name:'MACD', koreanName:'이동평균수렴발산', shortName:'MACD', category:'Momentum', overlay:false, defaultParams:{fastLength:12, slowLength:26, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'Histogram',color:'#26A69A',type:'histogram',histogramUpColor:'#26A69A',histogramDownColor:'#EF5350'},{id:'plot1',title:'MACD',color:'#2962FF',type:'line',lineWidth:1},{id:'plot2',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'이동평균 수렴·발산' },
|
||||
{ type:'Momentum', name:'Momentum', koreanName:'모멘텀', shortName:'MOM', category:'Momentum', overlay:false, defaultParams:{length:10, src:'close'}, plots:[{id:'plot0',title:'MOM', color:'#26A69A',type:'line',lineWidth:2}], hlines:[{price:0,color:'#607D8B'}], description:'모멘텀 지표' },
|
||||
{ type:'ROC', name:'Rate of Change', koreanName:'가격변화율', shortName:'ROC', category:'Momentum', overlay:false, defaultParams:{length:9, src:'close'}, plots:[{id:'plot0',title:'ROC', color:'#00BCD4',type:'line',lineWidth:2}], hlines:[{price:10,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-10,color:'#4CAF5080'}], description:'가격 변화율 (%)' },
|
||||
{ type:'TSI', name:'True Strength Index', koreanName:'참강도지수', shortName:'TSI', category:'Momentum', overlay:false, defaultParams:{longLength:25, shortLength:13, signalLength:13, src:'close'}, plots:[{id:'plot0',title:'TSI', color:'#2196F3',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:25,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-25,color:'#4CAF50'}], description:'참 강도 지수' },
|
||||
@@ -319,7 +327,7 @@ export const INDICATOR_REGISTRY: IndicatorDef[] = [
|
||||
{ type:'ADX', name:'Average Directional Index', koreanName:'평균방향성지수', shortName:'ADX', category:'Trend', overlay:false, defaultParams:{adxSmoothing:14, diLength:14}, plots:[{id:'plot0',title:'ADX',color:'#FF6D00',type:'line',lineWidth:2},{id:'plot1',title:'+DI',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot2',title:'-DI',color:'#EF5350',type:'line',lineWidth:1}], hlines:ADX_DEFAULT_HLINES, description:'평균 방향성 지수 (추세 강도)' },
|
||||
{ type:'DMI', name:'Directional Movement Index', koreanName:'방향성이동지수', shortName:'DMI', category:'Trend', overlay:false, defaultParams:{diLength:14, adxSmoothing:14}, plots:[{id:'plot0',title:'+DI',color:'#2962FF',type:'line',lineWidth:1},{id:'plot1',title:'-DI',color:'#FF6D00',type:'line',lineWidth:1},{id:'plot2',title:'DX',color:'#FF9800',type:'line',lineWidth:1},{id:'plot3',title:'ADX',color:'#E91E63',type:'line',lineWidth:1},{id:'plot4',title:'ADXR',color:'#9C27B0',type:'line',lineWidth:1}], hlines:DMI_DEFAULT_HLINES, description:'방향성 이동 지수 (+DI·-DI·DX·ADX·ADXR)' },
|
||||
{ type:'Aroon', name:'Aroon', koreanName:'아룬지표', shortName:'Aroon', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'Up', color:'#4CAF50',type:'line',lineWidth:1},{id:'plot1',title:'Down',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:70,color:'#4CAF5060'},{price:30,color:'#EF535060'}], description:'아룬 추세 지표' },
|
||||
{ type:'IchimokuCloud', name:'Ichimoku Cloud', koreanName:'일목균형표', shortName:'Ichi', category:'Trend', overlay:true, defaultParams:{conversionPeriods:9, basePeriods:26, laggingSpan2Periods:52, displacement:26}, plots:[{id:'plot0',title:'전환선',color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'기준선',color:'#EF5350',type:'line',lineWidth:1},{id:'plot2',title:'후행스팬',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot3',title:'선행스팬1',color:'#EF5350',type:'line',lineWidth:1},{id:'plot4',title:'선행스팬2',color:'#9C27B0',type:'line',lineWidth:1}], description:'일목균형표' },
|
||||
{ type:'IchimokuCloud', name:'Ichimoku Cloud', koreanName:'일목균형표', shortName:'Ichi', category:'Trend', overlay:true, defaultParams:{conversionPeriods:9, basePeriods:26, laggingSpan2Periods:52, displacement:26, chikouDisplacement:26, senkouDisplacement:26}, plots:[{id:'plot0',title:'전환선',color:'#2196F3',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot1',title:'기준선',color:'#EF5350',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot2',title:'후행스팬',color:'#4CAF50',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot3',title:'선행스팬1',color:'#EF5350',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot4',title:'선행스팬2',color:'#9C27B0',type:'line',lineWidth:1,lineStyle:'solid'}], description:'일목균형표' },
|
||||
{ type:'Supertrend', name:'Supertrend', koreanName:'슈퍼트렌드', shortName:'ST', category:'Trend', overlay:true, defaultParams:{atrPeriod:10, factor:3}, plots:[{id:'plot0',title:'ST', color:'#4CAF50',type:'line',lineWidth:2}], description:'슈퍼트렌드 (ATR 기반)' },
|
||||
{ type:'ParabolicSAR', name:'Parabolic SAR', koreanName:'파라볼릭SAR', shortName:'SAR', category:'Trend', overlay:true, defaultParams:{start:0.02, increment:0.02, maximum:0.2}, plots:[{id:'plot0',title:'SAR', color:'#FF6D00',type:'scatter'}], description:'파라볼릭 SAR' },
|
||||
{ type:'Choppiness', name:'Choppiness Index', koreanName:'불규칙성지수', shortName:'CHOP', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'CHOP',color:'#FFC107',type:'line',lineWidth:2}], hlines:[{price:61.8,color:'#EF535080'},{price:38.2,color:'#4CAF5080'}], description:'변동성 vs 추세 구분 (61.8이하=추세)' },
|
||||
@@ -603,6 +611,10 @@ export async function calculateIndicator(
|
||||
|
||||
let calcBars = bars;
|
||||
let calcParams = params;
|
||||
if (type === 'IchimokuCloud') {
|
||||
const { senkou } = resolveIchimokuDisplacements(params);
|
||||
calcParams = { ...params, displacement: senkou };
|
||||
}
|
||||
if (type === 'BollingerBands') {
|
||||
calcParams = normalizeBollingerParams(params);
|
||||
calcBars = await resolveBollingerBars(bars, calcParams, chartContext.timeframe);
|
||||
|
||||
@@ -46,10 +46,10 @@ export const INDICATOR_PLOT_PARAMS: Record<string, Record<string, string[]>> = {
|
||||
IchimokuCloud: {
|
||||
plot0: ['conversionPeriods'],
|
||||
plot1: ['basePeriods'],
|
||||
/** 후행스팬 — chikou 이동(displacement) */
|
||||
plot2: ['displacement'],
|
||||
/** 선행스팬1 — 전환·기준선에서 산출, 별도 기간 없음 */
|
||||
plot3: [],
|
||||
/** 후행스팬 — 과거 이동 기간 */
|
||||
plot2: ['chikouDisplacement'],
|
||||
/** 선행스팬1 — 미래 이동 기간 */
|
||||
plot3: ['senkouDisplacement'],
|
||||
/** 선행스팬2 — Donchian 기간 */
|
||||
plot4: ['laggingSpan2Periods'],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { HLineStyle } from './indicatorRegistry';
|
||||
import type { HLineStyle, PlotDef } from './indicatorRegistry';
|
||||
|
||||
const DEFAULT_HIST_UP = '#26A69A';
|
||||
const DEFAULT_HIST_DOWN = '#EF5350';
|
||||
|
||||
/** 업비트/트레이딩뷰 스타일 색상 팔레트 (10×7) */
|
||||
export const COLOR_PALETTE: string[] = [
|
||||
@@ -48,3 +51,44 @@ export const LINE_STYLE_LABELS: Record<HLineStyle, string> = {
|
||||
dashed: '점선',
|
||||
dotted: '도트',
|
||||
};
|
||||
|
||||
/** histogram 막대 색에 투명도(88) 보장 */
|
||||
export function withHistogramAlpha(color: string): string {
|
||||
if (color.startsWith('rgba(') || color.startsWith('rgb(')) return color;
|
||||
if (color.length >= 9 && color.startsWith('#')) return color;
|
||||
if (color.length >= 7 && color.startsWith('#')) return `${color}88`;
|
||||
return `${color}88`;
|
||||
}
|
||||
|
||||
export function resolveHistogramUpColor(plot: PlotDef): string {
|
||||
return plot.histogramUpColor ?? plot.color ?? DEFAULT_HIST_UP;
|
||||
}
|
||||
|
||||
export function resolveHistogramDownColor(plot: PlotDef): string {
|
||||
return plot.histogramDownColor ?? DEFAULT_HIST_DOWN;
|
||||
}
|
||||
|
||||
/** MACD 등 histogram 막대 색 (상승·하락/음수 구분) */
|
||||
export function histogramBarColor(
|
||||
value: number,
|
||||
prev: number | null | undefined,
|
||||
plot: PlotDef,
|
||||
pointColor?: string,
|
||||
): string {
|
||||
if (pointColor) return withHistogramAlpha(pointColor);
|
||||
const isDown = prev != null && !isNaN(prev) && value < prev;
|
||||
const isNeg = value < 0;
|
||||
const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot);
|
||||
return withHistogramAlpha(raw);
|
||||
}
|
||||
|
||||
export function mapHistogramSeriesData(
|
||||
filtered: Array<{ time: number; value: number; color?: string }>,
|
||||
plot: PlotDef,
|
||||
): Array<{ time: import('lightweight-charts').Time; value: number; color: string }> {
|
||||
return filtered.map((p, idx, arr) => ({
|
||||
time: p.time as import('lightweight-charts').Time,
|
||||
value: p.value,
|
||||
color: histogramBarColor(p.value, idx > 0 ? arr[idx - 1].value : null, plot, p.color),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
DEFAULT_START_CANDLE,
|
||||
DEFAULT_START_COMBINE_OP,
|
||||
START_NODE_ID,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
createStartNodeId,
|
||||
defaultStartMeta,
|
||||
getStartCandleTypes,
|
||||
normalizeStartCandleType,
|
||||
type StartCombineOp,
|
||||
type StartNodeMeta,
|
||||
@@ -25,30 +27,42 @@ export type { StartCombineOp };
|
||||
|
||||
export type ConditionBranch = {
|
||||
candleType: string;
|
||||
candleTypes?: string[];
|
||||
root: LogicNode | null;
|
||||
};
|
||||
|
||||
export type StartSection = {
|
||||
startId: string;
|
||||
candleType: string;
|
||||
candleTypes: string[];
|
||||
root: LogicNode | null;
|
||||
canDelete: boolean;
|
||||
};
|
||||
|
||||
function metaToStartSection(
|
||||
startId: string,
|
||||
meta: Record<string, StartNodeMeta>,
|
||||
root: LogicNode | null,
|
||||
canDelete: boolean,
|
||||
): StartSection {
|
||||
const candleTypes = getStartCandleTypes(meta[startId]);
|
||||
return {
|
||||
startId,
|
||||
candleType: candleTypes[0],
|
||||
candleTypes,
|
||||
root,
|
||||
canDelete,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectStartSections(state: EditorConditionState): StartSection[] {
|
||||
const sections: StartSection[] = [{
|
||||
startId: START_NODE_ID,
|
||||
candleType: normalizeStartCandleType(state.startMeta[START_NODE_ID]?.candleType),
|
||||
root: state.root,
|
||||
canDelete: false,
|
||||
}];
|
||||
const sections: StartSection[] = [
|
||||
metaToStartSection(START_NODE_ID, state.startMeta, state.root, false),
|
||||
];
|
||||
for (const startId of state.extraStartIds) {
|
||||
sections.push({
|
||||
startId,
|
||||
candleType: normalizeStartCandleType(state.startMeta[startId]?.candleType),
|
||||
root: state.extraRoots[startId] ?? null,
|
||||
canDelete: true,
|
||||
});
|
||||
sections.push(
|
||||
metaToStartSection(startId, state.startMeta, state.extraRoots[startId] ?? null, true),
|
||||
);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
@@ -110,8 +124,12 @@ function syncSubtreeCandleTypes(node: LogicNode | null, candleType: string): Log
|
||||
|
||||
function collectExplicitCandleTypesFromTree(node: LogicNode | null, out: Set<string>): void {
|
||||
if (!node) return;
|
||||
if (node.type === 'TIMEFRAME' && node.candleType) {
|
||||
out.add(normalizeStartCandleType(node.candleType));
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
if (node.candleTypes?.length) {
|
||||
for (const ct of node.candleTypes) out.add(normalizeStartCandleType(ct));
|
||||
} else if (node.candleType) {
|
||||
out.add(normalizeStartCandleType(node.candleType));
|
||||
}
|
||||
}
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = node.condition as ConditionDSL;
|
||||
@@ -130,22 +148,23 @@ function inferPrimaryCandleFromTree(root: LogicNode | null): string {
|
||||
return DEFAULT_START_CANDLE;
|
||||
}
|
||||
|
||||
export function updateStartCandleType(
|
||||
export function updateStartCandleTypes(
|
||||
state: EditorConditionState,
|
||||
startId: string,
|
||||
candleType: string,
|
||||
candleTypes: string[],
|
||||
): EditorConditionState {
|
||||
const ct = normalizeStartCandleType(candleType);
|
||||
const types = normalizeCandleTypesList(candleTypes);
|
||||
const primary = types[0];
|
||||
const nextMeta = {
|
||||
...state.startMeta,
|
||||
[startId]: { candleType: ct },
|
||||
[startId]: { candleTypes: types, candleType: primary },
|
||||
};
|
||||
|
||||
if (startId === START_NODE_ID) {
|
||||
return {
|
||||
...state,
|
||||
startMeta: nextMeta,
|
||||
root: syncSubtreeCandleTypes(state.root, ct),
|
||||
root: syncSubtreeCandleTypes(state.root, primary),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,11 +173,39 @@ export function updateStartCandleType(
|
||||
startMeta: nextMeta,
|
||||
extraRoots: {
|
||||
...state.extraRoots,
|
||||
[startId]: syncSubtreeCandleTypes(state.extraRoots[startId] ?? null, ct),
|
||||
[startId]: syncSubtreeCandleTypes(state.extraRoots[startId] ?? null, primary),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 단일 분봉 — updateStartCandleTypes 사용 */
|
||||
export function updateStartCandleType(
|
||||
state: EditorConditionState,
|
||||
startId: string,
|
||||
candleType: string,
|
||||
): EditorConditionState {
|
||||
return updateStartCandleTypes(state, startId, [candleType]);
|
||||
}
|
||||
|
||||
function normalizeCandleTypesList(types: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const ct of STRATEGY_CANDLE_TYPES) {
|
||||
if (types.some(t => normalizeStartCandleType(t) === ct) && !seen.has(ct)) {
|
||||
seen.add(ct);
|
||||
out.push(ct);
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : [DEFAULT_START_CANDLE];
|
||||
}
|
||||
|
||||
function readTimeframeCandleTypes(node: LogicNode): string[] {
|
||||
if (node.candleTypes?.length) {
|
||||
return normalizeCandleTypesList(node.candleTypes);
|
||||
}
|
||||
return [normalizeStartCandleType(node.candleType)];
|
||||
}
|
||||
|
||||
export function addExtraStartSection(state: EditorConditionState): EditorConditionState {
|
||||
const startId = createStartNodeId();
|
||||
return {
|
||||
@@ -166,7 +213,7 @@ export function addExtraStartSection(state: EditorConditionState): EditorConditi
|
||||
extraStartIds: [...state.extraStartIds, startId],
|
||||
startMeta: {
|
||||
...state.startMeta,
|
||||
[startId]: { candleType: DEFAULT_START_CANDLE },
|
||||
[startId]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE },
|
||||
},
|
||||
extraRoots: { ...state.extraRoots, [startId]: null },
|
||||
};
|
||||
@@ -206,32 +253,48 @@ export function emptyEditorConditionState(): EditorConditionState {
|
||||
}
|
||||
|
||||
function wrapTimeframe(candleType: string, root: LogicNode): LogicNode {
|
||||
const ct = normalizeStartCandleType(candleType);
|
||||
return {
|
||||
id: generateNodeId(),
|
||||
type: 'TIMEFRAME',
|
||||
candleType: normalizeStartCandleType(candleType),
|
||||
candleType: ct,
|
||||
children: [root],
|
||||
};
|
||||
}
|
||||
|
||||
function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
||||
const types = normalizeCandleTypesList(candleTypes);
|
||||
if (types.length === 1) return wrapTimeframe(types[0], root);
|
||||
return {
|
||||
id: generateNodeId(),
|
||||
type: 'TIMEFRAME',
|
||||
candleType: types[0],
|
||||
candleTypes: types,
|
||||
children: [root],
|
||||
};
|
||||
}
|
||||
|
||||
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
||||
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
||||
const branches = collectEditorBranches(state).filter(b => b.root != null) as Array<{
|
||||
candleType: string;
|
||||
root: LogicNode;
|
||||
}>;
|
||||
const branches = collectEditorBranches(state).filter(
|
||||
(b): b is ConditionBranch & { root: LogicNode } => b.root != null,
|
||||
);
|
||||
|
||||
if (branches.length === 0) return null;
|
||||
if (branches.length === 1) {
|
||||
const { candleType, root } = branches[0];
|
||||
return wrapTimeframe(candleType, root);
|
||||
const { candleType, candleTypes, root } = branches[0];
|
||||
const types = candleTypes?.length ? candleTypes : [candleType];
|
||||
return wrapTimeframes(types, root);
|
||||
}
|
||||
|
||||
const combineOp = normalizeStartCombineOp(state.startCombineOp);
|
||||
return {
|
||||
id: generateNodeId(),
|
||||
type: combineOp,
|
||||
children: branches.map(b => wrapTimeframe(b.candleType, b.root)),
|
||||
children: branches.map(b => {
|
||||
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
||||
return wrapTimeframes(types, b.root);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,15 +310,16 @@ function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null {
|
||||
let primaryRoot: LogicNode | null = null;
|
||||
|
||||
children.forEach((tf, index) => {
|
||||
const candleType = normalizeStartCandleType(tf.candleType);
|
||||
const candleTypes = readTimeframeCandleTypes(tf as LogicNode);
|
||||
const candleType = candleTypes[0];
|
||||
const branchRoot = tf.children?.[0] ?? null;
|
||||
if (index === 0) {
|
||||
startMeta[START_NODE_ID] = { candleType };
|
||||
startMeta[START_NODE_ID] = { candleTypes, candleType };
|
||||
primaryRoot = branchRoot;
|
||||
return;
|
||||
}
|
||||
const startId = createStartNodeId();
|
||||
startMeta[startId] = { candleType };
|
||||
startMeta[startId] = { candleTypes, candleType };
|
||||
extraStartIds.push(startId);
|
||||
extraRoots[startId] = branchRoot;
|
||||
});
|
||||
@@ -277,10 +341,11 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
||||
if (multiStart) return multiStart;
|
||||
|
||||
if (dsl.type === 'TIMEFRAME') {
|
||||
const candleTypes = readTimeframeCandleTypes(dsl);
|
||||
return {
|
||||
root: dsl.children?.[0] ?? null,
|
||||
startMeta: {
|
||||
[START_NODE_ID]: { candleType: normalizeStartCandleType(dsl.candleType) },
|
||||
[START_NODE_ID]: { candleTypes, candleType: candleTypes[0] },
|
||||
},
|
||||
extraStartIds: [],
|
||||
extraRoots: {},
|
||||
@@ -292,7 +357,7 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
||||
return {
|
||||
root: dsl,
|
||||
startMeta: {
|
||||
[START_NODE_ID]: { candleType: inferred },
|
||||
[START_NODE_ID]: { candleTypes: [inferred], candleType: inferred },
|
||||
},
|
||||
extraStartIds: [],
|
||||
extraRoots: {},
|
||||
@@ -303,14 +368,22 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
||||
/** Logic Expression / 미리보기용 — START별 평가 분봉과 서브트리 */
|
||||
export function collectEditorBranches(state: EditorConditionState): ConditionBranch[] {
|
||||
const branches: ConditionBranch[] = [];
|
||||
const primaryCt = state.startMeta[START_NODE_ID]?.candleType ?? DEFAULT_START_CANDLE;
|
||||
if (state.root) branches.push({ candleType: primaryCt, root: state.root });
|
||||
const primaryTypes = getStartCandleTypes(state.startMeta[START_NODE_ID]);
|
||||
if (state.root) {
|
||||
branches.push({
|
||||
candleType: primaryTypes[0],
|
||||
candleTypes: primaryTypes.length > 1 ? primaryTypes : undefined,
|
||||
root: state.root,
|
||||
});
|
||||
}
|
||||
|
||||
for (const startId of state.extraStartIds) {
|
||||
const root = state.extraRoots[startId] ?? null;
|
||||
if (root) {
|
||||
const types = getStartCandleTypes(state.startMeta[startId]);
|
||||
branches.push({
|
||||
candleType: state.startMeta[startId]?.candleType ?? DEFAULT_START_CANDLE,
|
||||
candleType: types[0],
|
||||
candleTypes: types.length > 1 ? types : undefined,
|
||||
root,
|
||||
});
|
||||
}
|
||||
@@ -318,6 +391,15 @@ export function collectEditorBranches(state: EditorConditionState): ConditionBra
|
||||
return branches;
|
||||
}
|
||||
|
||||
/** DSL·UI에서 사용하는 전략 평가 분봉 목록 */
|
||||
export function collectTimeframesFromEditorState(state: EditorConditionState): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const section of collectStartSections(state)) {
|
||||
for (const ct of section.candleTypes) set.add(ct);
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
||||
export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
||||
return collectEditorBranches(decodeConditionForEditor(dsl));
|
||||
@@ -335,7 +417,8 @@ export function mergeStartMetaForLoad(
|
||||
...stored,
|
||||
};
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
merged[startId] = { candleType: normalizeStartCandleType(meta.candleType) };
|
||||
const types = getStartCandleTypes(meta);
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,12 @@ function describeNode(
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const tf = candleKo(node.candleType ?? '1m');
|
||||
const types = node.candleTypes?.length
|
||||
? node.candleTypes
|
||||
: [node.candleType ?? '1m'];
|
||||
const tf = types.length > 1
|
||||
? types.map(candleKo).join(', ')
|
||||
: candleKo(types[0]);
|
||||
if (!inner) return [`[${tf}] 연결된 조건이 없습니다.`];
|
||||
const innerLines = describeNode(inner, signalType, def, depth + 1);
|
||||
return [`[${tf} 기준]`, ...innerLines.map(l => (l.startsWith('•') ? ` ${l}` : ` • ${l}`))];
|
||||
@@ -248,9 +253,15 @@ function describeSignalBranches(
|
||||
const paragraphs: string[] = [];
|
||||
|
||||
if (active.length === 1) {
|
||||
const { candleType, root } = active[0];
|
||||
const tf = candleKo(candleType);
|
||||
paragraphs.push(`${tf} 차트를 기준으로 아래 조건을 평가합니다.`);
|
||||
const { candleType, candleTypes, root } = active[0];
|
||||
const tf = candleTypes && candleTypes.length > 1
|
||||
? candleTypes.map(candleKo).join(', ')
|
||||
: candleKo(candleType);
|
||||
paragraphs.push(
|
||||
candleTypes && candleTypes.length > 1
|
||||
? `${tf} 각 시간봉 마감 시점마다 아래 조건을 독립적으로 평가합니다.`
|
||||
: `${tf} 차트를 기준으로 아래 조건을 평가합니다.`,
|
||||
);
|
||||
bullets.push(...describeNode(root!, signalType, def));
|
||||
return { hasContent: true, paragraphs, bullets };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { LogicNode, ConditionDSL } from './strategyTypes';
|
||||
import { nodeToText, type DefType } from './strategyEditorShared';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
defaultStartMeta,
|
||||
getStartCandleTypes,
|
||||
isStartNodeId,
|
||||
normalizeStartCandleType,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
@@ -30,12 +32,15 @@ export type StrategyFlowNodeData = {
|
||||
def?: DefType;
|
||||
signalTab?: 'buy' | 'sell';
|
||||
selected?: boolean;
|
||||
/** START 노드 — 조건 판별 시간봉 */
|
||||
/** START 노드 — 조건 판별 시간봉 (표시용 대표) */
|
||||
candleType?: string;
|
||||
onStartCandleTypeChange?: (startId: string, candleType: string) => void;
|
||||
candleTypes?: string[];
|
||||
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
|
||||
onDeleteStart?: (startId: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
||||
/** AND ↔ OR 전환 */
|
||||
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
|
||||
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
||||
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
|
||||
onDragLeaveTarget?: (targetId: string) => void;
|
||||
@@ -353,8 +358,8 @@ export type EdgeHandleBinding = {
|
||||
|
||||
export const FLOW_NODE_W = 200;
|
||||
export const FLOW_NODE_H = 72;
|
||||
export const FLOW_START_W = 168;
|
||||
export const FLOW_START_H = 56;
|
||||
export const FLOW_START_W = 200;
|
||||
export const FLOW_START_H = 78;
|
||||
|
||||
function nodeCenter(
|
||||
id: string,
|
||||
@@ -464,7 +469,11 @@ function layoutTree(
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
callbacks: Pick<
|
||||
StrategyFlowNodeData,
|
||||
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
|
||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||
>,
|
||||
dragOverId: string | null,
|
||||
): void {
|
||||
const yBase = 220;
|
||||
@@ -484,6 +493,7 @@ function layoutTree(
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onUpdateCondition: callbacks.onUpdateCondition,
|
||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
@@ -610,7 +620,11 @@ function appendOrphanFlowNodes(
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
callbacks: Pick<
|
||||
StrategyFlowNodeData,
|
||||
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
|
||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||
>,
|
||||
): void {
|
||||
let orphanY = 80;
|
||||
for (const node of orphans) {
|
||||
@@ -630,6 +644,7 @@ function appendOrphanFlowNodes(
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onUpdateCondition: callbacks.onUpdateCondition,
|
||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
@@ -655,12 +670,13 @@ export function logicNodeToFlow(
|
||||
| 'onDropTarget'
|
||||
| 'onDragOverTarget'
|
||||
| 'onDragLeaveTarget'
|
||||
| 'onStartCandleTypeChange'
|
||||
| 'onStartCandleTypesChange'
|
||||
| 'onDeleteStart'
|
||||
| 'onChangeLogicGateType'
|
||||
>,
|
||||
dragOverId: string | null = null,
|
||||
orphans: LogicNode[] = [],
|
||||
startMeta: Record<string, StartNodeMeta> = { [START_NODE_ID]: { candleType: '1m' } },
|
||||
startMeta: Record<string, StartNodeMeta> = defaultStartMeta(),
|
||||
extraStartIds: string[] = [],
|
||||
extraRoots: Record<string, LogicNode | null> = {},
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
@@ -668,7 +684,7 @@ export function logicNodeToFlow(
|
||||
const edges: Edge[] = [];
|
||||
|
||||
const appendStart = (startId: string, branchRoot: LogicNode | null, defaultY: number) => {
|
||||
const candleType = normalizeStartCandleType(startMeta[startId]?.candleType);
|
||||
const candleTypes = getStartCandleTypes(startMeta[startId]);
|
||||
nodes.push({
|
||||
id: startId,
|
||||
type: 'start',
|
||||
@@ -676,8 +692,9 @@ export function logicNodeToFlow(
|
||||
data: {
|
||||
label: 'START',
|
||||
signalTab,
|
||||
candleType,
|
||||
onStartCandleTypeChange: callbacks.onStartCandleTypeChange,
|
||||
candleType: candleTypes[0],
|
||||
candleTypes,
|
||||
onStartCandleTypesChange: callbacks.onStartCandleTypesChange,
|
||||
onDeleteStart: startId === START_NODE_ID ? undefined : callbacks.onDeleteStart,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
|
||||
@@ -4,13 +4,22 @@ export const START_NODE_ID = '__strategy_start__';
|
||||
export const DEFAULT_START_CANDLE = '1m';
|
||||
|
||||
export const STRATEGY_CANDLE_TYPES = [
|
||||
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d',
|
||||
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1w', '1d',
|
||||
] as const;
|
||||
|
||||
/** 그래프 START 노드 — 시간봉 체크박스 2행 배치 */
|
||||
export const START_TIMEFRAME_GRAPH_ROWS: readonly (readonly StrategyCandleType[])[] = [
|
||||
['1m', '3m', '5m', '10m', '15m'],
|
||||
['30m', '1h', '4h', '1w', '1d'],
|
||||
] as const;
|
||||
|
||||
export type StrategyCandleType = (typeof STRATEGY_CANDLE_TYPES)[number];
|
||||
|
||||
export type StartNodeMeta = {
|
||||
candleType: string;
|
||||
/** @deprecated 단일 분봉 — candleTypes 우선 */
|
||||
candleType?: string;
|
||||
/** 선택된 평가 시간봉 (마감봉마다 독립 체크) */
|
||||
candleTypes?: string[];
|
||||
};
|
||||
|
||||
/** START가 2개 이상일 때 분기 간 결합 연산 */
|
||||
@@ -27,12 +36,42 @@ export function createStartNodeId(): string {
|
||||
}
|
||||
|
||||
export function defaultStartMeta(): Record<string, StartNodeMeta> {
|
||||
return { [START_NODE_ID]: { candleType: DEFAULT_START_CANDLE } };
|
||||
return { [START_NODE_ID]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE } };
|
||||
}
|
||||
|
||||
/** START 메타에서 정렬된 평가 분봉 목록 (최소 1개) */
|
||||
export function getStartCandleTypes(meta?: StartNodeMeta | null): string[] {
|
||||
const raw = meta?.candleTypes?.length
|
||||
? meta.candleTypes
|
||||
: meta?.candleType
|
||||
? [meta.candleType]
|
||||
: [DEFAULT_START_CANDLE];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const ct of STRATEGY_CANDLE_TYPES) {
|
||||
if (raw.some(r => normalizeStartCandleType(r) === ct) && !seen.has(ct)) {
|
||||
seen.add(ct);
|
||||
out.push(ct);
|
||||
}
|
||||
}
|
||||
for (const r of raw) {
|
||||
const n = normalizeStartCandleType(r);
|
||||
if (!seen.has(n)) {
|
||||
seen.add(n);
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : [DEFAULT_START_CANDLE];
|
||||
}
|
||||
|
||||
export function formatStartCandleTypesLabel(types: string[]): string {
|
||||
const list = getStartCandleTypes({ candleTypes: types });
|
||||
return list.map(formatStrategyCandleLabel).join(' · ');
|
||||
}
|
||||
|
||||
export function normalizeStartCandleType(value: string | undefined | null): string {
|
||||
const raw = (value ?? DEFAULT_START_CANDLE).trim().toLowerCase();
|
||||
if (raw === '1d') return '1d';
|
||||
if (raw === '1d' || raw === '1w') return raw;
|
||||
return (STRATEGY_CANDLE_TYPES as readonly string[]).includes(raw) ? raw : DEFAULT_START_CANDLE;
|
||||
}
|
||||
|
||||
@@ -49,6 +88,7 @@ const CANDLE_TYPE_LABELS: Record<string, string> = {
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1w': '주간봉',
|
||||
'1d': '일봉',
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ function newIndId(): string {
|
||||
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;
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface LogicNode {
|
||||
description?: string;
|
||||
/** TIMEFRAME 노드 — 조건 판별 대상 캔들 타입 (1m, 5m, 1h …) */
|
||||
candleType?: string;
|
||||
/** TIMEFRAME 노드 — 동일 조건을 여러 분봉 마감마다 평가 */
|
||||
candleTypes?: string[];
|
||||
}
|
||||
|
||||
export interface StrategyDSLDto {
|
||||
|
||||
@@ -48,6 +48,7 @@ const CANDLE_TYPE_KO: Record<string, string> = {
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1w': '주간봉',
|
||||
'1d': '일봉',
|
||||
'1D': '일봉',
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import type { StrategyDto } from './backendApi';
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
|
||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||
|
||||
function collectUiTimeframes(strategy: StrategyDto | null | undefined): string[] {
|
||||
if (!strategy) return [];
|
||||
@@ -15,7 +16,8 @@ function collectUiTimeframes(strategy: StrategyDto | null | undefined): string[]
|
||||
...collectDslBranches(buy ?? null),
|
||||
...collectDslBranches(sell ?? null),
|
||||
]) {
|
||||
set.add(normalizeStartCandleType(b.candleType));
|
||||
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
||||
for (const ct of types) set.add(normalizeStartCandleType(ct));
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
@@ -76,7 +78,7 @@ export async function syncVirtualTargetsToBackend(
|
||||
}
|
||||
|
||||
const pinJobs = targets.map(async t => {
|
||||
const strategyId = t.strategyId ?? session.globalStrategyId;
|
||||
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
|
||||
if (strategyId == null) return;
|
||||
await pinStrategyEvaluationTimeframes(t.market, strategyId);
|
||||
});
|
||||
@@ -86,7 +88,7 @@ export async function syncVirtualTargetsToBackend(
|
||||
targets.map(t =>
|
||||
saveLiveStrategySettings({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||
isPinned: !!t.pinned,
|
||||
skipGlobalTemplate: true,
|
||||
...shared,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../utils/virtualTargetLimits';
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync';
|
||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||
import {
|
||||
loadVirtualSession,
|
||||
loadVirtualTargets,
|
||||
@@ -46,7 +47,8 @@ export async function addVirtualTarget(
|
||||
const en = meta.englishName ?? market.replace(/^KRW-/, '');
|
||||
const item: VirtualTargetItem = {
|
||||
market,
|
||||
strategyId: session.globalStrategyId,
|
||||
/** null = 상단 기본 전략 사용 */
|
||||
strategyId: null,
|
||||
koreanName: meta.koreanName,
|
||||
englishName: en,
|
||||
candleType: meta.candleType ? normalizeStartCandleType(meta.candleType) : undefined,
|
||||
@@ -82,7 +84,9 @@ export async function persistVirtualTargetPinned(market: string, pinned: boolean
|
||||
const item = loadVirtualTargets().find(t => t.market === market);
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId: item?.strategyId ?? session.globalStrategyId,
|
||||
strategyId: item
|
||||
? resolveVirtualTargetStrategyId(item, session.globalStrategyId)
|
||||
: session.globalStrategyId,
|
||||
isLiveCheck: session.running,
|
||||
isPinned: pinned,
|
||||
executionType: session.executionType,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { VirtualTargetItem } from './virtualTradingStorage';
|
||||
|
||||
/** 종목 전략 셀렉트 — 기본(전역) 전략 옵션 값 */
|
||||
export const VIRTUAL_DEFAULT_STRATEGY_VALUE = '__default__';
|
||||
|
||||
/** 종목에 전략이 지정되지 않았으면 전역(기본) 전략 ID */
|
||||
export function resolveVirtualTargetStrategyId(
|
||||
target: Pick<VirtualTargetItem, 'strategyId'>,
|
||||
globalStrategyId: number | null,
|
||||
): number | null {
|
||||
if (target.strategyId != null) return target.strategyId;
|
||||
return globalStrategyId;
|
||||
}
|
||||
|
||||
export function usesDefaultStrategy(target: Pick<VirtualTargetItem, 'strategyId'>): boolean {
|
||||
return target.strategyId == null;
|
||||
}
|
||||
|
||||
export function targetStrategySelectValue(target: Pick<VirtualTargetItem, 'strategyId'>): string {
|
||||
return target.strategyId != null ? String(target.strategyId) : VIRTUAL_DEFAULT_STRATEGY_VALUE;
|
||||
}
|
||||
|
||||
export function parseTargetStrategySelectValue(value: string): number | null {
|
||||
if (!value || value === VIRTUAL_DEFAULT_STRATEGY_VALUE) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function defaultStrategyOptionLabel(
|
||||
globalStrategyId: number | null,
|
||||
strategies: Array<{ id?: number; name?: string }>,
|
||||
): string {
|
||||
if (globalStrategyId == null) return '기본 전략 (미설정)';
|
||||
const name = strategies.find(s => s.id === globalStrategyId)?.name;
|
||||
return name ? `기본 전략 (${name})` : '기본 전략';
|
||||
}
|
||||
|
||||
/**
|
||||
* 예전에 전역 전략 ID가 종목에 복사 저장된 항목 → null(기본 전략 따름)로 정규화
|
||||
*/
|
||||
export function coalesceTargetsToDefaultStrategy(
|
||||
targets: VirtualTargetItem[],
|
||||
globalStrategyId: number | null,
|
||||
): VirtualTargetItem[] {
|
||||
if (globalStrategyId == null) return targets;
|
||||
let changed = false;
|
||||
const next = targets.map(t => {
|
||||
if (t.strategyId != null && t.strategyId === globalStrategyId) {
|
||||
changed = true;
|
||||
return { ...t, strategyId: null };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
return changed ? next : targets;
|
||||
}
|
||||
Reference in New Issue
Block a user