d16456cb92
# 사용자별 격리 - JWT 토큰에 uid 추가 (auth.get_uid 헬퍼) - PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT - SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환) - 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가 - alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화 - core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용 - ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드 # 사용자 관리 (admin only) - users_db: delete_user / admin_reset_password / set_role - /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호) - /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달 - 사이드바 adminOnly 필터 — admin role 만 메뉴 노출 # 대시보드 개선 - 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200) - 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산) - 메트릭 카드 분리 (실거래 vs 실시간 open) # 안정성 - api.ts: error.detail array/object 안전 처리 ([object Object] 방지) - Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
8.0 KiB
TypeScript
159 lines
8.0 KiB
TypeScript
'use client';
|
|
import dynamic from 'next/dynamic';
|
|
const Plot = dynamic(() => import('react-plotly.js'), { ssr: false });
|
|
|
|
interface Row {
|
|
open_time: string; open: number; high: number; low: number; close: number; volume: number;
|
|
taker_buy_vol: number; taker_sell_vol: number;
|
|
MA7?: number; MA25?: number; MA99?: number; MA200?: number;
|
|
BB_upper?: number; BB_lower?: number; BB_mid?: number;
|
|
RSI?: number; StochRSI_k?: number; StochRSI_d?: number;
|
|
MACD?: number; MACD_signal?: number; MACD_hist?: number;
|
|
sumOpenInterest?: number; fundingRate?: number; longShortRatio?: number;
|
|
long_signal?: boolean; short_signal?: boolean;
|
|
strong_long_signal?: boolean; strong_short_signal?: boolean;
|
|
vol_long_signal?: boolean; vol_short_signal?: boolean;
|
|
reversal_long_signal?: boolean; reversal_short_signal?: boolean;
|
|
exhaustion_long?: boolean; exhaustion_short?: boolean;
|
|
short_caution_signal?: boolean;
|
|
}
|
|
|
|
const C = {
|
|
green: '#26a69a', red: '#ef5350', yellow: '#f5ce05', blue: '#2962ff',
|
|
purple: '#9c27b0', orange: '#ff9800',
|
|
MA7: '#f5ce05', MA25: '#ef5350', MA99: '#9c27b0', MA200: '#2962ff',
|
|
grid: '#e0e3eb', text: '#131722',
|
|
};
|
|
|
|
const SIG_MARKER = [
|
|
{ col: 'strong_long_signal', sym: 'triangle-up', color: C.green, name: '강한 롱', side: 'low' },
|
|
{ col: 'strong_short_signal', sym: 'triangle-down', color: C.red, name: '강한 숏', side: 'high' },
|
|
{ col: 'long_signal', sym: 'triangle-up', color: C.blue, name: '롱', side: 'low' },
|
|
{ col: 'short_signal', sym: 'triangle-down', color: C.orange, name: '숏', side: 'high' },
|
|
{ col: 'vol_long_signal', sym: 'triangle-up', color: '#00bfff',name: '볼륨 롱', side: 'low' },
|
|
{ col: 'vol_short_signal', sym: 'triangle-down', color: C.orange, name: '볼륨 숏', side: 'high' },
|
|
{ col: 'short_caution_signal', sym: 'diamond', color: '#ff00ff',name: '숏 주의', side: 'high' },
|
|
{ col: 'exhaustion_long', sym: 'star', color: C.green, name: '매도소진', side: 'low' },
|
|
{ col: 'exhaustion_short', sym: 'star', color: C.red, name: '매수소진', side: 'high' },
|
|
];
|
|
|
|
export default function Chart({ rows, lastPrice, mobile = false, showLegend = false }: { rows: Row[]; lastPrice?: number | null; mobile?: boolean; showLegend?: boolean }) {
|
|
if (!rows || rows.length === 0) return <div className="text-slate-400 text-sm py-8 text-center">데이터 로딩 중...</div>;
|
|
|
|
const t = rows.map(r => r.open_time);
|
|
const data: any[] = [];
|
|
|
|
// 캔들
|
|
data.push({
|
|
type: 'candlestick',
|
|
x: t,
|
|
open: rows.map(r => r.open),
|
|
high: rows.map(r => r.high),
|
|
low: rows.map(r => r.low),
|
|
close: rows.map(r => r.close),
|
|
increasing: { line: { color: C.green }, fillcolor: C.green },
|
|
decreasing: { line: { color: C.red }, fillcolor: C.red },
|
|
name: '캔들',
|
|
yaxis: 'y',
|
|
xaxis: 'x',
|
|
showlegend: false,
|
|
});
|
|
|
|
// BB
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.BB_upper), line: { color: 'rgba(41,98,255,0.6)', width: 0.8 }, name: 'BB상단', showlegend: false, yaxis: 'y' });
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.BB_lower), line: { color: 'rgba(41,98,255,0.6)', width: 0.8 }, fill: 'tonexty', fillcolor: 'rgba(41,98,255,0.10)', name: 'BB하단', showlegend: false, yaxis: 'y' });
|
|
|
|
// MA
|
|
for (const [k, color] of [['MA200', C.MA200], ['MA99', C.MA99], ['MA25', C.MA25], ['MA7', C.MA7]] as const) {
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => (r as any)[k]), line: { color, width: 1.2 }, name: k, yaxis: 'y' });
|
|
}
|
|
|
|
// 신호 마커
|
|
for (const m of SIG_MARKER) {
|
|
const xs: any[] = []; const ys: any[] = [];
|
|
for (const r of rows) {
|
|
if ((r as any)[m.col]) {
|
|
xs.push(r.open_time);
|
|
ys.push(m.side === 'low' ? r.low * 0.9998 : r.high * 1.0002);
|
|
}
|
|
}
|
|
if (xs.length > 0) {
|
|
data.push({ type: 'scatter', mode: 'markers', x: xs, y: ys, marker: { symbol: m.sym, color: m.color, size: 10 }, name: m.name, yaxis: 'y' });
|
|
}
|
|
}
|
|
|
|
// Taker Net (subplot 2)
|
|
const takerNet = rows.map(r => (r.taker_buy_vol || 0) - (r.taker_sell_vol || 0));
|
|
data.push({
|
|
type: 'bar', x: t, y: takerNet,
|
|
marker: { color: takerNet.map(v => v >= 0 ? C.green : C.red) },
|
|
name: 'Taker Net', yaxis: 'y2', xaxis: 'x', showlegend: false,
|
|
});
|
|
|
|
// OI (subplot 3)
|
|
if (rows.some(r => r.sumOpenInterest != null)) {
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.sumOpenInterest), line: { color: C.purple, width: 1.5 }, fill: 'tozeroy', fillcolor: 'rgba(156,39,176,0.15)', name: 'OI', yaxis: 'y3', showlegend: false });
|
|
}
|
|
|
|
// FR (subplot 4)
|
|
if (rows.some(r => r.fundingRate != null)) {
|
|
data.push({ type: 'bar', x: t, y: rows.map(r => r.fundingRate), marker: { color: rows.map(r => (r.fundingRate ?? 0) < 0 ? C.red : C.green) }, name: 'FR', yaxis: 'y4', showlegend: false });
|
|
}
|
|
|
|
// L/S (subplot 5)
|
|
if (rows.some(r => r.longShortRatio != null)) {
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.longShortRatio), line: { color: C.orange, width: 1.5 }, name: 'L/S', yaxis: 'y5', showlegend: false });
|
|
}
|
|
|
|
// RSI / StochRSI (subplot 6)
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.RSI), line: { color: C.blue, width: 1.5 }, name: 'RSI', yaxis: 'y6', showlegend: false });
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.StochRSI_k), line: { color: C.red, width: 1.2 }, name: 'StochRSI K', yaxis: 'y6', showlegend: false });
|
|
|
|
// MACD (subplot 7)
|
|
data.push({ type: 'bar', x: t, y: rows.map(r => r.MACD_hist), marker: { color: rows.map(r => (r.MACD_hist ?? 0) >= 0 ? C.green : C.red) }, name: 'MACD H', yaxis: 'y7', showlegend: false });
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.MACD), line: { color: C.blue, width: 1.2 }, name: 'MACD', yaxis: 'y7', showlegend: false });
|
|
data.push({ type: 'scatter', x: t, y: rows.map(r => r.MACD_signal), line: { color: C.orange, width: 1.2 }, name: 'Signal', yaxis: 'y7', showlegend: false });
|
|
|
|
// 모바일: 메인 차트 더 크게 + 하단 row 컴팩트 (RSI 만 강조, 나머지 작게)
|
|
const layout: any = {
|
|
height: mobile ? 760 : 1400,
|
|
paper_bgcolor: '#ffffff',
|
|
plot_bgcolor: '#ffffff',
|
|
font: { color: C.text, size: mobile ? 10 : 11, family: 'Pretendard, Noto Sans KR, sans-serif' },
|
|
margin: { l: mobile ? 42 : 60, r: mobile ? 16 : 70, t: 16, b: 16 },
|
|
hovermode: 'x unified',
|
|
dragmode: 'pan',
|
|
showlegend: showLegend,
|
|
legend: { orientation: 'h', x: 0, y: 1.02, yanchor: 'bottom', font: { size: 10 }, bgcolor: 'rgba(255,255,255,0.95)' },
|
|
xaxis: { rangeslider: { visible: false }, gridcolor: C.grid, showspikes: false },
|
|
yaxis: { domain: mobile ? [0.55, 1.0] : [0.62, 1.0], gridcolor: C.grid },
|
|
yaxis2: { domain: mobile ? [0.46, 0.54] : [0.52, 0.61], gridcolor: C.grid, title: { text: 'Taker' } },
|
|
yaxis3: { domain: mobile ? [0.37, 0.45] : [0.42, 0.51], gridcolor: C.grid, title: { text: 'OI' } },
|
|
yaxis4: { domain: mobile ? [0.28, 0.36] : [0.32, 0.41], gridcolor: C.grid, title: { text: 'FR' } },
|
|
yaxis5: { domain: mobile ? [0.19, 0.27] : [0.22, 0.31], gridcolor: C.grid, title: { text: 'L/S' } },
|
|
yaxis6: { domain: mobile ? [0.10, 0.18] : [0.11, 0.21], gridcolor: C.grid, title: { text: 'RSI' }, range: [0, 100] },
|
|
yaxis7: { domain: mobile ? [0.0, 0.09] : [0.0, 0.10], gridcolor: C.grid, title: { text: 'MACD' } },
|
|
shapes: lastPrice ? [{
|
|
type: 'line', xref: 'paper', yref: 'y',
|
|
x0: 0, x1: 1, y0: lastPrice, y1: lastPrice,
|
|
line: { color: C.yellow, width: 1, dash: 'dash' },
|
|
}] : [],
|
|
annotations: lastPrice ? [{
|
|
x: t[t.length - 1], y: lastPrice, yref: 'y',
|
|
text: `▶ ${lastPrice.toLocaleString(undefined, { maximumFractionDigits: 1 })}`,
|
|
showarrow: false, xanchor: 'left', font: { color: C.yellow, size: 12 },
|
|
bgcolor: 'rgba(245,206,5,0.15)', bordercolor: C.yellow,
|
|
}] : [],
|
|
};
|
|
|
|
return (
|
|
<Plot
|
|
data={data}
|
|
layout={layout}
|
|
config={{ scrollZoom: true, displayModeBar: true, modeBarButtonsToRemove: ['lasso2d', 'select2d'], displaylogo: false }}
|
|
style={{ width: '100%' }}
|
|
useResizeHandler
|
|
/>
|
|
);
|
|
}
|