Files
goldenChart/frontend/src/utils/buildSignalTrendLineDrawings.ts
T
2026-06-25 02:45:39 +09:00

155 lines
5.9 KiB
TypeScript

/**
* 전략평가 차트 — 선택 봉 기준 N봉 추세선 Drawing 생성
*/
import type { Drawing } from '../types';
import type { OHLCVBar } from '../types';
import type { BacktestSettingsDto, StrategyDto } from './backendApi';
import type { ConditionDSL, LogicNode } from './strategyTypes';
import { trendLineConfigFromSettings, trendLineSegmentAt, trendLineKindForCross, isTrendLinePriceScaleField } from './trendLineGeometry';
import { calculateIndicator } from './indicatorRegistry';
import { DSL_TO_REGISTRY } from './strategyToChartIndicators';
function walkTrendLineConditions(
node: LogicNode | null | undefined,
side: 'buy' | 'sell',
out: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }>,
): void {
if (!node) return;
if (node.type === 'TIMEFRAME') {
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
return;
}
if (node.type === 'NOT') {
const child = node.children?.[0];
if (child) walkTrendLineConditions(child, side, out);
return;
}
if (node.type === 'AND' || node.type === 'OR') {
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
return;
}
if ((!node.type || node.type === 'CONDITION') && node.condition) {
const ct = node.condition.conditionType;
if (ct === 'TREND_LINE_CROSS_UP' || ct === 'TREND_LINE_CROSS_DOWN') {
out.push({ side, cond: node.condition });
}
}
}
function registryType(dslType: string): string {
return DSL_TO_REGISTRY[dslType] ?? dslType;
}
function isPriceScaleField(field?: string): boolean {
if (!field) return true;
const f = field.toUpperCase();
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|| f.includes('BAND') || f.includes('DC_');
}
async function resolveSourceValues(
bars: OHLCVBar[],
cond: ConditionDSL,
getParams: (type: string) => Record<string, number | string | boolean>,
): Promise<number[] | null> {
const lf = (cond.leftField ?? 'CLOSE_PRICE').toUpperCase();
if (lf === 'CLOSE_PRICE' || lf === 'CURRENT') {
return bars.map(b => b.close);
}
if (lf === 'HIGH_PRICE') return bars.map(b => b.high);
if (lf === 'LOW_PRICE') return bars.map(b => b.low);
if (lf === 'OPEN_PRICE') return bars.map(b => b.open);
const regType = registryType(cond.indicatorType);
try {
const params = { ...getParams(regType), ...(cond.params ?? {}) };
const { plots } = await calculateIndicator(regType, bars, params);
const plotKeys = Object.keys(plots);
if (plotKeys.length === 0) return null;
let plotKey = plotKeys[0]!;
if (lf.includes('K') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('SIGNAL') && plots.plot1) plotKey = 'plot1';
else if (lf.includes('MACD') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('UPPER') && plots.plot2) plotKey = 'plot2';
else if (lf.includes('LOWER') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('MIDDLE') && plots.plot1) plotKey = 'plot1';
const plot = plots[plotKey as keyof typeof plots];
if (!plot || !Array.isArray(plot)) return null;
const byTime = new Map(plot.map((p: { time: number; value: number }) => [p.time, p.value]));
return bars.map(b => {
const v = byTime.get(b.time);
return v != null && Number.isFinite(v) ? v : NaN;
});
} catch {
return bars.map(b => b.close);
}
}
/** 선택 봉(barIndex) 기준 추세선만 반환 — 시그널 전체 순회하지 않음 */
export async function buildSignalTrendLineDrawings(opts: {
bars: OHLCVBar[];
/** 캔들 선택봉 인덱스 */
barIndex: number;
strategy: StrategyDto | null;
settings?: BacktestSettingsDto | null;
getParams: (type: string) => Record<string, number | string | boolean>;
}): Promise<Drawing[]> {
const { bars, barIndex, strategy, settings, getParams } = opts;
if (!strategy || bars.length === 0 || barIndex < 0 || barIndex >= bars.length) return [];
const conditions: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }> = [];
walkTrendLineConditions(strategy.buyCondition as LogicNode | null, 'buy', conditions);
walkTrendLineConditions(strategy.sellCondition as LogicNode | null, 'sell', conditions);
if (conditions.length === 0) return [];
const priceConds = conditions.filter(c => isPriceScaleField(c.cond.leftField));
if (priceConds.length === 0) return [];
const config = trendLineConfigFromSettings(settings);
const barTimes = bars.map(b => b.time);
const drawings: Drawing[] = [];
const sourceCache = new Map<string, number[]>();
for (const { cond } of priceConds) {
const key = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
if (!sourceCache.has(key)) {
const values = await resolveSourceValues(bars, cond, getParams);
if (values) sourceCache.set(key, values);
}
}
for (const { side, cond } of priceConds) {
const cacheKey = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
const sourceValues = sourceCache.get(cacheKey);
if (!sourceValues) continue;
const lookback = Math.max(2, cond.lookbackPeriod ?? config.defaultLookback);
const kind = trendLineKindForCross(cond.conditionType, side);
const priceScale = isTrendLinePriceScaleField(cond.leftField);
const seg = trendLineSegmentAt(
barTimes, sourceValues, bars, barIndex, lookback, config, kind, priceScale,
);
if (!seg) continue;
const id = `tl-${barIndex}-${side}-${cond.conditionType}-${lookback}-${config.mode}`;
drawings.push({
id,
type: 'trendline',
points: [
{ time: seg.startTimeSec, price: seg.startPrice },
{ time: seg.endTimeSec, price: seg.endPrice },
],
color: side === 'buy' ? '#26a69a' : '#ef5350',
lineWidth: 2,
style: 'dashed',
visible: true,
});
}
return drawings;
}