전략분봉 저장 에러 문제 수정
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
onPick?: (price: number, rowType: 'ask' | 'bid') => void;
|
||||
fillHeight?: boolean;
|
||||
hideHeader?: boolean;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
function fmtPrice(p: number): string {
|
||||
if (!Number.isFinite(p)) return '-';
|
||||
return p >= 1000 ? p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : p.toFixed(4);
|
||||
}
|
||||
|
||||
function fmtSize(s: number): string {
|
||||
if (!Number.isFinite(s)) return '-';
|
||||
if (s >= 1000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
return s.toFixed(4);
|
||||
}
|
||||
|
||||
const PaperCompactOrderbook: React.FC<Props> = memo(({
|
||||
market,
|
||||
onPick,
|
||||
fillHeight = false,
|
||||
hideHeader = false,
|
||||
depth = 8,
|
||||
}) => {
|
||||
const { orderbook } = useUpbitOrderbook(market);
|
||||
const rowCount = fillHeight ? Math.max(depth, 12) : depth;
|
||||
const asks = orderbook.asks.slice(0, rowCount).reverse();
|
||||
const bids = orderbook.bids.slice(0, rowCount);
|
||||
const maxSize = Math.max(
|
||||
...asks.map(a => a.size),
|
||||
...bids.map(b => b.size),
|
||||
1,
|
||||
);
|
||||
const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}`}>
|
||||
{!hideHeader && <div className="ptd-ob-head">호가 (Depth)</div>}
|
||||
<div className="ptd-ob-body">
|
||||
{asks.map(a => (
|
||||
<button
|
||||
key={`a-${a.price}`}
|
||||
type="button"
|
||||
className="ptd-ob-row ptd-ob-row--ask"
|
||||
onClick={() => onPick?.(a.price, 'ask')}
|
||||
>
|
||||
<span className="ptd-ob-bar" style={{ width: `${(a.size / maxSize) * 100}%` }} />
|
||||
<span className="ptd-ob-price">{fmtPrice(a.price)}</span>
|
||||
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>
|
||||
{bids.map(b => (
|
||||
<button
|
||||
key={`b-${b.price}`}
|
||||
type="button"
|
||||
className="ptd-ob-row ptd-ob-row--bid"
|
||||
onClick={() => onPick?.(b.price, 'bid')}
|
||||
>
|
||||
<span className="ptd-ob-bar" style={{ width: `${(b.size / maxSize) * 100}%` }} />
|
||||
<span className="ptd-ob-price">{fmtPrice(b.price)}</span>
|
||||
<span className="ptd-ob-size">{fmtSize(b.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PaperCompactOrderbook.displayName = 'PaperCompactOrderbook';
|
||||
export default PaperCompactOrderbook;
|
||||
@@ -0,0 +1,110 @@
|
||||
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;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../../types';
|
||||
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
||||
|
||||
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1D'];
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
wsLabel?: string;
|
||||
}
|
||||
|
||||
const PaperMiniChart: React.FC<Props> = ({ market, wsLabel = 'WebSocket <100ms' }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return undefined;
|
||||
const chart = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: '#121520' },
|
||||
textColor: '#9aa5ce',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(122,162,247,0.06)' },
|
||||
horzLines: { color: 'rgba(122,162,247,0.06)' },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(122,162,247,0.12)' },
|
||||
timeScale: { borderColor: 'rgba(122,162,247,0.12)' },
|
||||
crosshair: { mode: 1 },
|
||||
});
|
||||
const series = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#22c55e',
|
||||
downColor: '#ef4444',
|
||||
borderUpColor: '#22c55e',
|
||||
borderDownColor: '#ef4444',
|
||||
wickUpColor: '#22c55e',
|
||||
wickDownColor: '#ef4444',
|
||||
});
|
||||
chartRef.current = chart;
|
||||
seriesRef.current = series;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (containerRef.current) {
|
||||
chart.applyOptions({
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
});
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const bars = await fetchUpbitCandles(market, timeframe, 200);
|
||||
if (cancelled || !seriesRef.current) return;
|
||||
seriesRef.current.setData(
|
||||
bars.map(b => ({
|
||||
time: b.time as Time,
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
})),
|
||||
);
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe]);
|
||||
|
||||
return (
|
||||
<div className="ptd-chart-wrap">
|
||||
<div className="ptd-chart-head">
|
||||
<span className="ptd-chart-title">실시간 분석 차트</span>
|
||||
<span className="ptd-ws-badge">{wsLabel} 실시간 데이터 반영</span>
|
||||
</div>
|
||||
<div ref={containerRef} className="ptd-chart-canvas" />
|
||||
{loading && <div className="ptd-chart-loading">차트 로딩…</div>}
|
||||
<div className="ptd-tf-row">
|
||||
{TF_OPTIONS.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`ptd-tf-btn${timeframe === tf ? ' ptd-tf-btn--active' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperMiniChart;
|
||||
Reference in New Issue
Block a user