111 lines
3.9 KiB
TypeScript
111 lines
3.9 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;
|
|
}
|
|
|
|
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
|
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(-40));
|
|
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(-40).map((v, i) => v - e26[closes.length - 40 + 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(-40));
|
|
} catch { /* ignore */ }
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [market, timeframe]);
|
|
|
|
const panels = useMemo(() => [
|
|
{ label: 'RSI', values: rsi, color: '#a78bfa', ref: [30, 70] },
|
|
{ label: 'MACD', values: macd, color: '#38bdf8' },
|
|
{ label: 'CCI', values: cci, color: '#fbbf24', ref: [-100, 100] },
|
|
], [rsi, macd, cci]);
|
|
|
|
return (
|
|
<div className="ptd-indicators">
|
|
{panels.map(p => (
|
|
<div key={p.label} className="ptd-ind-panel">
|
|
<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;
|