Files
goldenChart/frontend/src/components/paper/PaperIndicatorPanel.tsx
T
2026-05-24 20:13:21 +09:00

116 lines
4.1 KiB
TypeScript

import React, { useMemo } from 'react';
import { fetchUpbitCandles } from '../../utils/upbitApi';
import type { Timeframe } from '../../types';
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 sparkPath(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 - 4) - 2;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
}
interface Props {
market: string;
timeframe?: Timeframe;
/** 실시간 차트처럼 세로 스택 pane */
stacked?: boolean;
}
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h', stacked = false }) => {
const [rsi, setRsi] = React.useState<number[]>([]);
const [macd, setMacd] = React.useState<number[]>([]);
const [cci, setCci] = React.useState<number[]>([]);
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const bars = await fetchUpbitCandles(market, timeframe, 120);
if (cancelled) return;
const closes = bars.map(b => b.close);
setRsi(computeRsi(closes).slice(-60));
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);
setMacd(e12.slice(-60).map((v, i) => v - e26[closes.length - 60 + i]));
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
const cciVals: number[] = [];
for (let i = 20; i < tp.length; i++) {
const slice = tp.slice(i - 20, 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;
cciVals.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
}
setCci(cciVals.slice(-60));
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [market, timeframe]);
const panels = useMemo(() => [
{ label: 'RSI (14)', values: rsi, color: '#a78bfa', ref: [30, 70] as number[] },
{ label: 'MACD', values: macd, color: '#38bdf8' },
{ label: 'CCI (20)', values: cci, color: '#fbbf24', ref: [-100, 100] as number[] },
], [rsi, macd, cci]);
const rootClass = stacked ? 'ptd-ind-stack' : 'ptd-indicators';
const itemClass = stacked ? 'ptd-ind-row' : 'ptd-ind-panel';
return (
<div className={rootClass}>
{panels.map(p => (
<div key={p.label} className={itemClass}>
<div className="ptd-ind-label">{p.label}</div>
<svg viewBox="0 0 120 48" className="ptd-ind-svg" preserveAspectRatio="none">
{p.ref?.map((r, i) => {
const lo = p.ref![0];
const hi = p.ref![1];
const y = 48 - ((r - lo) / (hi - lo)) * 44 - 2;
return (
<line key={i} x1="0" y1={y} x2="120" y2={y} stroke="rgba(122,162,247,0.15)" strokeDasharray="2 2" />
);
})}
{p.values.length > 1 && (
<path d={sparkPath(p.values, 120, 48)} fill="none" stroke={p.color} strokeWidth="1.5" />
)}
</svg>
</div>
))}
</div>
);
};
export default PaperIndicatorPanel;