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 = ({ market, timeframe = '1h', stacked = false }) => { const [rsi, setRsi] = React.useState([]); const [macd, setMacd] = React.useState([]); const [cci, setCci] = React.useState([]); 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 (
{panels.map(p => (
{p.label}
{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 ( ); })} {p.values.length > 1 && ( )}
))}
); }; export default PaperIndicatorPanel;