210 lines
6.2 KiB
TypeScript
210 lines
6.2 KiB
TypeScript
import type { OHLCVBar } from '../types';
|
|
import type { StrategyDto } from './backendApi';
|
|
import { extractVirtualConditions } from './virtualStrategyConditions';
|
|
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
|
|
|
|
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',
|
|
ICHIMOKU: 'IchimokuCloud',
|
|
ATR: 'ATR',
|
|
MFI: 'MFI',
|
|
};
|
|
|
|
/** 메인 캔들 pane 오버레이 — 하단 스파크 pane 제외 */
|
|
const OVERLAY_DSL = new Set([
|
|
'MA', 'EMA', 'BOLLINGER', 'ICHIMOKU', 'DONCHIAN', 'NEW_HIGH', 'NEW_LOW', 'DISPARITY',
|
|
]);
|
|
|
|
export interface OscillatorPaneSpec {
|
|
key: string;
|
|
registryType: string;
|
|
label: string;
|
|
period?: number;
|
|
}
|
|
|
|
export interface OscillatorPaneData {
|
|
spec: OscillatorPaneSpec;
|
|
values: number[];
|
|
refLines?: number[];
|
|
color: string;
|
|
}
|
|
|
|
const PANE_COLORS: Record<string, string> = {
|
|
RSI: '#a78bfa',
|
|
MACD: '#38bdf8',
|
|
CCI: '#fbbf24',
|
|
Stochastic: '#f472b6',
|
|
WilliamsPercentRange: '#c084fc',
|
|
ADX: '#34d399',
|
|
MFI: '#fb923c',
|
|
OBV: '#94a3b8',
|
|
TRIX: '#22d3ee',
|
|
VolumeOscillator: '#86efac',
|
|
VR: '#fcd34d',
|
|
Psychological: '#e879f9',
|
|
NewPsychological: '#e879f9',
|
|
InvestPsychological: '#e879f9',
|
|
};
|
|
|
|
function computeRsi(closes: number[], period = 14): number[] {
|
|
if (closes.length < period + 1) return [];
|
|
const out: number[] = [];
|
|
let avgGain = 0;
|
|
let avgLoss = 0;
|
|
for (let i = 1; i <= period; i++) {
|
|
const d = closes[i] - closes[i - 1];
|
|
if (d >= 0) avgGain += d; else avgLoss -= d;
|
|
}
|
|
avgGain /= period;
|
|
avgLoss /= period;
|
|
for (let i = period; i < closes.length; i++) {
|
|
const d = closes[i] - closes[i - 1];
|
|
const gain = d > 0 ? d : 0;
|
|
const loss = d < 0 ? -d : 0;
|
|
avgGain = (avgGain * (period - 1) + gain) / period;
|
|
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
|
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
|
out.push(100 - 100 / (1 + rs));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function computeMacd(closes: number[]): number[] {
|
|
const ema = (arr: number[], p: number) => {
|
|
const k = 2 / (p + 1);
|
|
let v = arr[0];
|
|
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
|
|
};
|
|
const e12 = ema(closes, 12);
|
|
const e26 = ema(closes, 26);
|
|
return e12.map((v, i) => v - e26[i]);
|
|
}
|
|
|
|
function computeCci(bars: OHLCVBar[], period = 20): number[] {
|
|
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
|
|
const out: number[] = [];
|
|
for (let i = period; i < tp.length; i++) {
|
|
const slice = tp.slice(i - period, i);
|
|
const sma = slice.reduce((a, b) => a + b, 0) / slice.length;
|
|
const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length;
|
|
out.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function computeSeries(bars: OHLCVBar[], registryType: string, period?: number): number[] {
|
|
const closes = bars.map(b => b.close);
|
|
switch (registryType) {
|
|
case 'RSI':
|
|
return computeRsi(closes, period ?? 14);
|
|
case 'MACD':
|
|
return computeMacd(closes);
|
|
case 'CCI':
|
|
return computeCci(bars, period ?? 20);
|
|
default:
|
|
return computeRsi(closes, 14);
|
|
}
|
|
}
|
|
|
|
function refLinesFor(registryType: string): number[] | undefined {
|
|
if (registryType === 'RSI') return [30, 50, 70];
|
|
if (registryType === 'CCI') return [-100, 0, 100];
|
|
if (registryType === 'MACD') return [0];
|
|
return undefined;
|
|
}
|
|
|
|
export function collectStrategyOscillatorPanes(strategy: StrategyDto | undefined): OscillatorPaneSpec[] {
|
|
if (!strategy) return [];
|
|
const rows = extractVirtualConditions(strategy);
|
|
const seen = new Set<string>();
|
|
const out: OscillatorPaneSpec[] = [];
|
|
for (const row of rows) {
|
|
if (OVERLAY_DSL.has(row.indicatorType)) continue;
|
|
const registryType = DSL_TO_REGISTRY[row.indicatorType] ?? row.indicatorType;
|
|
if (OVERLAY_DSL.has(registryType) || registryType === 'SMA' || registryType === 'EMA'
|
|
|| registryType === 'BollingerBands' || registryType === 'IchimokuCloud') {
|
|
continue;
|
|
}
|
|
if (seen.has(registryType)) continue;
|
|
seen.add(registryType);
|
|
out.push({
|
|
key: registryType,
|
|
registryType,
|
|
label: formatIndicatorDisplayLabel(row.indicatorType),
|
|
period: undefined,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function buildOscillatorPaneData(
|
|
bars: OHLCVBar[],
|
|
specs: OscillatorPaneSpec[],
|
|
): OscillatorPaneData[] {
|
|
if (bars.length < 20) return [];
|
|
const out: OscillatorPaneData[] = [];
|
|
for (const spec of specs) {
|
|
const values = computeSeries(bars, spec.registryType, spec.period).slice(-80);
|
|
if (values.length < 2) continue;
|
|
out.push({
|
|
spec,
|
|
values,
|
|
refLines: refLinesFor(spec.registryType),
|
|
color: PANE_COLORS[spec.registryType] ?? '#94a3b8',
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function defaultOscillatorPaneSpecs(): OscillatorPaneSpec[] {
|
|
return [
|
|
{ key: 'RSI', registryType: 'RSI', label: 'RSI (14)', period: 14 },
|
|
{ key: 'MACD', registryType: 'MACD', label: 'MACD (12, 26, 9)' },
|
|
];
|
|
}
|
|
|
|
/** StrategyOscillatorPanes 에 실제로 그려지는 보조 pane 개수 */
|
|
export function countAuxiliaryOscillatorPaneCount(strategy: StrategyDto | undefined): number {
|
|
const specs = collectStrategyOscillatorPanes(strategy);
|
|
return (specs.length > 0 ? specs : defaultOscillatorPaneSpecs()).length;
|
|
}
|
|
|
|
/** TradingChart 보조 pane(비오버레이 지표) 개수 */
|
|
export function countNonOverlayIndicatorPanes(
|
|
indicators: Array<{ type: string }>,
|
|
isOverlay: (type: string) => boolean,
|
|
): number {
|
|
return indicators.filter(ind => !isOverlay(ind.type)).length;
|
|
}
|
|
|
|
/**
|
|
* 캔들 vs 보조지표 영역 flex 비율
|
|
* - 보조 1개: 2:1
|
|
* - 보조 2~5개: 1:1
|
|
* - 보조 6개+: 1:2
|
|
*/
|
|
export function chartPaneFlexRatio(auxPaneCount: number): { candle: number; aux: number } {
|
|
const n = Math.max(0, auxPaneCount);
|
|
if (n === 0) return { candle: 1, aux: 0 };
|
|
if (n === 1) return { candle: 2, aux: 1 };
|
|
if (n <= 5) return { candle: 1, aux: 1 };
|
|
return { candle: 1, aux: 2 };
|
|
}
|