투자관리 차트탭 기능 수정
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user