투자관리 차트탭 기능 수정

This commit is contained in:
Macbook
2026-05-31 16:31:25 +09:00
parent 1b7c39e11f
commit 78f00dbaa5
14 changed files with 935 additions and 111 deletions
@@ -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 };
}