추세선 전략추가
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 전략평가 차트 — 선택 봉 기준 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;
|
||||
}
|
||||
Reference in New Issue
Block a user