99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
import React, { useMemo } from 'react';
|
||
import type { OHLCVBar } from '../../types';
|
||
|
||
function spark(values: number[], w: number, h: number, min?: number, max?: number): string {
|
||
if (!values.length) return '';
|
||
const lo = min ?? Math.min(...values);
|
||
const hi = max ?? Math.max(...values);
|
||
const range = hi - lo || 1;
|
||
return values.map((v, i) => {
|
||
const x = (i / Math.max(values.length - 1, 1)) * w;
|
||
const y = h - ((v - lo) / range) * (h - 6) - 3;
|
||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).join(' ');
|
||
}
|
||
|
||
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]);
|
||
}
|
||
|
||
interface Props {
|
||
bars: OHLCVBar[];
|
||
}
|
||
|
||
/** 백테스팅 분석 — RSI / MACD 하단 pane (참조 UI) */
|
||
const BacktestOscillatorPanes: React.FC<Props> = ({ bars }) => {
|
||
const { rsiPath, macdPath, rsiLast, macdLast } = useMemo(() => {
|
||
const closes = bars.map(b => b.close);
|
||
const rsi = computeRsi(closes).slice(-80);
|
||
const macd = computeMacd(closes).slice(-80);
|
||
return {
|
||
rsiPath: spark(rsi, 280, 52, 0, 100),
|
||
macdPath: spark(macd, 280, 52),
|
||
rsiLast: rsi[rsi.length - 1],
|
||
macdLast: macd[macd.length - 1],
|
||
};
|
||
}, [bars]);
|
||
|
||
if (bars.length < 20) return null;
|
||
|
||
return (
|
||
<div className="btd-osc-stack">
|
||
<div className="btd-osc-pane">
|
||
<div className="btd-osc-head">
|
||
<span className="btd-osc-label">RSI (14)</span>
|
||
<span className="btd-osc-val">{rsiLast != null ? rsiLast.toFixed(1) : '–'}</span>
|
||
</div>
|
||
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
|
||
<line x1="0" y1="15" x2="280" y2="15" className="btd-osc-ref btd-osc-ref--hi" />
|
||
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
|
||
<line x1="0" y1="37" x2="280" y2="37" className="btd-osc-ref btd-osc-ref--lo" />
|
||
<path d={rsiPath} className="btd-osc-line btd-osc-line--rsi" fill="none" />
|
||
</svg>
|
||
</div>
|
||
<div className="btd-osc-pane">
|
||
<div className="btd-osc-head">
|
||
<span className="btd-osc-label">MACD (12, 26, 9)</span>
|
||
<span className="btd-osc-val">{macdLast != null ? macdLast.toFixed(2) : '–'}</span>
|
||
</div>
|
||
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
|
||
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
|
||
<path d={macdPath} className="btd-osc-line btd-osc-line--macd" fill="none" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default BacktestOscillatorPanes;
|