전략분봉 저장 에러 문제 수정

This commit is contained in:
Macbook
2026-05-23 21:08:46 +09:00
parent eaf067db1c
commit 70ac67afe7
18 changed files with 2445 additions and 498 deletions
@@ -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;