goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env.local
.env*.local
*.log
+5
View File
@@ -0,0 +1,5 @@
# GoldenChart Frontend 환경 변수
# 이 파일을 .env.local 로 복사하여 사용
# Spring Boot 백엔드 URL (Vite 프록시 사용 시 /api 로 통일)
VITE_API_BASE_URL=/api
+26
View File
@@ -0,0 +1,26 @@
# ── 1단계: 빌드 ───────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# 의존성 레이어 캐시 (package*.json 변경 시만 재설치)
COPY package*.json ./
RUN npm ci
# 소스 복사 후 프로덕션 빌드
# Docker 환경에서 /api 는 nginx 가 backend:8080 으로 프록시하므로 그대로 사용
COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
# ── 2단계: 서비스 ─────────────────────────────────────────────────────────────
FROM nginx:1.27-alpine AS production
# 빌드 결과물 복사
COPY --from=builder /app/dist /usr/share/nginx/html
# SPA 라우팅 + 정적 파일 캐시 설정
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
<title>GoldenChart</title>
<link rel="icon" href="data:," />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
# WebSocket Upgrade 헤더 동적 처리 (Connection: upgrade ↔ close)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 외부 호스트명 DNS 조회용 resolver (Upbit + Backend 프록시에 필요)
resolver 127.0.0.11 8.8.8.8 8.8.4.4 valid=30s;
resolver_timeout 5s;
# ── STOMP / SockJS (Spring context-path /api → /api/ws/trading) ───
# SockJS 가 /api/ws/trading/{id}/{session}/websocket 으로 업그레이드하므로
# 일반 REST 프록시와 분리해 Upgrade 헤더·장시간 타임아웃을 반드시 설정한다.
location /api/ws/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
proxy_request_buffering off;
gzip off;
chunked_transfer_encoding on;
}
# ── GoldenChart Spring Boot Backend 프록시 ────────────────────────
# 브라우저 → /api/... → http://backend:8080/api/...
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_buffering off;
}
# ── Upbit REST API 역방향 프록시 ──────────────────────────────────────
# 브라우저 → /upbit-api/v1/... → https://api.upbit.com/v1/...
# CORS 없이 동일 오리진 요청으로 처리됨 (dev: Vite proxy, prod: nginx proxy)
location /upbit-api/ {
proxy_pass https://api.upbit.com/;
proxy_ssl_server_name on;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header Host api.upbit.com;
proxy_set_header Accept application/json;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin *;
proxy_cache_bypass $http_upgrade;
}
# ── Upbit WebSocket 역방향 프록시 ─────────────────────────────────────
# 브라우저 → ws://HOST/upbit-ws → wss://api.upbit.com/websocket/v1
location /upbit-ws {
proxy_pass https://api.upbit.com/websocket/v1;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host api.upbit.com;
proxy_set_header Origin "https://upbit.com";
proxy_set_header X-Real-IP $remote_addr;
# 쿠키/Authorization 헤더 제거 (업비트 WebSocket 인증 불필요)
proxy_set_header Authorization "";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# ── STOMP / SockJS WebSocket 프록시 ──────────────────────────────────
# Spring context-path /api → 실제 엔드포인트는 /api/ws/trading
# (구버전 클라이언트 /ws/** 요청도 /api/ws/** 로 전달)
location /ws/ {
proxy_pass http://backend:8080/api/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
# ── React SPA 폴백 ────────────────────────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
}
# JS/CSS/이미지 등 정적 자산 — 1년 장기 캐시 (Vite 콘텐츠 해시 포함 파일명)
location ~* \.(js|css|woff2?|ttf|eot|svg|ico|png|jpg|jpeg|gif|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# HTML 은 캐시 안 함 (항상 최신 index.html 로드)
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# gzip 압축
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript
application/json image/svg+xml;
}
+1948
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "react-trading-chart",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@stomp/stompjs": "^7.3.0",
"lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1",
"oakscriptjs": "^0.2.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Firebase Cloud Messaging Service Worker
* VITE_FIREBASE_* 는 빌드 시 index.html 주입 또는 수동 설정
*/
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
const firebaseConfig = {
apiKey: 'YOUR_API_KEY',
authDomain: 'YOUR_PROJECT.firebaseapp.com',
projectId: 'YOUR_PROJECT_ID',
storageBucket: 'YOUR_PROJECT.appspot.com',
messagingSenderId: 'YOUR_SENDER_ID',
appId: 'YOUR_APP_ID',
};
if (firebaseConfig.apiKey && firebaseConfig.apiKey !== 'YOUR_API_KEY') {
firebase.initializeApp(firebaseConfig);
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
const title = payload.notification?.title || 'GoldenChart';
const body = payload.notification?.body || payload.data?.message || '새 알림';
self.registration.showNotification(title, {
body,
icon: '/favicon.ico',
tag: 'goldenchart-fcm',
data: payload.data || {},
});
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.page) || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
for (const c of list) {
if (c.url.includes(self.location.origin) && 'focus' in c) return c.focus();
}
if (clients.openWindow) return clients.openWindow(self.location.origin + url);
}),
);
});
}
+11013
View File
File diff suppressed because it is too large Load Diff
+2212
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
/**
* 관리자 설정 패널 — 비밀번호 확인 후 콘텐츠 표시
*/
import React, { useState } from 'react';
import { verifyAdminPassword } from '../utils/backendApi';
import { isAdminUnlocked, setAdminUnlocked } from '../utils/adminUnlock';
interface Props {
children: React.ReactNode;
}
const AdminPasswordGate: React.FC<Props> = ({ children }) => {
const [unlocked, setUnlocked] = useState(isAdminUnlocked);
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
if (unlocked) return <>{children}</>;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await verifyAdminPassword(password);
setAdminUnlocked();
setUnlocked(true);
setPassword('');
} catch (err) {
setError(err instanceof Error ? err.message : '비밀번호 확인에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="admin-gate">
<div className="admin-gate-card">
<h3 className="admin-gate-title"> </h3>
<p className="admin-gate-desc">
.
(기본: admin / admin)
</p>
<form onSubmit={submit} className="admin-gate-form">
<label className="admin-gate-field">
<span> </span>
<input
type="text"
autoComplete="off"
className="admin-gate-field--visible"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
placeholder="비밀번호"
/>
</label>
{error && <p className="admin-gate-error">{error}</p>}
<button type="submit" className="admin-gate-btn" disabled={loading || !password}>
{loading ? '확인 중…' : '확인'}
</button>
</form>
</div>
</div>
);
};
export default AdminPasswordGate;
@@ -0,0 +1,257 @@
/**
* 관리자 설정 — 사용자 CRUD · 역할별 메뉴 권한
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
createAdminUser,
deleteAdminUser,
fetchRolePermissions,
listAdminUsers,
saveRolePermissions,
updateAdminUser,
type UserDto,
} from '../utils/backendApi';
import {
ALL_MENU_IDS,
MENU_LABELS,
ROLE_LABELS,
type UserRole,
} from '../utils/permissions';
const ROLES: UserRole[] = ['ADMIN', 'USER', 'GUEST'];
const AdminSettingsPanel: React.FC = () => {
const [users, setUsers] = useState<UserDto[]>([]);
const [rolePerms, setRolePerms] = useState<Record<string, Record<string, boolean>>>({});
const [permRole, setPermRole] = useState<UserRole>('USER');
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [formMode, setFormMode] = useState<'create' | 'edit' | null>(null);
const [editId, setEditId] = useState<number | null>(null);
const [fUsername, setFUsername] = useState('');
const [fPassword, setFPassword] = useState('');
const [fDisplayName, setFDisplayName] = useState('');
const [fRole, setFRole] = useState<UserRole>('USER');
const [fEnabled, setFEnabled] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const [u, p] = await Promise.all([listAdminUsers(), fetchRolePermissions()]);
setUsers(u);
setRolePerms(p);
} catch (e) {
setErr(e instanceof Error ? e.message : '데이터 로드 실패');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const openCreate = () => {
setFormMode('create');
setEditId(null);
setFUsername('');
setFPassword('');
setFDisplayName('');
setFRole('USER');
setFEnabled(true);
};
const openEdit = (u: UserDto) => {
setFormMode('edit');
setEditId(u.id);
setFUsername(u.username);
setFPassword('');
setFDisplayName(u.displayName);
setFRole((u.role as UserRole) || 'USER');
setFEnabled(u.enabled);
};
const submitUser = async () => {
setErr(null);
setMsg(null);
try {
if (formMode === 'create') {
await createAdminUser({
username: fUsername.trim(),
password: fPassword,
displayName: fDisplayName.trim() || fUsername.trim(),
role: fRole,
enabled: fEnabled,
});
setMsg('사용자가 등록되었습니다.');
} else if (formMode === 'edit' && editId != null) {
await updateAdminUser(editId, {
displayName: fDisplayName.trim(),
role: fRole,
enabled: fEnabled,
...(fPassword ? { password: fPassword } : {}),
});
setMsg('사용자 정보가 저장되었습니다.');
}
setFormMode(null);
await refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : '저장 실패');
}
};
const removeUser = async (id: number) => {
if (!window.confirm('이 사용자를 삭제하시겠습니까?')) return;
setErr(null);
try {
await deleteAdminUser(id);
setMsg('삭제되었습니다.');
await refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : '삭제 실패');
}
};
const savePerms = async () => {
const perms = rolePerms[permRole];
if (!perms) return;
setErr(null);
try {
await saveRolePermissions(permRole, perms);
setMsg(`${ROLE_LABELS[permRole]} 역할 권한이 저장되었습니다.`);
await refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : '권한 저장 실패');
}
};
const togglePerm = (menuId: string) => {
setRolePerms(prev => ({
...prev,
[permRole]: {
...(prev[permRole] ?? {}),
[menuId]: !(prev[permRole]?.[menuId]),
},
}));
};
if (loading && users.length === 0) {
return <p className="stg-loading"> </p>;
}
return (
<div className="admin-settings">
{msg && <p className="admin-msg admin-msg--ok">{msg}</p>}
{err && <p className="admin-msg admin-msg--err">{err}</p>}
<section className="stg-section">
<h3 className="stg-section-title"> </h3>
<div className="admin-toolbar">
<button type="button" className="stg-btn stg-btn--primary" onClick={openCreate}>
+
</button>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th />
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.username}</td>
<td>{u.displayName}</td>
<td><span className={`admin-role admin-role--${u.role.toLowerCase()}`}>{ROLE_LABELS[u.role as UserRole] ?? u.role}</span></td>
<td>{u.enabled ? '활성' : '비활성'}</td>
<td className="admin-actions">
<button type="button" className="admin-link" onClick={() => openEdit(u)}></button>
<button type="button" className="admin-link admin-link--danger" onClick={() => removeUser(u.id)}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{formMode && (
<section className="stg-section admin-form-section">
<h3 className="stg-section-title">{formMode === 'create' ? '사용자 등록' : '사용자 수정'}</h3>
<div className="admin-form-grid">
{formMode === 'create' && (
<label>
<span></span>
<input value={fUsername} onChange={e => setFUsername(e.target.value)} />
</label>
)}
<label>
<span>{formMode === 'create' ? '비밀번호' : '새 비밀번호 (변경 시만)'}</span>
<input type="text" autoComplete="off" className="stg-input stg-input--sensitive" value={fPassword} onChange={e => setFPassword(e.target.value)} />
</label>
<label>
<span></span>
<input value={fDisplayName} onChange={e => setFDisplayName(e.target.value)} />
</label>
<label>
<span></span>
<select value={fRole} onChange={e => setFRole(e.target.value as UserRole)}>
{ROLES.map(r => (
<option key={r} value={r}>{ROLE_LABELS[r]}</option>
))}
</select>
</label>
<label className="admin-check">
<input type="checkbox" checked={fEnabled} onChange={e => setFEnabled(e.target.checked)} />
<span> </span>
</label>
</div>
<div className="admin-form-actions">
<button type="button" className="stg-btn stg-btn--primary" onClick={() => void submitUser()}></button>
<button type="button" className="stg-btn" onClick={() => setFormMode(null)}></button>
</div>
</section>
)}
<section className="stg-section">
<h3 className="stg-section-title"> </h3>
<p className="stg-row-desc" style={{ marginBottom: 12 }}>
· · . (GUEST) .
</p>
<div className="admin-perm-role-tabs">
{ROLES.map(r => (
<button
key={r}
type="button"
className={`admin-perm-tab${permRole === r ? ' active' : ''}`}
onClick={() => setPermRole(r)}
>{ROLE_LABELS[r]}</button>
))}
</div>
<div className="admin-perm-grid">
{ALL_MENU_IDS.map(menuId => (
<label key={menuId} className="admin-perm-item">
<input
type="checkbox"
checked={rolePerms[permRole]?.[menuId] === true}
onChange={() => togglePerm(menuId)}
/>
<span>{MENU_LABELS[menuId] ?? menuId}</span>
</label>
))}
</div>
<button type="button" className="stg-btn stg-btn--primary" style={{ marginTop: 12 }} onClick={() => void savePerms()}>
{ROLE_LABELS[permRole]}
</button>
</section>
</div>
);
};
export default AdminSettingsPanel;
@@ -0,0 +1,238 @@
/**
* 채도·명도 영역 + Hue 슬라이더 + 스포이드 + RGB 입력 (TradingView 스타일)
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
hexToHsv,
hexToRgb,
hsvToHex,
hsvToRgb,
normalizeHex6,
rgbToHex,
rgbToHsv,
svPanelBackground,
HUE_SLIDER_BG,
type Hsv,
} from '../utils/colorSpaceUtils';
export interface AdvancedColorPickerProps {
hex6: string;
onChange: (hex6: string) => void;
className?: string;
}
function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}
function useHueDrag(onHue: (h: number) => void) {
const dragging = useRef(false);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const rect = e.currentTarget.getBoundingClientRect();
const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
onHue(clamp01(t) * 360);
}, [onHue]);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
onHue(clamp01(t) * 360);
}, [onHue]);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
dragging.current = false;
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* noop */ }
}, []);
return { onPointerDown, onPointerMove, onPointerUp };
}
const AdvancedColorPicker: React.FC<AdvancedColorPickerProps> = ({
hex6,
onChange,
className = '',
}) => {
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(normalizeHex6(hex6)));
const [rgb, setRgb] = useState(() => hexToRgb(normalizeHex6(hex6)));
const [colorMode, setColorMode] = useState<'rgb' | 'hex'>('rgb');
const skipSync = useRef(false);
useEffect(() => {
if (skipSync.current) {
skipSync.current = false;
return;
}
const h = hexToHsv(normalizeHex6(hex6));
setHsv(h);
setRgb(hexToRgb(normalizeHex6(hex6)));
}, [hex6]);
const emitHsv = useCallback((next: Hsv) => {
const h = { h: next.h, s: clamp01(next.s), v: clamp01(next.v) };
setHsv(h);
const r = hsvToRgb(h);
setRgb(r);
skipSync.current = true;
onChange(hsvToHex(h));
}, [onChange]);
const emitRgb = useCallback((r: number, g: number, b: number) => {
const next = { r, g, b };
setRgb(next);
setHsv(rgbToHsv(next));
skipSync.current = true;
onChange(rgbToHex(r, g, b));
}, [onChange]);
const hueDrag = useHueDrag(h => emitHsv({ ...hsv, h }));
const handleSvPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const update = (cx: number, cy: number) => {
const rect = el.getBoundingClientRect();
const s = clamp01(rect.width > 0 ? (cx - rect.left) / rect.width : 0);
const v = clamp01(rect.height > 0 ? 1 - (cy - rect.top) / rect.height : 0);
emitHsv({ ...hsv, s, v });
};
update(e.clientX, e.clientY);
const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
const pickEyedropper = async () => {
const EyeDropperCtor = (window as Window & { EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper;
if (!EyeDropperCtor) return;
try {
const result = await new EyeDropperCtor().open();
if (result?.sRGBHex) {
const hex = normalizeHex6(result.sRGBHex);
emitHsv(hexToHsv(hex));
}
} catch {
/* 사용자 취소 */
}
};
const pointerX = `${hsv.s * 100}%`;
const pointerY = `${(1 - hsv.v) * 100}%`;
const hueLeft = `${(hsv.h / 360) * 100}%`;
return (
<div className={`acp-root ${className}`.trim()}>
<div
className="acp-sv-panel"
style={{ background: svPanelBackground(hsv.h) }}
onPointerDown={handleSvPointerDown}
role="presentation"
>
<span
className="acp-sv-pointer"
style={{ left: pointerX, top: pointerY }}
aria-hidden
/>
</div>
<div className="acp-controls-row">
<button
type="button"
className="acp-eyedropper"
title="스포이드"
aria-label="스포이드로 색상 선택"
onClick={() => void pickEyedropper()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M20.71 5.63l-2.34-2.34a1 1 0 00-1.42 0l-3.12 3.12-1.93-1.91a1 1 0 00-1.41 0l-1.83 1.83a1 1 0 000 1.41l1.91 1.91-6.36 6.36a1 1 0 000 1.41l2.12 2.12a1 1 0 001.41 0l6.36-6.36 1.91 1.91a1 1 0 001.41 0l1.83-1.83a1 1 0 000-1.41l-1.92-1.9 3.12-3.12a1 1 0 000-1.42z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
</button>
<span
className="acp-preview"
style={{ background: normalizeHex6(hex6) }}
aria-hidden
/>
<div
className="acp-hue-track"
style={{ background: HUE_SLIDER_BG }}
onPointerDown={hueDrag.onPointerDown}
onPointerMove={hueDrag.onPointerMove}
onPointerUp={hueDrag.onPointerUp}
role="slider"
aria-label="색상(Hue)"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hsv.h)}
>
<span className="acp-hue-thumb" style={{ left: hueLeft }} aria-hidden />
</div>
</div>
<div className="acp-rgb-row">
{colorMode === 'rgb' ? (
<>
{(['r', 'g', 'b'] as const).map(ch => (
<label key={ch} className="acp-rgb-field">
<input
type="number"
className="acp-rgb-input"
min={0}
max={255}
value={rgb[ch]}
onChange={e => {
const v = Number(e.target.value);
if (Number.isNaN(v)) return;
emitRgb(
ch === 'r' ? v : rgb.r,
ch === 'g' ? v : rgb.g,
ch === 'b' ? v : rgb.b,
);
}}
/>
<span className="acp-rgb-label">{ch.toUpperCase()}</span>
</label>
))}
</>
) : (
<label className="acp-rgb-field acp-hex-field">
<input
type="text"
className="acp-rgb-input acp-hex-input"
value={normalizeHex6(hex6).slice(1)}
maxLength={6}
onChange={e => {
const raw = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
if (raw.length === 6) {
emitHsv(hexToHsv('#' + raw));
}
}}
/>
<span className="acp-rgb-label">HEX</span>
</label>
)}
<button
type="button"
className="acp-mode-toggle"
title={colorMode === 'rgb' ? 'HEX 입력' : 'RGB 입력'}
aria-label="색상 입력 방식 전환"
onClick={() => setColorMode(m => (m === 'rgb' ? 'hex' : 'rgb'))}
>
</button>
</div>
</div>
);
};
export default AdvancedColorPicker;
+59
View File
@@ -0,0 +1,59 @@
import React, { useState } from 'react';
interface AlertManagerProps {
alerts: Array<{ price: number; label: string }>;
onAdd: (price: number, label: string) => void;
onRemove: (price: number) => void;
onClose: () => void;
currentPrice: number;
}
const AlertManager: React.FC<AlertManagerProps> = ({ alerts, onAdd, onRemove, onClose, currentPrice }) => {
const [priceInput, setPriceInput] = useState(currentPrice.toFixed(2));
const [labelInput, setLabelInput] = useState('');
const handleAdd = () => {
const price = parseFloat(priceInput);
if (!isNaN(price) && price > 0) {
onAdd(price, labelInput.trim());
setLabelInput('');
}
};
return (
<div className="alert-panel">
<div className="alert-header">
<span className="alert-title">🔔 </span>
<button className="alert-close" onClick={onClose}></button>
</div>
<div className="alert-add">
<input
className="alert-input"
type="number"
value={priceInput}
onChange={e => setPriceInput(e.target.value)}
placeholder="가격"
/>
<input
className="alert-input"
value={labelInput}
onChange={e => setLabelInput(e.target.value)}
placeholder="메모 (선택)"
/>
<button className="alert-add-btn" onClick={handleAdd}></button>
</div>
<div className="alert-body">
{alerts.length === 0 && <div className="alert-empty"> </div>}
{alerts.map((a, i) => (
<div key={i} className="alert-item">
<span className="alert-price">{a.price.toFixed(2)}</span>
{a.label && <span className="alert-label">{a.label}</span>}
<button className="alert-remove" onClick={() => onRemove(a.price)}></button>
</div>
))}
</div>
</div>
);
};
export default AlertManager;
@@ -0,0 +1,83 @@
/**
* 전역 알림 UI — 모든 화면 위에 표시 (body 포털)
*/
import React from 'react';
import { createPortal } from 'react-dom';
import TradeSignalSnackbar from './TradeSignalSnackbar';
import TradeAlertModal from './TradeAlertModal';
import { useTradeNotification } from '../contexts/TradeNotificationContext';
import {
normalizeTradeAlertGridCols,
normalizeTradeAlertPopupLayout,
normalizeTradeAlertPopupPosition,
} from '../utils/tradeAlertPopupLayout';
import type { MenuPage } from './TopMenuBar';
interface Props {
menuPage: MenuPage;
onPage: (page: MenuPage) => void;
onGoToChart: (market: string) => void;
tradingMode?: string;
hasUpbitKeys?: boolean;
paperTradingEnabled?: boolean;
liveAutoTradeBudgetPct?: number;
paperAutoTradeBudgetPct?: number;
tradeAlertPopupPosition?: string;
tradeAlertPopupLayout?: string;
tradeAlertPopupGridCols?: number;
onOrderDone?: () => void;
}
export const AppNotificationLayer: React.FC<Props> = ({
menuPage,
onPage,
onGoToChart,
tradingMode,
hasUpbitKeys,
paperTradingEnabled,
liveAutoTradeBudgetPct,
paperAutoTradeBudgetPct,
tradeAlertPopupPosition = 'right',
tradeAlertPopupLayout = 'stack',
tradeAlertPopupGridCols = 2,
onOrderDone,
}) => {
const { detailSignal, detailCentered, closeDetail } = useTradeNotification();
const popupPosition = normalizeTradeAlertPopupPosition(tradeAlertPopupPosition);
const popupLayout = normalizeTradeAlertPopupLayout(tradeAlertPopupLayout);
const gridCols = normalizeTradeAlertGridCols(tradeAlertPopupGridCols);
const content = (
<div className="app-notification-layer" aria-hidden={false}>
<TradeSignalSnackbar
onGoToChart={market => {
onGoToChart(market);
onPage('chart');
}}
onOpenList={() => onPage('notifications')}
popupPosition={popupPosition}
popupLayout={popupLayout}
gridCols={gridCols}
/>
{detailSignal && (
<TradeAlertModal
key={`${detailSignal.market}-${detailSignal.candleTime}-${detailSignal.signalType}`}
signal={detailSignal}
centered={detailCentered}
onClose={closeDetail}
tradingMode={tradingMode}
hasUpbitKeys={hasUpbitKeys}
paperTradingEnabled={paperTradingEnabled}
liveAutoTradeBudgetPct={liveAutoTradeBudgetPct}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onOrderDone={onOrderDone}
/>
)}
</div>
);
if (typeof document === 'undefined') return null;
return createPortal(content, document.body);
};
export default AppNotificationLayer;
@@ -0,0 +1,289 @@
/**
* BacktestHistoryPage
* ─────────────────────────────────────────────────────
* [좌측] 체크박스 선택 + 삭제 가능한 이력 목록
* [우측] 카드 기반 대시보드 결과 뷰
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
loadBacktestResults,
deleteBacktestResult,
type BacktestResultRecord,
type BacktestAnalysis,
type BacktestSignal,
} from '../utils/backendApi';
import { BacktestDashboard } from './BacktestResultModal';
// ── 유틸 ──────────────────────────────────────────────────────────────────
const pct = (v: number) =>
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '';
const fmtDateTime = (iso: string) => {
const d = new Date(iso);
return {
date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
time: `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`,
};
};
// ── 아이콘 ────────────────────────────────────────────────────────────────
const IcTrash = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<polyline points="1,3 13,3"/><line x1="5" y1="3" x2="5" y2="1"/><line x1="9" y1="3" x2="9" y2="1"/>
<line x1="6.5" y1="1" x2="7.5" y2="1"/>
<path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
<line x1="5.5" y1="6" x2="5.5" y2="10"/><line x1="7" y1="6" x2="7" y2="10"/><line x1="8.5" y1="6" x2="8.5" y2="10"/>
</svg>
);
const IcTrashAll = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<polyline points="1,3 13,3"/><path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
<line x1="5.5" y1="6" x2="5.5" y2="10"/>
<line x1="8.5" y1="6" x2="8.5" y2="10"/>
<line x1="4" y1="1.5" x2="10" y2="1.5" strokeDasharray="1.5 1"/>
</svg>
);
const IcRefresh = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M12 7 A5 5 0 1 1 9.5 2.5"/>
<polyline points="10,1 10,4 13,4"/>
</svg>
);
// ── 목록 아이템 ────────────────────────────────────────────────────────────
interface ListItemProps {
r: BacktestResultRecord;
checked: boolean;
active: boolean;
onToggle: () => void;
onClick: () => void;
}
function ListItem({ r, checked, active, onToggle, onClick }: ListItemProps) {
const ret = r.totalReturn ?? 0;
const dt = fmtDateTime(r.createdAt);
return (
<div
className={`bh-item ${active ? 'bh-item--active' : ''} ${checked ? 'bh-item--checked' : ''}`}
onClick={onClick}
>
<div className="bh-item-cb-wrap" onClick={e => { e.stopPropagation(); onToggle(); }}>
<div className={`bh-cb ${checked ? 'bh-cb--on' : ''}`}>
{checked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
</div>
</div>
<div className="bh-item-body">
<div className="bh-item-row1">
<span className="bh-item-name">{r.strategyName || '전략 없음'}</span>
<span className={`bh-item-ret ${ret > 0 ? 'bh-pos' : ret < 0 ? 'bh-neg' : ''}`}>{pct(ret)}</span>
</div>
<div className="bh-item-row2">
<span className="bh-item-symbol">{r.symbol}</span>
<span className="bh-item-sep">·</span>
<span className="bh-item-tf">{r.timeframe}</span>
<span className="bh-item-sep">·</span>
<span className="bh-item-trades">{r.totalTrades}</span>
</div>
<div className="bh-item-row3">
<span className="bh-item-date">{dt.date}</span>
<span className="bh-item-time">{dt.time}</span>
{(r.winRate ?? 0) > 0 && (
<span className="bh-item-wr"> {((r.winRate ?? 0) * 100).toFixed(0)}%</span>
)}
</div>
</div>
</div>
);
}
// ── 메인 페이지 ───────────────────────────────────────────────────────────
export function BacktestHistoryPage() {
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
// ── 로드 ────────────────────────────────────────────────────────────────
const fetchList = useCallback(async () => {
setLoading(true);
const list = await loadBacktestResults();
setRecords(list);
if (list.length > 0) setSelected(prev => prev ?? list[0]);
setLoading(false);
}, []);
useEffect(() => { fetchList(); }, [fetchList]);
// ── 체크박스 ──────────────────────────────────────────────────────────
const toggleCheck = useCallback((id: number) => {
setChecked(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setChecked(prev =>
prev.size === records.length
? new Set()
: new Set(records.map(r => r.id))
);
}, [records]);
const allChecked = records.length > 0 && checked.size === records.length;
const someChecked = checked.size > 0 && !allChecked;
// ── 삭제 ─────────────────────────────────────────────────────────────
const deleteSelected = useCallback(async () => {
if (checked.size === 0) return;
if (!window.confirm(`선택한 ${checked.size}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
await Promise.all([...checked].map(id => deleteBacktestResult(id)));
setRecords(prev => {
const next = prev.filter(r => !checked.has(r.id));
if (selected && checked.has(selected.id)) setSelected(next[0] ?? null);
return next;
});
setChecked(new Set());
}, [checked, selected]);
const deleteAll = useCallback(async () => {
if (records.length === 0) return;
if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
await Promise.all(records.map(r => deleteBacktestResult(r.id)));
setRecords([]);
setSelected(null);
setChecked(new Set());
}, [records]);
// ── 상세 데이터 파싱 ──────────────────────────────────────────────────
const getDetail = (r: BacktestResultRecord) => {
let analysis: BacktestAnalysis | null = null;
let signals: BacktestSignal[] = [];
try {
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
} catch (_) {}
return { analysis, signals };
};
// ── 렌더 ─────────────────────────────────────────────────────────────
return (
<div className="bh-page">
{/* ── 좌측 목록 ── */}
<aside className="bh-sidebar">
{/* 사이드바 헤더 */}
<div className="bh-sidebar-header">
<div className="bh-sidebar-title-row">
<span className="bh-sidebar-title"> </span>
<span className="bh-sidebar-count">{records.length}</span>
</div>
<div className="bh-sidebar-actions">
<button
className="bh-act-btn"
title="선택 항목 삭제"
disabled={checked.size === 0}
onClick={deleteSelected}
>
<IcTrash />
{checked.size > 0 && <span className="bh-act-badge">{checked.size}</span>}
</button>
<button
className="bh-act-btn bh-act-btn--danger"
title="전체 삭제"
disabled={records.length === 0}
onClick={deleteAll}
>
<IcTrashAll />
</button>
<button
className="bh-act-btn"
title="새로고침"
onClick={fetchList}
>
<IcRefresh />
</button>
</div>
</div>
{/* 전체 선택 바 */}
{records.length > 0 && (
<div className="bh-select-all-bar" onClick={toggleAll}>
<div className={`bh-cb ${allChecked ? 'bh-cb--on' : someChecked ? 'bh-cb--indeterminate' : ''}`}>
{allChecked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
{someChecked && <div className="bh-cb-dash"/>}
</div>
<span className="bh-select-all-label">
{someChecked || allChecked ? `${checked.size}개 선택됨` : '전체 선택'}
</span>
</div>
)}
{/* 목록 */}
{loading ? (
<div className="bh-loading">
<div className="bh-loading-spinner"/>
<span> </span>
</div>
) : records.length === 0 ? (
<div className="bh-empty-list">
<div className="bh-empty-icon">📊</div>
<p> .</p>
<p className="bh-empty-sub"> <br/> .</p>
</div>
) : (
<div className="bh-list">
{records.map(r => (
<ListItem
key={r.id}
r={r}
checked={checked.has(r.id)}
active={selected?.id === r.id}
onToggle={() => toggleCheck(r.id)}
onClick={() => setSelected(r)}
/>
))}
</div>
)}
</aside>
{/* ── 우측 대시보드 ── */}
<main className="bh-content">
{selected ? (
(() => {
const { analysis, signals } = getDetail(selected);
return (
<div className="bh-detail">
<BacktestDashboard
analysis={analysis}
signals={signals}
strategyName={selected.strategyName}
symbol={selected.symbol}
timeframe={selected.timeframe}
barCount={selected.barCount}
createdAt={selected.createdAt}
/>
</div>
);
})()
) : (
<div className="bh-no-selection">
<div className="bh-no-sel-icon">📈</div>
<p> .</p>
</div>
)}
</main>
</div>
);
}
export default BacktestHistoryPage;
+59
View File
@@ -0,0 +1,59 @@
/**
* 백테스팅 결과 통계 배지 — 차트 위에 오버레이로 표시.
* 전략 선택 / 실행은 Toolbar의 드롭다운에서 담당.
*/
import React from 'react';
import type { BacktestStats } from '../utils/backendApi';
interface BacktestPanelProps {
stats: BacktestStats | null;
signalCount: number;
onClear: () => void;
}
const BacktestPanel: React.FC<BacktestPanelProps> = ({ stats, signalCount, onClear }) => {
const fmt = (n: number) => (n * 100).toFixed(2) + '%';
const fmtWon = (n: number) => n >= 1000 ? (n / 10000).toFixed(0) + '만원' : n.toFixed(0) + '원';
const retColor = stats && stats.totalReturn > 0 ? '#26A69A' : stats && stats.totalReturn < 0 ? '#EF5350' : 'var(--text2)';
return (
<div className="bt-result-badge">
<div className="bt-result-row">
<span className="bt-result-label"></span>
<span className="bt-result-val">{signalCount}</span>
</div>
{stats && (
<>
<div className="bt-result-sep" />
<div className="bt-result-row">
<span className="bt-result-label"></span>
<span className="bt-result-val">{stats.totalTrades}</span>
</div>
<div className="bt-result-row">
<span className="bt-result-label"></span>
<span className="bt-result-val">{fmt(stats.winRate)}</span>
</div>
<div className="bt-result-row">
<span className="bt-result-label"></span>
<span className="bt-result-val" style={{ color: retColor }}>
{stats.totalReturn >= 0 ? '+' : ''}{fmt(stats.totalReturn)}
</span>
</div>
<div className="bt-result-row">
<span className="bt-result-label"></span>
<span className="bt-result-val" style={{ color: '#EF5350' }}>{fmt(stats.maxDrawdown)}</span>
</div>
{stats.finalEquity > 0 && (
<div className="bt-result-row">
<span className="bt-result-label"></span>
<span className="bt-result-val" style={{ color: retColor }}>{fmtWon(stats.finalEquity)}</span>
</div>
)}
</>
)}
<button className="bt-result-clear" onClick={onClear} title="결과 지우기"></button>
</div>
);
};
export default BacktestPanel;
@@ -0,0 +1,504 @@
/**
* BacktestResultModal / BacktestDashboard
* ─────────────────────────────────────────────────────────────────────
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
*/
import React, { useState } from 'react';
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
// ── 포맷 유틸 ────────────────────────────────────────────────────────────────
const pct = (v: number, dec = 2) =>
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '';
const pctAbs = (v: number, dec = 1) =>
isFinite(v) && v !== 0 ? `${(v * 100).toFixed(dec)}%` : '';
const num = (v: number, dec = 2) =>
isFinite(v) && v !== 0 ? v.toFixed(dec) : '';
const wonFmt = (v: number) => {
if (!isFinite(v) || v === 0) return '';
const abs = Math.abs(Math.round(v));
const s = abs >= 100_000_000 ? `${(abs/100_000_000).toFixed(2)}`
: abs >= 10_000 ? `${(abs/10_000).toFixed(1)}`
: abs.toLocaleString();
return (v >= 0 ? '+' : '-') + s + '원';
};
const fmtDate = (ts: number) => {
const d = new Date(ts * 1000);
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`;
};
const fmtDateStr = (iso: string) => {
const d = new Date(iso);
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
};
const colorCls = (v: number) => v > 0 ? 'brd-pos' : v < 0 ? 'brd-neg' : '';
const colorStyle = (v: number): React.CSSProperties => ({
color: v > 0 ? 'var(--brd-pos)' : v < 0 ? 'var(--brd-neg)' : undefined,
});
// ── Props ─────────────────────────────────────────────────────────────────
export interface BacktestResultModalProps {
analysis?: BacktestAnalysis | null;
stats?: BacktestStats | null;
signals?: BacktestSignal[];
strategyName: string;
symbol: string;
timeframe: string;
barCount: number;
onClose: () => void;
embedded?: boolean;
}
export interface BacktestDashboardProps {
analysis: BacktestAnalysis | null;
signals: BacktestSignal[];
strategyName: string;
symbol: string;
timeframe: string;
barCount: number;
createdAt?: string;
}
// ── 섹션 제목 ─────────────────────────────────────────────────────────────
function SectionTitle({ icon, title, right }: { icon: string; title: string; right?: React.ReactNode }) {
return (
<div className="brd-section-title">
<span className="brd-section-icon">{icon}</span>
<span className="brd-section-text">{title}</span>
{right && <span className="brd-section-right">{right}</span>}
</div>
);
}
// ── KPI 카드 ──────────────────────────────────────────────────────────────
function KpiCard({ icon, label, value, sub, valueColor, accent }:
{ icon: string; label: string; value: string; sub: string; valueColor?: string; accent?: string }) {
return (
<div className={`brd-kpi-card ${accent ? `brd-kpi-card--${accent}` : ''}`}>
<div className="brd-kpi-top">
<span className="brd-kpi-icon">{icon}</span>
<span className="brd-kpi-label">{label}</span>
</div>
<div className={`brd-kpi-value ${valueColor ?? ''}`}>{value}</div>
<div className="brd-kpi-sub">{sub}</div>
</div>
);
}
// ── 미니 지표 행 (테이블 스타일) ─────────────────────────────────────────
function MetricRow({ label, value, cls, note }: { label: string; value: string; cls?: string; note?: string }) {
return (
<div className="brd-metric-row">
<span className="brd-metric-label">
{label}
{note && <span className="brd-metric-note">{note}</span>}
</span>
<span className={`brd-metric-value ${cls ?? ''}`}>{value}</span>
</div>
);
}
// ── 승률 도넛 (CSS 원) ────────────────────────────────────────────────────
function WinRateCircle({ winRate, winning, total }: { winRate: number; winning: number; total: number }) {
const r = 28;
const circ = 2 * Math.PI * r;
const pct2 = Math.max(0, Math.min(1, winRate));
const dash = pct2 * circ;
return (
<div className="brd-donut-wrap">
<svg width="72" height="72" viewBox="0 0 72 72">
<circle cx="36" cy="36" r={r} fill="none" stroke="var(--bg4)" strokeWidth="8"/>
<circle
cx="36" cy="36" r={r} fill="none"
stroke={pct2 >= 0.5 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
strokeWidth="8"
strokeDasharray={`${dash} ${circ}`}
strokeLinecap="round"
strokeDashoffset={circ * 0.25}
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}
/>
<text x="36" y="33" textAnchor="middle" fontSize="11" fontWeight="800" fill="var(--text)">{pctAbs(winRate)}</text>
<text x="36" y="45" textAnchor="middle" fontSize="8.5" fill="var(--text3)"></text>
</svg>
<div className="brd-donut-detail">
<span className="brd-pos"> {winning}</span>
<span className="brd-neg"> {total - winning}</span>
</div>
</div>
);
}
// ── 수평 비교 바 ─────────────────────────────────────────────────────────
function CompareBar({ labelA, valA, labelB, valB, colorA, colorB }:
{ labelA: string; valA: number; labelB: string; valB: number; colorA: string; colorB: string }) {
const max = Math.max(Math.abs(valA), Math.abs(valB), 0.01);
const pctA2 = Math.min(100, Math.abs(valA) / max * 100);
const pctB2 = Math.min(100, Math.abs(valB) / max * 100);
return (
<div className="brd-compare">
<div className="brd-compare-row">
<span className="brd-compare-label">{labelA}</span>
<div className="brd-compare-track">
<div className="brd-compare-bar" style={{ width: `${pctA2}%`, background: colorA }}/>
</div>
<span className="brd-compare-val" style={{ color: colorA }}>{pct(valA)}</span>
</div>
<div className="brd-compare-row">
<span className="brd-compare-label">{labelB}</span>
<div className="brd-compare-track">
<div className="brd-compare-bar" style={{ width: `${pctB2}%`, background: colorB }}/>
</div>
<span className="brd-compare-val" style={{ color: colorB }}>{pct(valB)}</span>
</div>
</div>
);
}
// ── 시그널 타입 ───────────────────────────────────────────────────────────
const SIG_LABEL: Record<string, string> = {
BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도',
};
const SIG_COLOR: Record<string, string> = {
BUY:'brd-pos', SELL:'brd-neg', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
};
// ══════════════════════════════════════════════════════════════════════════
// BacktestDashboard — 메인 대시보드 컴포넌트 (재사용 가능)
// ══════════════════════════════════════════════════════════════════════════
export function BacktestDashboard({
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
}: BacktestDashboardProps) {
const [sigExpanded, setSigExpanded] = useState(false);
const a = analysis;
if (!a) {
return (
<div className="brd-empty-state">
<div>📊</div>
<p> .</p>
</div>
);
}
const sigLimit = sigExpanded ? signals.length : 8;
const periodStr = signals.length >= 2
? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}`
: `${barCount}`;
const sharpeLevel = a.sharpeRatio >= 2 ? '탁월' : a.sharpeRatio >= 1 ? '우수' : a.sharpeRatio >= 0 ? '보통' : '미흡';
return (
<div className="brd-dashboard">
{/* ── 대시보드 헤더 ── */}
<div className="brd-dash-header">
<div className="brd-dash-header-left">
<div className="brd-dash-strategy">{strategyName}</div>
<div className="brd-dash-meta">
<span className="brd-dash-badge">{symbol}</span>
<span className="brd-dash-badge">{timeframe}</span>
<span className="brd-dash-badge">{periodStr}</span>
{createdAt && <span className="brd-dash-badge brd-dash-badge--time"> {fmtDateStr(createdAt)}</span>}
</div>
</div>
<div className="brd-dash-header-kpi">
<div className="brd-dash-big-kpi">
<div className={`brd-dash-big-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</div>
<div className="brd-dash-big-label"> </div>
</div>
<div className="brd-dash-big-kpi">
<div className={`brd-dash-big-val ${colorCls(a.finalEquity - a.initialCapital)}`}>
{wonFmt(a.finalEquity)}
</div>
<div className="brd-dash-big-label"> </div>
</div>
</div>
</div>
{/* ── Row 1: KPI 카드 6개 ── */}
<div className="brd-row">
<div className="brd-kpi-grid">
<KpiCard icon="🎯" label="승률" value={pctAbs(a.winRate)} sub={`${a.numberOfWinning}${a.numberOfLosing}`} valueColor={colorCls(a.winRate - 0.5)} accent="blue"/>
<KpiCard icon="📉" label="최대 낙폭" value={pct(a.maxDrawdownPct)} sub="기간 중 최대 손실" valueColor={a.maxDrawdownPct < 0 ? 'brd-neg' : ''} accent="red"/>
<KpiCard icon="⚡" label="샤프 비율" value={num(a.sharpeRatio)} sub={sharpeLevel} valueColor={colorCls(a.sharpeRatio - 1)} accent="yellow"/>
<KpiCard icon="🔄" label="소르티노" value={num(a.sortinoRatio)} sub="하방 변동성 기준" valueColor={colorCls(a.sortinoRatio - 1)} accent="purple"/>
<KpiCard icon="⚖️" label="손익비" value={num(a.profitLossRatio)} sub="이익/손실 배수" valueColor={colorCls(a.profitLossRatio - 1)} accent="green"/>
<KpiCard icon="📐" label="칼마 비율" value={num(a.calmarRatio)} sub="수익 / |MDD|" valueColor={colorCls(a.calmarRatio - 1)} accent="teal"/>
</div>
</div>
{/* ── Row 2: 수익성 + 거래 통계 + 승률 도넛 ── */}
<div className="brd-row brd-row--3col">
{/* 수익성 지표 카드 */}
<div className="brd-card">
<SectionTitle icon="📊" title="수익성 지표"/>
<div className="brd-card-body">
<MetricRow label="총 수익률" value={pct(a.totalReturnPct)} cls={colorCls(a.totalReturnPct)}/>
<MetricRow label="총 손익 (금액)" value={wonFmt(a.totalProfitLoss)} cls={colorCls(a.totalProfitLoss)}/>
<MetricRow label="총 이익" value={wonFmt(a.grossProfit)} cls="brd-pos"/>
<MetricRow label="총 손실" value={wonFmt(a.grossLoss)} cls="brd-neg"/>
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
<MetricRow label="최대 상승폭" value={pct(a.maxRunupPct)} cls="brd-pos"/>
<MetricRow label="손익분기 거래" value={`${a.numberOfBreakEven}`}/>
</div>
</div>
{/* 리스크 지표 카드 */}
<div className="brd-card">
<SectionTitle icon="🛡" title="리스크 지표"/>
<div className="brd-card-body">
<MetricRow label="최대 낙폭 (MDD)" value={pct(a.maxDrawdownPct)} cls={colorCls(a.maxDrawdownPct)} note="고점 대비"/>
<MetricRow label="샤프 비율" value={num(a.sharpeRatio)} cls={colorCls(a.sharpeRatio - 1)} note="≥1 우수"/>
<MetricRow label="소르티노 비율" value={num(a.sortinoRatio)} cls={colorCls(a.sortinoRatio - 1)} note="하방 기준"/>
<MetricRow label="칼마 비율" value={num(a.calmarRatio)} cls={colorCls(a.calmarRatio - 1)}/>
<MetricRow label="VaR 95%" value={pct(a.valueAtRisk95)} cls="brd-neg" note="최대 손실 예상"/>
<MetricRow label="기대 손실 (CVaR)" value={pct(a.expectedShortfall)} cls="brd-neg" note="꼬리 리스크"/>
</div>
</div>
{/* 승률 + 자본 카드 */}
<div className="brd-card">
<SectionTitle icon="🔢" title="거래 통계"/>
<div className="brd-card-body brd-card-body--center">
<WinRateCircle winRate={a.winRate} winning={a.numberOfWinning} total={a.numberOfPositions}/>
<div className="brd-trade-stats">
<MetricRow label="총 거래" value={`${a.numberOfPositions}`}/>
<MetricRow label="수익 거래" value={`${a.numberOfWinning}`} cls="brd-pos"/>
<MetricRow label="손실 거래" value={`${a.numberOfLosing}`} cls="brd-neg"/>
<MetricRow label="초기 자본" value={`${Math.round(a.initialCapital/10000).toLocaleString()}만원`}/>
<MetricRow label="최종 자산" value={`${Math.round(a.finalEquity/10000).toLocaleString()}만원`} cls={colorCls(a.finalEquity - a.initialCapital)}/>
</div>
</div>
</div>
</div>
{/* ── Row 3: 벤치마크 비교 + 자본 분석 ── */}
<div className="brd-row brd-row--2col">
{/* 벤치마크 비교 */}
<div className="brd-card">
<SectionTitle icon="📐" title="벤치마크 비교" right={
<span className={`brd-badge-inline ${colorCls(a.vsBuyAndHold - 1)}`}>
{isFinite(a.vsBuyAndHold) && a.vsBuyAndHold !== 0
? `${a.vsBuyAndHold >= 1 ? '전략 우위 ' : ''}${a.vsBuyAndHold.toFixed(2)}×`
: ''
}
</span>
}/>
<div className="brd-card-body">
<CompareBar
labelA="전략 수익률" valA={a.totalReturnPct}
labelB="바이앤홀드" valB={a.buyAndHoldReturnPct}
colorA={a.totalReturnPct >= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
colorB="var(--text3)"
/>
<div className="brd-bench-summary">
<div className="brd-bench-item">
<span className="brd-bench-label"></span>
<span className={`brd-bench-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</span>
</div>
<div className="brd-bench-divider"/>
<div className="brd-bench-item">
<span className="brd-bench-label"></span>
<span className={`brd-bench-val ${colorCls(a.buyAndHoldReturnPct)}`}>{pct(a.buyAndHoldReturnPct)}</span>
</div>
<div className="brd-bench-divider"/>
<div className="brd-bench-item">
<span className="brd-bench-label"> </span>
<span className={`brd-bench-val ${colorCls(a.totalReturnPct - a.buyAndHoldReturnPct)}`}>
{pct(a.totalReturnPct - a.buyAndHoldReturnPct)}
</span>
</div>
</div>
</div>
</div>
{/* 수익/손실 상세 */}
<div className="brd-card">
<SectionTitle icon="💰" title="손익 분석"/>
<div className="brd-card-body">
<div className="brd-pnl-bar-section">
<div className="brd-pnl-row">
<span className="brd-pnl-label brd-pos"> </span>
<div className="brd-pnl-track">
<div className="brd-pnl-bar brd-pnl-bar--pos" style={{
width: `${a.grossProfit > 0 && Math.abs(a.grossLoss) > 0
? Math.min(100, a.grossProfit / (a.grossProfit + Math.abs(a.grossLoss)) * 100)
: a.grossProfit > 0 ? 100 : 0}%`
}}/>
</div>
<span className="brd-pnl-val brd-pos">{wonFmt(a.grossProfit)}</span>
</div>
<div className="brd-pnl-row">
<span className="brd-pnl-label brd-neg"> </span>
<div className="brd-pnl-track">
<div className="brd-pnl-bar brd-pnl-bar--neg" style={{
width: `${Math.abs(a.grossLoss) > 0 && a.grossProfit > 0
? Math.min(100, Math.abs(a.grossLoss) / (a.grossProfit + Math.abs(a.grossLoss)) * 100)
: Math.abs(a.grossLoss) > 0 ? 100 : 0}%`
}}/>
</div>
<span className="brd-pnl-val brd-neg">{wonFmt(a.grossLoss)}</span>
</div>
</div>
<div className="brd-pnl-net">
<span className="brd-pnl-net-label"> </span>
<span className={`brd-pnl-net-val ${colorCls(a.totalProfitLoss)}`}>{wonFmt(a.totalProfitLoss)}</span>
</div>
<MetricRow label="손익비" value={num(a.profitLossRatio)} cls={colorCls(a.profitLossRatio - 1)}/>
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
</div>
</div>
</div>
{/* ── Row 4: 거래 시그널 목록 ── */}
<div className="brd-row">
<div className="brd-card brd-card--full">
<SectionTitle icon="📋" title="거래 시그널" right={
<span className="brd-section-count">{signals.length}</span>
}/>
{signals.length === 0 ? (
<div className="brd-empty-msg"> .</div>
) : (
<>
<div className="brd-sig-scroll">
<table className="brd-sig-table">
<thead>
<tr>
<th style={{width:36}}>#</th>
<th></th>
<th></th>
<th style={{textAlign:'right'}}></th>
<th style={{textAlign:'center',width:50}}>#</th>
</tr>
</thead>
<tbody>
{signals.slice(0, sigLimit).map((s, i) => (
<tr key={i} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
<td style={{textAlign:'center',color:'var(--text3)'}}>{i+1}</td>
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
<td>
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
{SIG_LABEL[s.type] ?? s.type}
</span>
</td>
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
{Math.round(s.price).toLocaleString()}
</td>
<td style={{textAlign:'center',color:'var(--text3)'}}>{s.barIndex}</td>
</tr>
))}
</tbody>
</table>
</div>
{signals.length > 8 && (
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
</button>
)}
</>
)}
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════
// BacktestResultModal — 팝업 래퍼
// ══════════════════════════════════════════════════════════════════════════
export function BacktestResultModal({
analysis, stats, signals = [], strategyName, symbol, timeframe, barCount, onClose, embedded = false,
}: BacktestResultModalProps) {
const {
panelRef: modalRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: !embedded });
const a = analysis ?? (stats ? buildAnalysisFromStats(stats) : null);
if (!a && !stats) return null;
const inner = (
<div
ref={modalRef}
className={`brm-modal ${embedded ? 'brm-modal--embedded' : ''}`}
style={embedded ? undefined : {
...panelStyle,
zIndex: 9200,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={embedded ? undefined : e => e.stopPropagation()}
>
<div
className="gc-popup-header brm-header"
onPointerDown={embedded ? undefined : onHeaderPointerDown}
style={embedded ? undefined : { cursor: headerCursor, ...headerTouchStyle }}
>
<div className="brm-header-left">
<span className="brm-header-title"> </span>
</div>
{!embedded && (
<button className="brm-close" onClick={onClose} title="닫기"></button>
)}
</div>
<div className="brm-body">
<BacktestDashboard
analysis={a}
signals={signals}
strategyName={strategyName}
symbol={symbol}
timeframe={timeframe}
barCount={barCount}
/>
</div>
</div>
);
if (embedded) return inner;
return (
<div className="brm-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
{inner}
</div>
);
}
function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis {
return {
initialCapital: 10_000_000,
finalEquity: s.finalEquity ?? 0,
totalReturnPct: s.totalReturn ?? 0,
totalProfitLoss: (s.finalEquity ?? 0) - 10_000_000,
grossProfit: 0, grossLoss: 0,
avgReturnPct: s.avgReturn ?? 0,
profitLossRatio: 0,
numberOfPositions: s.totalTrades ?? 0,
numberOfWinning: s.winTrades ?? 0,
numberOfLosing: (s.totalTrades ?? 0) - (s.winTrades ?? 0),
numberOfBreakEven: 0,
winRate: s.winRate ?? 0,
maxDrawdownPct: s.maxDrawdown ?? 0,
maxRunupPct: 0, sharpeRatio: 0, sortinoRatio: 0, calmarRatio: 0,
valueAtRisk95: 0, expectedShortfall: 0,
buyAndHoldReturnPct: 0, vsBuyAndHold: 0,
};
}
export default BacktestResultModal;
@@ -0,0 +1,428 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import {
BacktestSettingsDto,
DEFAULT_BACKTEST_SETTINGS,
loadBacktestSettings,
saveBacktestSettings,
} from '../utils/backendApi';
// ── 설명 메타데이터 ─────────────────────────────────────────────────────────
interface FieldMeta {
label: string;
description: string;
}
const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = {
initialCapital: {
label: '초기 자본',
description:
'백테스팅 시뮬레이션의 시작 자본금(원)입니다. 수익/손실 금액과 최종 자산을 이 금액 기준으로 계산합니다.\n예) 1,000만원 설정 → 총 수익률 +15% 달성 시 최종 자산 = 1,150만원으로 표시됩니다.',
},
commissionType: {
label: '수수료 유형',
description:
'매매 수수료 계산 방식을 선택합니다.\n• LINEAR: 거래금액 × 수수료율로 계산합니다. 국내 거래소 현물거래에 적합합니다.\n• ZERO: 수수료 없이 계산합니다. 순수 전략 성과만 검증할 때 사용합니다.',
},
commissionRate: {
label: '수수료율',
description:
'매수·매도 각 1회당 부과되는 수수료율입니다.\n예) 0.15% = 0.0015, 거래금액 100만원일 때 수수료 1,500원\n• 업비트 현물: 약 0.05% (0.0005)\n• 빗썸 현물: 약 0.25% (0.0025)\n왕복(매수+매도) 합산 수수료가 손익에서 차감됩니다.',
},
slippageRate: {
label: '슬리피지',
description:
'주문을 낼 때 의도한 가격과 실제 체결 가격의 차이 비율입니다.\n• 매수: 목표가보다 슬리피지만큼 높게 체결 (불리)\n• 매도: 목표가보다 슬리피지만큼 낮게 체결 (불리)\n예) 슬리피지 0.05%, 100만원 매수 → 실제 체결 1,000,500원\n거래량이 적거나 변동성이 높을수록 슬리피지가 커집니다.',
},
entryPriceType: {
label: '진입 가격 기준',
description:
'매수 신호 발생 시 어느 가격에 진입하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 진입. 구현이 단순하지만 후행성(look-ahead bias) 우려가 있습니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 진입. 실제 트레이딩과 가장 유사한 현실적 시뮬레이션입니다.',
},
exitPriceType: {
label: '청산 가격 기준',
description:
'매도 신호 발생 시 어느 가격에 청산하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 청산합니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 청산. 야간 갭 하락 등 실제 리스크를 반영합니다.',
},
positionDirection: {
label: '포지션 방향',
description:
'어떤 방향의 거래를 시뮬레이션할지 선택합니다.\n• LONG(매수만): 매수 후 가격 상승을 기대하는 전략입니다. 가장 일반적인 방식입니다.\n• SHORT(공매도): 매도 후 가격 하락을 기대합니다. 선물/마진 거래 전략 검증에 사용합니다.\n• BOTH(양방향): 매수 신호는 롱 진입, 매도 신호는 숏 진입으로 처리합니다.',
},
tradeSizeType: {
label: '거래 규모 유형',
description:
'매 거래마다 사용할 자금 규모 방식을 선택합니다.\n• CAPITAL_PCT(자본 비율): 현재 보유 자본의 N%를 거래에 사용합니다. 복리 효과가 반영됩니다.\n• FIXED_AMOUNT(고정 금액): 매 거래마다 동일한 금액(원)을 사용합니다. 단리 방식과 유사합니다.',
},
tradeSizeValue: {
label: '거래 규모 값',
description:
'자본 비율 선택 시: 매 거래에 사용할 자본 비율(%)입니다.\n예) 100 = 전체 자본 사용, 50 = 절반만 사용\n\n고정 금액 선택 시: 매 거래마다 사용할 원화 금액입니다.\n예) 1,000,000 = 매 거래당 100만원 고정 투자',
},
stopLossEnabled: {
label: '손절 활성화',
description:
'진입가 대비 N% 하락 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopLossRule).\n전략의 매도 조건보다 손절이 먼저 발동될 경우 손절로 청산됩니다.\n예) 손절 비율 2% 설정, 100만원 매수 → 가격이 98만원으로 하락 시 자동 손절',
},
stopLossPct: {
label: '손절 비율 (%)',
description:
'진입가 대비 이 비율만큼 가격이 하락하면 즉시 포지션을 청산합니다.\n예) 2.0 설정 → 진입가 100,000원일 때 98,000원 이하로 하락 시 손절\n낮은 값: 손실 최소화 but 잦은 손절로 기회 상실 가능\n높은 값: 큰 손실 허용 but 반전 기회 포착 가능',
},
takeProfitEnabled: {
label: '익절 활성화',
description:
'진입가 대비 N% 상승 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopGainRule).\n목표 수익률에 도달하면 확실히 이익을 실현합니다.\n예) 익절 비율 5% 설정, 100만원 매수 → 가격이 105만원으로 상승 시 자동 익절',
},
takeProfitPct: {
label: '익절 비율 (%)',
description:
'진입가 대비 이 비율만큼 가격이 상승하면 즉시 포지션을 청산합니다.\n예) 5.0 설정 → 진입가 100,000원일 때 105,000원 이상 상승 시 익절\n낮은 값: 자주 이익 실현 but 대세 상승장 추세 이탈 가능\n높은 값: 큰 수익 추구 but 수익 반납 위험 증가',
},
trailingStopEnabled: {
label: '트레일링 스탑 활성화',
description:
'포지션 보유 중 도달한 최고가 대비 N% 하락 시 자동 청산하는 기능입니다 (Ta4j TrailingStopLossRule).\n고정 손절과 달리 추세를 따라 기준점이 올라가므로 상승 추세를 최대한 활용하면서 수익을 보호합니다.\n예) 100만원 매수, 트레일링 2% → 가격이 120만원까지 상승 후 2% 하락한 117.6만원에서 청산',
},
trailingStopPct: {
label: '트레일링 비율 (%)',
description:
'포지션 진입 후 기록된 최고가 대비 이 비율만큼 하락하면 청산합니다.\n예) 2.0 설정, 최고가 120,000원 → 117,600원 (= 120,000 × 0.98) 이하 하락 시 청산\n낮은 값: 소폭 하락에도 청산, 수익 보호에 유리\n높은 값: 큰 조정을 허용, 장기 추세 추종에 유리',
},
reentryWaitBars: {
label: '재진입 대기 봉 수',
description:
'포지션 청산(매도) 후 다음 매수 신호를 받을 때까지 최소 대기해야 하는 봉(캔들) 수입니다.\n연속 매매를 방지하고 시장이 안정되길 기다리는 효과가 있습니다.\n예) 3 설정 → 매도 후 3봉 동안은 매수 신호 무시\n0: 즉시 재진입 허용 (기본값)',
},
maxOpenTrades: {
label: '최대 동시 포지션',
description:
'동시에 보유할 수 있는 최대 포지션(거래) 수입니다.\n현재 구현에서는 단일 포지션(1)만 완전히 지원됩니다.\n1로 설정 시: 이미 포지션을 보유 중이면 새 매수 신호를 무시합니다.',
},
partialExitEnabled: {
label: '분할 청산 활성화',
description:
'첫 매도 신호 발생 시 포지션 전부가 아닌 일부만 청산하는 기능입니다.\n리스크를 줄이면서도 나머지 포지션으로 추가 상승을 노릴 수 있습니다.\n예) 분할 청산 50% 설정 → 첫 매도 신호에 50% 청산, 이후 다음 신호 또는 손절/익절 시 나머지 50% 청산',
},
partialExitPct: {
label: '1차 청산 비율 (%)',
description:
'첫 번째 매도 신호 발생 시 현재 포지션의 몇 퍼센트를 청산할지 설정합니다.\n예) 50 설정 → 보유량의 절반을 1차 청산, 나머지는 계속 보유\n예) 30 설정 → 보유량의 30%만 1차 청산 (더 공격적 운용)',
},
};
// ── 섹션 정의 ─────────────────────────────────────────────────────────────
interface SettingField {
key: keyof BacktestSettingsDto;
type: 'number' | 'select' | 'toggle' | 'percent';
opts?: { value: string; label: string }[];
min?: number; max?: number; step?: number;
/** 이 필드가 보이려면 이 조건이 true여야 함 */
visibleWhen?: (cfg: BacktestSettingsDto) => boolean;
}
interface SettingSection {
title: string;
fields: SettingField[];
}
const SECTIONS: SettingSection[] = [
{
title: '💰 자본 설정',
fields: [
{ key: 'initialCapital', type: 'number', min: 100000, step: 1000000 },
],
},
{
title: '💸 비용 모델',
fields: [
{
key: 'commissionType', type: 'select',
opts: [{ value:'LINEAR', label:'LINEAR (비율 수수료)' }, { value:'ZERO', label:'ZERO (수수료 없음)' }],
},
{ key: 'commissionRate', type: 'percent', min: 0, max: 5, step: 0.01,
visibleWhen: c => c.commissionType === 'LINEAR' },
{ key: 'slippageRate', type: 'percent', min: 0, max: 5, step: 0.01 },
],
},
{
title: '📈 거래 가격',
fields: [
{
key: 'entryPriceType', type: 'select',
opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
},
{
key: 'exitPriceType', type: 'select',
opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
},
{
key: 'positionDirection', type: 'select',
opts: [
{ value:'LONG', label:'LONG (매수만)' },
{ value:'SHORT', label:'SHORT (공매도)' },
{ value:'BOTH', label:'BOTH (양방향)' },
],
},
],
},
{
title: '📦 거래 규모',
fields: [
{
key: 'tradeSizeType', type: 'select',
opts: [{ value:'CAPITAL_PCT', label:'자본 비율 (%)' }, { value:'FIXED_AMOUNT', label:'고정 금액 (원)' }],
},
{ key: 'tradeSizeValue', type: 'number', min: 1, step: 1 },
],
},
{
title: '🛑 손절',
fields: [
{ key: 'stopLossEnabled', type: 'toggle' },
{ key: 'stopLossPct', type: 'number', min: 0.1, max: 50, step: 0.1,
visibleWhen: c => !!c.stopLossEnabled },
],
},
{
title: '🎯 익절',
fields: [
{ key: 'takeProfitEnabled', type: 'toggle' },
{ key: 'takeProfitPct', type: 'number', min: 0.1, max: 200, step: 0.1,
visibleWhen: c => !!c.takeProfitEnabled },
],
},
{
title: '📉 트레일링 스탑',
fields: [
{ key: 'trailingStopEnabled', type: 'toggle' },
{ key: 'trailingStopPct', type: 'number', min: 0.1, max: 50, step: 0.1,
visibleWhen: c => !!c.trailingStopEnabled },
],
},
{
title: '🔁 재진입 제어',
fields: [
{ key: 'reentryWaitBars', type: 'number', min: 0, max: 100, step: 1 },
{ key: 'maxOpenTrades', type: 'number', min: 1, max: 10, step: 1 },
],
},
{
title: '✂️ 분할 청산',
fields: [
{ key: 'partialExitEnabled', type: 'toggle' },
{ key: 'partialExitPct', type: 'number', min: 10, max: 90, step: 5,
visibleWhen: c => !!c.partialExitEnabled },
],
},
];
// ── 컴포넌트 ────────────────────────────────────────────────────────────────
interface Props {
onClose: () => void;
onSettingsChange: (s: BacktestSettingsDto) => void;
}
export default function BacktestSettingsModal({ onClose, onSettingsChange }: Props) {
const [cfg, setCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [focused, setFocused] = useState<keyof BacktestSettingsDto | null>('initialCapital');
const {
panelRef: modalRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
// 설정 로드
useEffect(() => {
loadBacktestSettings().then(s => { if (s) setCfg(s); });
}, []);
const set = <K extends keyof BacktestSettingsDto>(k: K, v: BacktestSettingsDto[K]) =>
setCfg(prev => ({ ...prev, [k]: v }));
const handleSave = async () => {
setSaving(true);
const result = await saveBacktestSettings(cfg);
if (result) {
onSettingsChange({ ...cfg, ...result });
setSaved(true);
setTimeout(() => setSaved(false), 1500);
}
setSaving(false);
};
const focusMeta = focused ? FIELD_META[focused] : null;
// 모든 섹션의 보이는 필드를 1차원 배열로 수집 (2열 그리드용)
const allFields: Array<{ section: string; field: SettingField }> = [];
for (const sec of SECTIONS) {
for (const f of sec.fields) {
if (!f.visibleWhen || f.visibleWhen(cfg)) {
allFields.push({ section: sec.title, field: f });
}
}
}
return (
<div
className="bts-modal"
ref={modalRef}
style={{
...panelStyle,
zIndex: 9100,
cursor: dragging ? 'grabbing' : undefined,
}}
>
{/* ── 타이틀바 (드래그 핸들) ── */}
<div
className="gc-popup-header bts-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" style={{opacity:.6}}>
<circle cx="8" cy="8" r="2.5"/>
<path d="M8 1.5L8 3.5 M8 12.5L8 14.5 M1.5 8L3.5 8 M12.5 8L14.5 8
M3.4 3.4L4.8 4.8 M11.2 11.2L12.6 12.6 M12.6 3.4L11.2 4.8 M4.8 11.2L3.4 12.6"
strokeLinecap="round"/>
</svg>
<span className="bts-title"> </span>
<button className="bts-close" onMouseDown={e => e.stopPropagation()} onClick={onClose}></button>
</div>
{/* ── 2열 설정 그리드 ── */}
<div className="bts-body">
{SECTIONS.map(sec => {
const visibleFields = sec.fields.filter(f => !f.visibleWhen || f.visibleWhen(cfg));
if (visibleFields.length === 0) return null;
return (
<div key={sec.title} className="bts-section">
<div className="bts-section-title">{sec.title}</div>
<div className="bts-section-grid">
{visibleFields.map(f => (
<FieldRow
key={f.key}
field={f}
cfg={cfg}
focused={focused}
onFocus={setFocused}
onChange={set}
/>
))}
</div>
</div>
);
})}
</div>
{/* ── 하단 설명 패널 ── */}
<div className="bts-desc-panel">
{focusMeta ? (
<>
<span className="bts-desc-label">{focusMeta.label}</span>
<span className="bts-desc-sep"></span>
<span className="bts-desc-text">{focusMeta.description}</span>
</>
) : (
<span className="bts-desc-placeholder"> .</span>
)}
</div>
{/* ── 푸터 ── */}
<div className="bts-footer">
<button className="bts-btn bts-btn-reset" onClick={() => setCfg(DEFAULT_BACKTEST_SETTINGS)}>
</button>
<div style={{ flex: 1 }} />
<button className="bts-btn bts-btn-cancel" onClick={onClose}></button>
<button className="bts-btn bts-btn-save" onClick={handleSave} disabled={saving}>
{saving ? '저장 중...' : saved ? '✓ 저장됨' : '확인'}
</button>
</div>
</div>
);
}
// ── FieldRow ─────────────────────────────────────────────────────────────────
function FieldRow({
field, cfg, focused, onFocus, onChange,
}: {
field: SettingField;
cfg: BacktestSettingsDto;
focused: keyof BacktestSettingsDto | null;
onFocus: (k: keyof BacktestSettingsDto) => void;
onChange: <K extends keyof BacktestSettingsDto>(k: K, v: BacktestSettingsDto[K]) => void;
}) {
const meta = FIELD_META[field.key];
const label = meta?.label ?? field.key;
const isFocused = focused === field.key;
const renderCtrl = () => {
const val = cfg[field.key];
switch (field.type) {
case 'toggle':
return (
<button
className={`bts-toggle${val ? ' bts-toggle-on' : ''}`}
onClick={e => { e.stopPropagation(); onChange(field.key, !val as BacktestSettingsDto[typeof field.key]); }}
>
{val ? 'ON' : 'OFF'}
</button>
);
case 'select':
return (
<select
className="bts-select"
value={String(val)}
onChange={e => onChange(field.key, e.target.value as BacktestSettingsDto[typeof field.key])}
onClick={e => e.stopPropagation()}
>
{field.opts?.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
);
case 'percent':
return (
<div className="bts-pct-wrap">
<input
type="number"
className="bts-input"
value={((val as number) * 100).toFixed(3)}
min={field.min} max={field.max} step={field.step ?? 0.01}
onChange={e => onChange(field.key, Number(e.target.value) / 100 as BacktestSettingsDto[typeof field.key])}
onClick={e => e.stopPropagation()}
/>
<span className="bts-unit">%</span>
</div>
);
default:
return (
<input
type="number"
className="bts-input"
value={val as number}
min={field.min} max={field.max} step={field.step ?? 1}
onChange={e => onChange(field.key, Number(e.target.value) as BacktestSettingsDto[typeof field.key])}
onClick={e => e.stopPropagation()}
/>
);
}
};
return (
<div
className={`bts-row${isFocused ? ' bts-row-focused' : ''}`}
onClick={() => onFocus(field.key)}
>
<span className="bts-row-label">{label}</span>
<span className="bts-row-ctrl">{renderCtrl()}</span>
</div>
);
}
@@ -0,0 +1,335 @@
/**
* 설정 화면 — 백테스팅 설정 패널 (2열 카드 레이아웃)
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
type BacktestSettingsDto,
DEFAULT_BACKTEST_SETTINGS,
loadBacktestSettings,
saveBacktestSettings,
} from '../utils/backendApi';
// ── 백테스팅 전용 UI 컴포넌트 ───────────────────────────────────────────────
const BtSection: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<section className="stg-bt-section">
<h3 className="stg-bt-section-title">{title}</h3>
<div className="stg-bt-section-body">{children}</div>
</section>
);
const BtGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="stg-bt-grid">{children}</div>
);
const BtField: React.FC<{
label: string;
desc?: string;
full?: boolean;
children: React.ReactNode;
}> = ({ label, desc, full, children }) => (
<div className={`stg-bt-field${full ? ' stg-bt-field--full' : ''}`}>
<div className="stg-bt-field-head">
<span className="stg-bt-field-label">{label}</span>
{desc && <span className="stg-bt-field-desc">{desc}</span>}
</div>
<div className="stg-bt-field-ctrl">{children}</div>
</div>
);
export interface BacktestSettingsPanelProps {
btAutoPopup?: boolean;
onBtAutoPopup?: (v: boolean) => void;
btShowPrice?: boolean;
onBtShowPrice?: (v: boolean) => void;
}
export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
btAutoPopup = true,
onBtAutoPopup,
btShowPrice = true,
onBtShowPrice,
}) => {
const [cfg, setCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
loadBacktestSettings().then(s => { if (s) setCfg(s); });
}, []);
const update = useCallback(async (patch: Partial<BacktestSettingsDto>) => {
const next = { ...cfg, ...patch };
setCfg(next);
setSaving(true);
setSaved(false);
try {
await saveBacktestSettings(next);
setSaved(true);
setTimeout(() => setSaved(false), 1500);
} finally {
setSaving(false);
}
}, [cfg]);
const field = <K extends keyof BacktestSettingsDto>(key: K, value: BacktestSettingsDto[K]) =>
update({ [key]: value } as Partial<BacktestSettingsDto>);
return (
<div className="stg-bt-panel">
{(saving || saved) && (
<div className={`stg-bt-status${saved ? ' stg-bt-status--ok' : ''}`}>
{saving ? '저장 중…' : '✓ 저장됨'}
</div>
)}
<BtSection title="표시 설정">
<BtGrid>
<BtField
label="결과 팝업 자동 표시"
desc="백테스팅 실행 완료 후 결과 팝업을 자동으로 띄웁니다. OFF이면 툴바의 '결과보기' 버튼을 직접 클릭해야 합니다."
>
<label className="stg-toggle">
<input type="checkbox" checked={btAutoPopup} onChange={e => onBtAutoPopup?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</BtField>
<BtField
label="매수/매도 금액 표시"
desc="차트 마커에 체결 금액을 함께 표시합니다. ON: '매수 : 1,234,000' / OFF: '매수'만 표시."
>
<label className="stg-toggle">
<input type="checkbox" checked={btShowPrice} onChange={e => onBtShowPrice?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</BtField>
</BtGrid>
</BtSection>
<BtSection title="자본 · 거래 규모">
<BtGrid>
<BtField
label="초기 자본 (원)"
desc="백테스팅 시뮬레이션의 시작 자본금입니다. 수익/손실과 최종 자산을 이 금액 기준으로 계산합니다."
>
<input
className="stg-input stg-input--wide"
type="number" min={100000} step={1000000}
value={cfg.initialCapital}
onChange={e => field('initialCapital', Number(e.target.value))}
/>
</BtField>
<BtField label="거래 규모 유형" desc="자본 비율(복리) 또는 매 거래 고정 금액 중 선택합니다.">
<select className="stg-select stg-select--wide"
value={cfg.tradeSizeType}
onChange={e => field('tradeSizeType', e.target.value as 'CAPITAL_PCT' | 'FIXED_AMOUNT')}>
<option value="CAPITAL_PCT"> (%)</option>
<option value="FIXED_AMOUNT"> ()</option>
</select>
</BtField>
<BtField
label={cfg.tradeSizeType === 'CAPITAL_PCT' ? '사용 자본 비율 (%)' : '거래당 금액 (원)'}
desc={cfg.tradeSizeType === 'CAPITAL_PCT'
? '매 거래에 사용할 자본 비율. 100 = 전체 자본.'
: '매 거래마다 사용할 원화 금액.'}
>
<input
className="stg-input stg-input--wide"
type="number" min={1} step={cfg.tradeSizeType === 'CAPITAL_PCT' ? 1 : 100000}
value={cfg.tradeSizeValue}
onChange={e => field('tradeSizeValue', Number(e.target.value))}
/>
</BtField>
</BtGrid>
</BtSection>
<BtSection title="비용 모델">
<BtGrid>
<BtField label="수수료 유형" desc="LINEAR: 거래금액 × 수수료율. ZERO: 수수료 없음.">
<select className="stg-select stg-select--wide"
value={cfg.commissionType}
onChange={e => field('commissionType', e.target.value as 'LINEAR' | 'ZERO')}>
<option value="LINEAR">LINEAR ( )</option>
<option value="ZERO">ZERO ( )</option>
</select>
</BtField>
{cfg.commissionType === 'LINEAR' ? (
<BtField label="수수료율 (%)" desc="매수·매도 각 1회당 수수료율. 업비트 현물 약 0.05%.">
<input
className="stg-input stg-input--wide"
type="number" min={0} max={5} step={0.01}
value={(cfg.commissionRate ?? 0) * 100}
onChange={e => field('commissionRate', Number(e.target.value) / 100)}
/>
</BtField>
) : (
<div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />
)}
<BtField
label="슬리피지 (%)"
desc="의도 가격과 실제 체결 가격 차이. 매수는 높게, 매도는 낮게 체결되는 효과."
>
<input
className="stg-input stg-input--wide"
type="number" min={0} max={5} step={0.01}
value={(cfg.slippageRate ?? 0) * 100}
onChange={e => field('slippageRate', Number(e.target.value) / 100)}
/>
</BtField>
</BtGrid>
</BtSection>
<BtSection title="거래 가격 기준">
<BtGrid>
<BtField label="진입 가격 기준" desc="매수 신호 시 진입 가격. 종가 즉시 진입 또는 다음 봉 시가 진입.">
<select className="stg-select stg-select--wide"
value={cfg.entryPriceType}
onChange={e => field('entryPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
<option value="CLOSE"> (Close)</option>
<option value="NEXT_OPEN"> (Next Open)</option>
</select>
</BtField>
<BtField label="청산 가격 기준" desc="매도 신호 시 청산 가격. 종가 즉시 청산 또는 다음 봉 시가 청산.">
<select className="stg-select stg-select--wide"
value={cfg.exitPriceType}
onChange={e => field('exitPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
<option value="CLOSE"> (Close)</option>
<option value="NEXT_OPEN"> (Next Open)</option>
</select>
</BtField>
<BtField label="포지션 방향" desc="LONG(매수만), SHORT(공매도), BOTH(양방향) 중 선택.">
<select className="stg-select stg-select--wide"
value={cfg.positionDirection}
onChange={e => field('positionDirection', e.target.value as 'LONG' | 'SHORT' | 'BOTH')}>
<option value="LONG">LONG ()</option>
<option value="SHORT">SHORT ()</option>
<option value="BOTH">BOTH ()</option>
</select>
</BtField>
</BtGrid>
</BtSection>
<BtSection title="리스크 관리">
<BtGrid>
<BtField label="손절 활성화" desc="진입가 대비 N% 하락 시 자동 청산 (StopLossRule).">
<label className="stg-toggle">
<input type="checkbox" checked={!!cfg.stopLossEnabled}
onChange={e => field('stopLossEnabled', e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</BtField>
{cfg.stopLossEnabled ? (
<BtField label="손절 비율 (%)" desc="진입가 대비 하락 허용 비율.">
<input className="stg-input stg-input--wide" type="number" min={0.1} max={50} step={0.1}
value={cfg.stopLossPct ?? 2}
onChange={e => field('stopLossPct', Number(e.target.value))} />
</BtField>
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
<BtField label="익절 활성화" desc="진입가 대비 N% 상승 시 자동 청산 (StopGainRule).">
<label className="stg-toggle">
<input type="checkbox" checked={!!cfg.takeProfitEnabled}
onChange={e => field('takeProfitEnabled', e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</BtField>
{cfg.takeProfitEnabled ? (
<BtField label="익절 비율 (%)" desc="진입가 대비 상승 목표 비율.">
<input className="stg-input stg-input--wide" type="number" min={0.1} max={200} step={0.1}
value={cfg.takeProfitPct ?? 5}
onChange={e => field('takeProfitPct', Number(e.target.value))} />
</BtField>
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
<BtField label="트레일링 스탑 활성화" desc="최고가 대비 N% 하락 시 자동 청산.">
<label className="stg-toggle">
<input type="checkbox" checked={!!cfg.trailingStopEnabled}
onChange={e => field('trailingStopEnabled', e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</BtField>
{cfg.trailingStopEnabled ? (
<BtField label="트레일링 비율 (%)" desc="최고가 대비 하락 허용 비율.">
<input className="stg-input stg-input--wide" type="number" min={0.1} max={50} step={0.1}
value={cfg.trailingStopPct ?? 2}
onChange={e => field('trailingStopPct', Number(e.target.value))} />
</BtField>
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
</BtGrid>
</BtSection>
<BtSection title="재진입 · 분할 청산">
<BtGrid>
<BtField label="재진입 대기 봉 수" desc="매도 후 다음 매수까지 최소 대기 캔들 수. 0 = 즉시 재진입.">
<input className="stg-input stg-input--wide" type="number" min={0} max={100} step={1}
value={cfg.reentryWaitBars ?? 0}
onChange={e => field('reentryWaitBars', Number(e.target.value))} />
</BtField>
<BtField label="최대 동시 포지션" desc="동시 보유 가능한 최대 포지션 수 (현재 단일 포지션 지원).">
<input className="stg-input stg-input--wide" type="number" min={1} max={10} step={1}
value={cfg.maxOpenTrades ?? 1}
onChange={e => field('maxOpenTrades', Number(e.target.value))} />
</BtField>
<BtField label="분할 청산 활성화" desc="첫 매도 신호 시 포지션의 일부만 청산합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={!!cfg.partialExitEnabled}
onChange={e => field('partialExitEnabled', e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</BtField>
{cfg.partialExitEnabled ? (
<BtField label="1차 청산 비율 (%)" desc="첫 매도 시 청산할 포지션 비율.">
<input className="stg-input stg-input--wide" type="number" min={10} max={90} step={5}
value={cfg.partialExitPct ?? 50}
onChange={e => field('partialExitPct', Number(e.target.value))} />
</BtField>
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
</BtGrid>
</BtSection>
<BtSection title="포지션 종속성 모드">
<BtField
full
label="매매 시그널 발생 조건"
desc="매도 시그널이 이전 매수(포지션) 없이도 발생할지 선택합니다."
>
<div className="stg-bt-radio-group">
<label className="stg-bt-radio">
<input
type="radio"
name="bt-pos-mode"
value="LONG_ONLY"
checked={(cfg.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
onChange={() => field('positionMode', 'LONG_ONLY')}
/>
<span className="stg-bt-radio-text">
<strong> (Long-Only)</strong>
<span> · Ta4j </span>
</span>
</label>
<label className="stg-bt-radio">
<input
type="radio"
name="bt-pos-mode"
value="SIGNAL_ONLY"
checked={cfg.positionMode === 'SIGNAL_ONLY'}
onChange={() => field('positionMode', 'SIGNAL_ONLY')}
/>
<span className="stg-bt-radio-text">
<strong> (Signal-Only)</strong>
<span> , · </span>
</span>
</label>
</div>
</BtField>
{cfg.positionMode === 'SIGNAL_ONLY' && (
<div className="stg-bt-hint stg-bt-hint--warn">
Signal-Only 모드: 매도
</div>
)}
</BtSection>
</div>
);
};
export default BacktestSettingsPanel;
+108
View File
@@ -0,0 +1,108 @@
import React from 'react';
import type { Timeframe } from '../types';
import TimezonePicker from './TimezonePicker';
interface BottomBarProps {
timeframe: Timeframe;
logScale: boolean;
percentScale: boolean;
showGrid: boolean;
lastTime: number;
displayTimezone: string;
onTimezoneChange: (tz: string) => void;
onTimeframe: (t: Timeframe) => void;
onLogScale: () => void;
onPercentScale: () => void;
onAutoScale: () => void;
onFitContent: () => void;
onToggleGrid: () => void;
}
const PERIODS: { label: string; tf: Timeframe }[] = [
{ label: '1M', tf: '1M' },
{ label: '1W', tf: '1W' },
{ label: '1D', tf: '1D' },
{ label: '4h', tf: '4h' },
{ label: '1h', tf: '1h' },
{ label: '30m',tf: '30m' },
{ label: '5m', tf: '5m' },
{ label: '3m', tf: '3m' },
{ label: '1m', tf: '1m' },
];
const IcCalendar = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="1" y="3" width="12" height="10" rx="1"/>
<line x1="1" y1="6" x2="13" y2="6"/>
<line x1="4" y1="1" x2="4" y2="4"/>
<line x1="10" y1="1" x2="10" y2="4"/>
</svg>
);
const IcGrid = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="1" y="1" width="12" height="12" rx="1"/>
<line x1="5" y1="1" x2="5" y2="13"/>
<line x1="9" y1="1" x2="9" y2="13"/>
<line x1="1" y1="5" x2="13" y2="5"/>
<line x1="1" y1="9" x2="13" y2="9"/>
</svg>
);
const BottomBar: React.FC<BottomBarProps> = ({
timeframe, logScale, percentScale, showGrid, lastTime,
displayTimezone, onTimezoneChange,
onTimeframe, onLogScale, onPercentScale, onAutoScale, onFitContent, onToggleGrid,
}) => {
return (
<div className="tv-bottom-bar">
<div className="tv-period-group">
{PERIODS.map(p => (
<button
key={p.label}
className={`tv-period-btn ${timeframe === p.tf ? 'active' : ''}`}
onClick={() => onTimeframe(p.tf)}
>{p.label}</button>
))}
</div>
<button className="tv-period-btn" title="화면 맞춤 (F)" onClick={onFitContent}>
<IcCalendar />
</button>
<div className="tv-bottom-spacer" />
<TimezonePicker
variant="bar"
value={displayTimezone}
displayUnix={lastTime}
onChange={onTimezoneChange}
/>
<div className="tv-period-group tv-right-toggles">
<button
className={`tv-period-btn tv-toggle-btn ${showGrid ? 'active' : ''}`}
title={showGrid ? '그리드 숨기기' : '그리드 표시'}
onClick={onToggleGrid}
><IcGrid /></button>
<button
className={`tv-period-btn tv-toggle-btn ${percentScale ? 'active' : ''}`}
title="퍼센트(%) 스케일"
onClick={onPercentScale}
>%</button>
<button
className={`tv-period-btn tv-toggle-btn ${logScale ? 'active' : ''}`}
onClick={onLogScale}
title="로그 스케일 (L)"
>log</button>
<button
className="tv-period-btn tv-toggle-btn"
title="가격 스케일 자동 맞춤"
onClick={onAutoScale}
>auto</button>
</div>
</div>
);
};
export default BottomBar;
@@ -0,0 +1,94 @@
/**
* 캔들 pane 좌측 하단 — 캔들 확대 보기 / 기본 보기 복원 버튼
*/
import React, { useState, useEffect, useCallback } from 'react';
import { ChartManager } from '../utils/ChartManager';
const IconExpand = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1,9 1,13 5,13"/>
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
<polyline points="13,5 13,1 9,1"/>
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
</svg>
);
const IconRestore = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="5,13 1,13 1,9"/>
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
<polyline points="9,1 13,1 13,5"/>
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
</svg>
);
interface CandlePaneControlsProps {
manager: ChartManager;
containerEl: HTMLElement;
candleOnly: boolean;
onExpand: () => void;
onRestore: () => void;
}
const CandlePaneControls: React.FC<CandlePaneControlsProps> = ({
manager,
containerEl,
candleOnly,
onExpand,
onRestore,
}) => {
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const updatePos = useCallback(() => {
const main = manager.getPaneLayouts().find(l => l.paneIndex === 0);
if (!main) { setPos(null); return; }
const rect = containerEl.getBoundingClientRect();
const paneBottomY = rect.top + main.topY + main.height;
setPos({
left: rect.left + 4,
top: paneBottomY - 30,
});
}, [manager, containerEl]);
useEffect(() => {
updatePos();
const ro = new ResizeObserver(() => updatePos());
ro.observe(containerEl);
const id = setInterval(updatePos, 400);
return () => { ro.disconnect(); clearInterval(id); };
}, [containerEl, updatePos, candleOnly]);
if (!pos) return null;
return (
<div
className="candle-pane-controls"
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 504 }}
onMouseDown={e => e.stopPropagation()}
>
{candleOnly ? (
<button
type="button"
className="pane-btn pane-btn-expand active"
onClick={e => { e.stopPropagation(); onRestore(); }}
title="기본 보기로 복원"
>
<IconRestore />
</button>
) : (
<button
type="button"
className="pane-btn pane-btn-expand"
onClick={e => { e.stopPropagation(); onExpand(); }}
title="캔들 전체보기 (캔들 영역 오버레이 지표 유지, 하단 보조지표 숨김)"
>
<IconExpand />
</button>
)}
</div>
);
};
export default CandlePaneControls;
@@ -0,0 +1,64 @@
/**
* 차트 우클릭 컨텍스트 메뉴 (매수 / 매도)
*/
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
export interface ChartContextMenuProps {
x: number;
y: number;
marketLabel: string;
priceLabel: string;
onBuy: () => void;
onSell: () => void;
onClose: () => void;
}
const ChartContextMenu: React.FC<ChartContextMenuProps> = ({
x, y, marketLabel, priceLabel, onBuy, onSell, onClose,
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
const menuW = 168;
const menuH = 88;
const left = Math.min(x, window.innerWidth - menuW - 8);
const top = Math.min(y, window.innerHeight - menuH - 8);
return createPortal(
<div
ref={ref}
className="chart-ctx-menu"
style={{ left, top }}
role="menu"
>
<div className="chart-ctx-menu-hint">
<span>{marketLabel}</span>
<span className="chart-ctx-menu-price">{priceLabel}</span>
</div>
<button type="button" className="chart-ctx-menu-item chart-ctx-menu-item--buy" onClick={onBuy}>
</button>
<button type="button" className="chart-ctx-menu-item chart-ctx-menu-item--sell" onClick={onSell}>
</button>
</div>,
document.body,
);
};
export default ChartContextMenu;
@@ -0,0 +1,60 @@
/**
* ChartHoverToolbar
* 차트 영역에 마우스 오버 시 하단 중앙에 표시되는 플로팅 툴바.
* - 마우스가 차트 영역을 벗어나면 사라진다.
* - 버튼: 줌아웃(−), 줌인(+), 전체보기(⌐┘), 왼쪽(‹), 오른쪽(›)
*/
import React from 'react';
interface ChartHoverToolbarProps {
onZoomOut: () => void;
onZoomIn: () => void;
onFit: () => void;
onScrollLeft: () => void;
onScrollRight: () => void;
/** 멀티차트 모드에서 이 슬롯을 단일 전체화면으로 확장. 없으면 기본 fitContent() 사용 */
onFullView?: () => void;
}
const ChartHoverToolbar: React.FC<ChartHoverToolbarProps> = ({
onZoomOut, onZoomIn, onFit, onScrollLeft, onScrollRight, onFullView,
}) => (
<div className="chart-hover-toolbar" onMouseDown={e => e.stopPropagation()}>
<button className="cht-btn" onClick={onZoomOut} title="축소 ()">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="2" y1="7" x2="12" y2="7"/>
</svg>
</button>
<button className="cht-btn" onClick={onZoomIn} title="확대 (+)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="7" y1="2" x2="7" y2="12"/>
<line x1="2" y1="7" x2="12" y2="7"/>
</svg>
</button>
{/* 멀티차트: 단일 전체화면 전환 / 단일차트: 전체 데이터 핏 */}
<button className="cht-btn" onClick={onFullView ?? onFit} title={onFullView ? '단일 차트로 전환' : '전체 보기'}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<polyline points="5,1 1,1 1,5"/>
<polyline points="9,13 13,13 13,9"/>
<line x1="1" y1="1" x2="6" y2="6"/>
<line x1="13" y1="13" x2="8" y2="8"/>
</svg>
</button>
<button className="cht-btn" onClick={onScrollLeft} title="왼쪽으로 이동 (과거)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9,2 4,7 9,12"/>
</svg>
</button>
<button className="cht-btn" onClick={onScrollRight} title="오른쪽으로 이동 (최근)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="5,2 10,7 5,12"/>
</svg>
</button>
</div>
);
export default ChartHoverToolbar;
+127
View File
@@ -0,0 +1,127 @@
/**
* 캔들차트 상단 OHLCV·지표 범례 (tv-legend)
*/
import React from 'react';
import type { WsStatus } from '../hooks/useUpbitData';
import type { ChartMode, IndicatorConfig, LegendData } from '../types';
import type { ChartLegendVisibility } from '../types/chartLegend';
import { formatPrice } from '../utils/dataGenerator';
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
import { formatBbLegendLabel } from '../utils/bollingerConfig';
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null;
const map: Record<WsStatus, { dot: string; label: string }> = {
connecting: { dot: 'dot-yellow', label: '연결 중...' },
connected: { dot: 'dot-lime', label: '실시간' },
disconnected: { dot: 'dot-red', label: '재연결 중' },
error: { dot: 'dot-red', label: '오류' },
};
const { dot, label } = map[status];
return <span className={`ws-badge ${dot}`}><span className="ws-dot" />{label}</span>;
};
export interface ChartLegendBarProps {
visibility: ChartLegendVisibility;
symbol: string;
timeframe: string;
useUpbit: boolean;
wsStatus: WsStatus;
mode: ChartMode;
currentBar: LegendData | null;
isUp: boolean;
dailyChange: number;
dailyChangePct: number;
indicators: IndicatorConfig[];
legend: LegendData | null;
}
export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
visibility: v,
symbol,
timeframe,
useUpbit,
wsStatus,
mode,
currentBar,
isUp,
dailyChange,
dailyChangePct,
indicators,
legend,
}) => {
const showOhlc = currentBar && (v.open || v.high || v.low || v.close || v.change);
const showAny =
v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge)
|| showOhlc || (v.indicators && indicators.length > 0);
if (!showAny) return null;
return (
<div className="tv-legend">
{v.symbol && (
<span className="tv-legend-sym">
<span className="tv-legend-dot" style={{ background: isUp ? 'var(--up)' : 'var(--down)' }} />
{symbol}
</span>
)}
{v.timeframe && <span className="tv-legend-tf">· {timeframe}</span>}
{useUpbit && v.liveStatus && <WsBadge status={wsStatus} isUpbit={true} />}
{mode === 'trading' && v.tradingBadge && <span className="tv-legend-badge trading"></span>}
{showOhlc && (
<span className="tv-legend-ohlcv">
{v.open && (
<span>O <b className={isUp ? 'up' : 'down'}>{formatPrice(currentBar!.open)}</b></span>
)}
{v.high && (
<span>H <b className="up">{formatPrice(currentBar!.high)}</b></span>
)}
{v.low && (
<span>L <b className="down">{formatPrice(currentBar!.low)}</b></span>
)}
{v.close && (
<span>C <b className={isUp ? 'up' : 'down'}>{formatPrice(currentBar!.close)}</b></span>
)}
{v.change && (
<span className={`tv-legend-chg ${isUp ? 'up' : 'down'}`}>
{isUp ? '▲' : '▼'} {formatPrice(Math.abs(dailyChange))} ({Math.abs(dailyChangePct).toFixed(2)}%)
</span>
)}
</span>
)}
{v.indicators && indicators.map(ind => {
const def = getIndicatorDef(ind.type);
const vals = legend?.indicatorValues?.[ind.type];
const plots = ind.plots ?? def?.plots ?? [];
const label = ind.type === 'BollingerBands'
? formatBbLegendLabel(ind.params ?? {})
: getIndicatorChartTitle(ind.type);
const fmtVal = (n: number) =>
Math.abs(n) >= 1000
? n.toLocaleString('ko-KR', { maximumFractionDigits: 1 })
: n.toFixed(2);
return def ? (
<span key={ind.id} className="tv-legend-ind">
<span className="tv-legend-ind-name">{label}</span>
{v.indicatorValues && vals && vals.length > 0 && (
<span className="tv-legend-ind-vals">
{vals.map((n, i) =>
Number.isFinite(n) ? (
<b
key={i}
style={{ color: (plots[i]?.color ?? def.plots?.[i]?.color ?? '#42A5F5').slice(0, 7) }}
>
{fmtVal(n)}
</b>
) : null,
)}
</span>
)}
</span>
) : null;
})}
</div>
);
};
export default ChartLegendBar;
+358
View File
@@ -0,0 +1,358 @@
/**
* ChartMagnifier
*
* 차트 위에 떠 있는 돋보기(루페) 오버레이.
* - 헤더 드래그로 이동
* - 8방향 핸들로 리사이즈
* - 내부 canvas 에 차트 픽셀을 확대 복사 (rAF 루프)
* - X/Y 동시 줌 배율 조절
* - Y축 전용 줌(Y-Zoom): 수직 방향만 추가 확대 → 미세한 Y값 변화도 파악 가능
* - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸
*/
import React, { useRef, useEffect, useCallback, useState } from 'react';
interface ChartMagnifierProps {
/** LWC canvas 들이 들어있는 chart-container div */
containerRef: React.RefObject<HTMLDivElement>;
/** position:relative 부모 (tv-chart-wrap) — 위치 기준점 */
wrapperRef: React.RefObject<HTMLDivElement>;
enabled: boolean;
onClose: () => void;
}
const HEADER_H = 30;
const MIN_W = 180;
const MIN_H = 120 + HEADER_H;
const INIT_W = 320;
const INIT_H = 240 + HEADER_H;
const XY_STEPS: number[] = [1.5, 2, 2.5, 3, 4, 5, 6, 8];
const Y_STEPS: number[] = [1, 1.5, 2, 3, 5, 8, 12];
type ResizeDir = 'nw'|'n'|'ne'|'e'|'se'|'s'|'sw'|'w';
const HANDLES: { id: ResizeDir; cursor: string; style: React.CSSProperties }[] = [
{ id:'nw', cursor:'nw-resize', style:{ top:-5, left:-5 } },
{ id:'n', cursor:'n-resize', style:{ top:-5, left:'50%', transform:'translateX(-50%)' } },
{ id:'ne', cursor:'ne-resize', style:{ top:-5, right:-5 } },
{ id:'e', cursor:'e-resize', style:{ top:'50%', right:-5, transform:'translateY(-50%)' } },
{ id:'se', cursor:'se-resize', style:{ bottom:-5, right:-5 } },
{ id:'s', cursor:'s-resize', style:{ bottom:-5, left:'50%',transform:'translateX(-50%)' } },
{ id:'sw', cursor:'sw-resize', style:{ bottom:-5, left:-5 } },
{ id:'w', cursor:'w-resize', style:{ top:'50%', left:-5, transform:'translateY(-50%)' } },
];
const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
containerRef, wrapperRef, enabled, onClose,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const tempCvRef = useRef<HTMLCanvasElement | null>(null); // Y-Zoom 용 임시 캔버스
const rafRef = useRef<number>(0);
// ── position / size / xy-zoom / y-zoom ────────────────────────────────────
// position: fixed 기준 — 뷰포트 좌표 (wrapper 밖으로도 이동 가능)
const initPos = useCallback(() => {
const wr = wrapperRef.current?.getBoundingClientRect();
return wr ? { x: wr.left + 40, y: wr.top + 40 } : { x: 80, y: 80 };
}, [wrapperRef]);
const [pos, setPos] = useState(() => ({ x: 80, y: 80 }));
const [size, setSize] = useState({ w: INIT_W, h: INIT_H });
const [xyZoom, setXyZoom] = useState(2.5);
const [yZoom, setYZoom] = useState(1);
/** LIVE 지시등: true = 직전 프레임 대비 픽셀 변화 있음 */
const [isLive, setIsLive] = useState(false);
const posRef = useRef({ x: 80, y: 80 });
const sizeRef = useRef({ w: INIT_W, h: INIT_H });
const xyZoomRef = useRef(2.5);
const yZoomRef = useRef(1);
// 픽셀 변화 감지용: 이전 프레임의 중앙 수직 라인 픽셀 데이터
const prevRowRef = useRef<Uint8ClampedArray | null>(null);
const liveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── rAF: 차트 캔버스 픽셀을 확대하여 magnifier canvas 에 복사 ──────────────
const renderFrame = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
const wrapper = wrapperRef.current;
if (!canvas || !container || !wrapper) return;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
const { x, y } = posRef.current;
const { w, h } = sizeRef.current;
const zm = xyZoomRef.current;
const yz = yZoomRef.current;
const canvasH = h - HEADER_H;
const dpr = window.devicePixelRatio || 1;
// canvas 해상도 유지
const tW = Math.round(w * dpr);
const tH = Math.round(canvasH * dpr);
if (canvas.width !== tW || canvas.height !== tH) {
canvas.width = tW;
canvas.height = tH;
canvas.style.width = w + 'px';
canvas.style.height = canvasH + 'px';
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 샘플링 중심 — position:fixed 이므로 pos가 이미 뷰포트 절대 좌표
const centerX = x + w / 2;
const centerY = y + HEADER_H + canvasH / 2;
// 원본 영역 (xy-zoom 적용)
const srcW = w / zm;
const srcH = canvasH / zm;
const srcL = centerX - srcW / 2;
const srcT = centerY - srcH / 2;
// position:fixed → pos 자체가 뷰포트 좌표이므로 wrapper 오프셋 불필요
const ssrcL = srcL;
const ssrcT = srcT;
// LWC 캔버스 레이어 순서대로 복사
const layers = Array.from(container.querySelectorAll<HTMLCanvasElement>('canvas'));
for (const lc of layers) {
const cRect = lc.getBoundingClientRect();
const csX = (ssrcL - cRect.left) * dpr;
const csY = (ssrcT - cRect.top ) * dpr;
const csW = srcW * dpr;
const csH = srcH * dpr;
const x0 = Math.max(0, csX);
const y0 = Math.max(0, csY);
const x1 = Math.min(lc.width, csX + csW);
const y1 = Math.min(lc.height, csY + csH);
if (x1 <= x0 || y1 <= y0) continue;
const dstX = ((x0 - csX) / csW) * canvas.width;
const dstY = ((y0 - csY) / csH) * canvas.height;
const dstW = ((x1 - x0) / csW) * canvas.width;
const dstH = ((y1 - y0) / csH) * canvas.height;
try { ctx.drawImage(lc, x0, y0, x1-x0, y1-y0, dstX, dstY, dstW, dstH); }
catch { /* cross-origin 무시 */ }
}
// ── Y-Zoom: 수직 방향 추가 확대 ───────────────────────────────────────────
// Y축의 미세한 움직임을 파악할 수 있도록 중앙 기준으로 수직만 재확대
if (yz > 1) {
// 임시 캔버스 초기화 (한 번만 생성)
let tmp = tempCvRef.current;
if (!tmp || tmp.width !== canvas.width || tmp.height !== canvas.height) {
tmp = document.createElement('canvas');
tmp.width = canvas.width;
tmp.height = canvas.height;
tempCvRef.current = tmp;
}
const tCtx = tmp.getContext('2d')!;
tCtx.clearRect(0, 0, tmp.width, tmp.height);
tCtx.drawImage(canvas, 0, 0);
// 중앙 ±(1/yz) 영역만 잘라 전체 높이로 늘림
const srcYH = Math.round(canvas.height / yz);
const srcYT = Math.round((canvas.height - srcYH) / 2);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(tmp, 0, srcYT, canvas.width, srcYH, 0, 0, canvas.width, canvas.height);
// Y-Zoom 배율 워터마크
ctx.fillStyle = 'rgba(255,200,50,0.6)';
ctx.font = `bold ${Math.round(10 * dpr)}px monospace`;
ctx.fillText(`Y×${yz}`, 4 * dpr, (canvasH - 4) * dpr);
}
// ── 테두리 ─────────────────────────────────────────────────────────────────
ctx.strokeStyle = 'rgba(255, 200, 50, 0.35)';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1);
// ── LIVE 감지: 중앙 세로 라인 픽셀 샘플링으로 변화 감지 ─────────────────
try {
const midX = Math.floor(canvas.width / 2);
const sample = ctx.getImageData(midX, 0, 1, canvas.height).data;
const prev = prevRowRef.current;
if (prev && prev.length === sample.length) {
let diff = 0;
for (let i = 0; i < sample.length; i += 4) {
diff += Math.abs(sample[i] - prev[i]);
diff += Math.abs(sample[i+1] - prev[i+1]);
diff += Math.abs(sample[i+2] - prev[i+2]);
}
if (diff > 30) { // 임계값: 미세 잡음 무시
setIsLive(true);
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
liveTimerRef.current = setTimeout(() => setIsLive(false), 600);
}
}
prevRowRef.current = new Uint8ClampedArray(sample);
} catch { /* getImageData 실패 무시 */ }
}, [containerRef, wrapperRef]);
// enabled 시 wrapper 위치 기준으로 초기 위치 설정
useEffect(() => {
if (!enabled) return;
const p = initPos();
posRef.current = p;
setPos(p);
}, [enabled, initPos]);
// rAF 루프
useEffect(() => {
if (!enabled) { cancelAnimationFrame(rafRef.current); return; }
const loop = () => { renderFrame(); rafRef.current = requestAnimationFrame(loop); };
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [enabled, renderFrame]);
// ── 드래그 (헤더) ─────────────────────────────────────────────────────────
const dragStart = useRef<{ mx: number; my: number; px: number; py: number } | null>(null);
const onHeaderDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
dragStart.current = { mx: e.clientX, my: e.clientY, px: posRef.current.x, py: posRef.current.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onHeaderMove = useCallback((e: React.PointerEvent) => {
if (!dragStart.current) return;
const dx = e.clientX - dragStart.current.mx;
const dy = e.clientY - dragStart.current.my;
const vw = window.innerWidth;
const vh = window.innerHeight;
const { w, h } = sizeRef.current;
// 헤더가 항상 화면 안에 있도록 최소 마진만 유지 (위아래 자유 이동)
const np = {
x: Math.max(-(w - 60), Math.min(vw - 60, dragStart.current.px + dx)),
y: Math.max(-(h - HEADER_H), Math.min(vh - HEADER_H, dragStart.current.py + dy)),
};
posRef.current = np;
setPos(np);
}, []);
const onHeaderUp = useCallback(() => { dragStart.current = null; }, []);
// ── 리사이즈 핸들 ──────────────────────────────────────────────────────────
const resizeStart = useRef<{
dir: ResizeDir; mx: number; my: number;
px: number; py: number; pw: number; ph: number;
} | null>(null);
const onHandleDown = useCallback((e: React.PointerEvent, dir: ResizeDir) => {
e.preventDefault(); e.stopPropagation();
resizeStart.current = {
dir, mx: e.clientX, my: e.clientY,
px: posRef.current.x, py: posRef.current.y,
pw: sizeRef.current.w, ph: sizeRef.current.h,
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onHandleMove = useCallback((e: React.PointerEvent) => {
const rs = resizeStart.current; if (!rs) return;
const { dir, mx, my, px, py, pw, ph } = rs;
const dx = e.clientX - mx; const dy = e.clientY - my;
let nx = px, ny = py, nw = pw, nh = ph;
if (dir.includes('e')) { nw = Math.max(MIN_W, pw + dx); }
if (dir.includes('w')) { nw = Math.max(MIN_W, pw - dx); nx = px + pw - nw; }
if (dir.includes('s')) { nh = Math.max(MIN_H, ph + dy); }
if (dir.includes('n')) { nh = Math.max(MIN_H, ph - dy); ny = py + ph - nh; }
// 리사이즈 시 뷰포트 경계 안에서만 클램프 (헤더 마진 확보)
nx = Math.max(-(nw - 60), Math.min(window.innerWidth - 60, nx));
ny = Math.max(-(nh - HEADER_H), Math.min(window.innerHeight - HEADER_H, ny));
posRef.current = { x: nx, y: ny };
sizeRef.current = { w: nw, h: nh };
setPos ({ x: nx, y: ny });
setSize({ w: nw, h: nh });
}, [wrapperRef]);
const onHandleUp = useCallback(() => { resizeStart.current = null; }, []);
// ── XY-Zoom 조절 ──────────────────────────────────────────────────────────
const adjustXyZoom = useCallback((delta: number) => {
const cur = xyZoomRef.current;
const idx = XY_STEPS.findIndex(s => s >= cur - 0.01);
const next = XY_STEPS[Math.max(0, Math.min(XY_STEPS.length - 1, idx + delta))];
xyZoomRef.current = next; setXyZoom(next);
}, []);
// ── Y-Zoom 조절 ───────────────────────────────────────────────────────────
const adjustYZoom = useCallback((delta: number) => {
const cur = yZoomRef.current;
const idx = Y_STEPS.findIndex(s => s >= cur - 0.01);
const next = Y_STEPS[Math.max(0, Math.min(Y_STEPS.length - 1, idx + delta))];
yZoomRef.current = next; setYZoom(next);
}, []);
if (!enabled) return null;
return (
<div
className="chart-magnifier"
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
onPointerDown={onHeaderDown}
onPointerMove={onHeaderMove}
onPointerUp={onHeaderUp}
>
{/* ── 헤더 ──────────────────────────────────────────────── */}
<div
className="chart-magnifier-header"
>
{/* 아이콘 + 제목 */}
<svg className="chart-magnifier-icon" width="13" height="13" viewBox="0 0 13 13"
fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="5.5" cy="5.5" r="4"/>
<line x1="8.8" y1="8.8" x2="12.5" y2="12.5"/>
<line x1="5.5" y1="3.5" x2="5.5" y2="7.5"/>
<line x1="3.5" y1="5.5" x2="7.5" y2="5.5"/>
</svg>
<span className="chart-magnifier-title"></span>
{/* LIVE 지시등 */}
<span
className={`chart-magnifier-live${isLive ? ' live-on' : ''}`}
title="캔버스 실시간 갱신 감지"
>LIVE</span>
{/* XY Zoom 컨트롤 */}
<div className="chart-magnifier-zoom" title="X·Y 동시 확대">
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
onClick={() => adjustXyZoom(-1)}></button>
<span className="chart-magnifier-zoom-val">{xyZoom}×</span>
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
onClick={() => adjustXyZoom(1)}>+</button>
</div>
{/* Y-Zoom 컨트롤 */}
<div className="chart-magnifier-yzoom" title="Y축 전용 확대 (미세 변화 감지용)">
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(-1)}></button>
<span className="chart-magnifier-zoom-val">
<span style={{ fontSize: 9, opacity: 0.7 }}>Y</span>{yZoom}×
</span>
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(1)}>+</button>
</div>
{/* 닫기 */}
<button className="chart-magnifier-close" onPointerDown={e => e.stopPropagation()}
onClick={onClose} title="닫기">×</button>
</div>
{/* ── 확대 canvas ───────────────────────────────────────── */}
<canvas ref={canvasRef} className="chart-magnifier-canvas" />
{/* ── 리사이즈 핸들 (8방향) ─────────────────────────────── */}
{HANDLES.map(h => (
<div key={h.id} className="chart-magnifier-handle"
style={{ ...h.style, cursor: h.cursor, position: 'absolute', width: 10, height: 10 }}
onPointerDown={e => onHandleDown(e, h.id)}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
/>
))}
</div>
);
};
export default ChartMagnifier;
+783
View File
@@ -0,0 +1,783 @@
/**
* ChartSlot
* 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다.
*/
import React, {
useState, useEffect, useCallback, useRef, useMemo,
useImperativeHandle, forwardRef,
} from 'react';
import ReactDOM from 'react-dom';
import TradingChart from './TradingChart';
import IndicatorContextToolbar from './IndicatorContextToolbar';
import IndicatorSettingsModal from './IndicatorSettingsModal';
import MainChartSettingsModal from './MainChartSettingsModal';
import { MarketSearchPanel } from './MarketSearchPanel';
import { generateBars, formatPrice } from '../utils/dataGenerator';
import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
import { isUpbitMarket } from '../utils/upbitApi';
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData';
import { invalidateMarketCache } from '../utils/requestCache';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import {
duplicateIndicatorInList,
mergeIndicators,
reorderIndicatorGroup,
splitIndicatorPane,
} from '../utils/indicatorPaneMerge';
import { saveSlot } from '../utils/backendApi';
import type {
OHLCVBar, ChartType, Theme, Timeframe,
IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle,
} from '../types';
import type { ChartManager } from '../utils/ChartManager';
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M'];
// ── Ws 배지 ───────────────────────────────────────────────────────────────
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null;
const map: Record<WsStatus, { dot: string; label: string }> = {
connecting: { dot: 'dot-yellow', label: '연결 중...' },
connected: { dot: 'dot-lime', label: '실시간' },
disconnected: { dot: 'dot-red', label: '재연결' },
error: { dot: 'dot-red', label: '오류' },
};
const { dot, label } = map[status];
return (
<span className={`ws-badge ${dot}`} style={{ fontSize: '10px' }}>
<span className="ws-dot" />{label}
</span>
);
};
let _slotIndCounter = 0;
function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; }
// ── Ref 핸들 (외부에서 슬롯 조작용) ─────────────────────────────────────────
export interface ChartSlotHandle {
/** 지표 추가 */
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
/** 지표 제거 (type 기준) */
removeIndicatorByType: (type: string) => void;
/** 보조지표 pane 복사 */
duplicateIndicator: (sourceId: string) => void;
/** 현재 활성 심볼 */
getSymbol: () => string;
/** 현재 활성 타임프레임 */
getTimeframe: () => Timeframe;
/** 현재 활성 지표 목록 */
getIndicators: () => IndicatorConfig[];
/** 심볼 변경 */
setSymbol: (s: string) => void;
/** 타임프레임 변경 */
setTimeframe: (tf: Timeframe) => void;
/** 지표 목록 교체 (모드 전환 시 이전 설정 복원용) */
setIndicators: (inds: IndicatorConfig[]) => void;
/** 크로스헤어 자석모드 설정 */
setCrosshairMagnet: (enabled: boolean) => void;
/** 가격축 라벨·설명 on/off */
setSeriesPriceLabelsEnabled: (enabled: boolean) => void;
/** 거래량 pane on/off */
setVolumeVisible: (visible: boolean) => void;
}
// ── Props ─────────────────────────────────────────────────────────────────
export interface ChartSlotProps {
slotIndex: number;
theme: Theme;
/** 슬롯이 활성(포커스) 상태인지 */
active: boolean;
onActivate: () => void;
/** 외부에서 강제 심볼 동기화 */
forcedSymbol?: string;
/** 외부에서 강제 타임프레임 동기화 */
forcedTf?: Timeframe;
/** 마운트 시 1회만 적용되는 초기 심볼 (단일↔다중 모드 전환 시 설정 이전용) */
initialSymbol?: string;
/** 마운트 시 1회만 적용되는 초기 타임프레임 */
initialTimeframe?: Timeframe;
/** 마운트 시 1회만 적용되는 초기 지표 목록 */
initialIndicators?: IndicatorConfig[];
/** Crosshair sync: 동기화 시각 (unix seconds) */
syncTime?: number;
onCrosshairTime?: (t: number) => void;
/** Time sync: 동기화 가시 시간 범위 */
syncVisibleRange?: { from: number; to: number } | null;
onVisibleRangeChange?: (r: { from: number; to: number }) => void;
/** Date range sync: 동기화 가시 논리 범위 */
syncLogicalRange?: { from: number; to: number } | null;
onLogicalRangeChange?: (r: { from: number; to: number }) => void;
/** 돋보기 활성 여부 (App 에서 전달) */
magnifierEnabled?: boolean;
/** 돋보기 닫기 콜백 */
onMagnifierClose?: () => void;
/** 멀티차트 전체 보기: 이 슬롯을 단일 차트로 확장 */
onFullView?: (symbol: string) => void;
/**
* 슬롯 내 지표 목록이 바뀔 때마다 호출.
* App 에서 활성 슬롯의 지표 목록을 IndicatorModal 에 반영하기 위해 사용.
*/
onIndicatorsChange?: (indicators: IndicatorConfig[]) => void;
/**
* 슬롯 내 심볼이 바뀔 때마다 호출.
* App 에서 activeSlotSymbol(호가창 심볼)을 동기화하기 위해 사용.
*/
onSymbolChange?: (symbol: string) => void;
/** 자석모드 상태 (크로스헤어 스냅) */
magnetMode?: 'off' | 'weak' | 'strong';
/** 차트 우클릭 → 매수·매도 패널 자동 입력 */
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex: number) => void;
/** 보조지표 우측 가격축 라벨·설명 표시 */
chartSeriesPriceLabels?: boolean;
/** 차트 하단 거래량 바 표시 */
chartVolumeVisible?: boolean;
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
displayTimezone?: string;
}
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
slotIndex, theme, active, onActivate,
forcedSymbol, forcedTf,
initialSymbol, initialTimeframe, initialIndicators,
syncTime, onCrosshairTime,
syncVisibleRange, onVisibleRangeChange,
syncLogicalRange, onLogicalRangeChange,
onFullView,
magnifierEnabled = false,
onMagnifierClose,
onIndicatorsChange,
onSymbolChange,
magnetMode = 'off',
onTradeOrderRequest,
chartSeriesPriceLabels = true,
chartVolumeVisible = true,
chartRealtimeSource = 'BACKEND_STOMP',
displayTimezone,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
// ── 자체 상태 ─────────────────────────────────────────────────────────
// initial* 값이 있으면 우선 적용 (단일↔다중 모드 전환 시 설정 이전용)
const [symbol, setSymbol] = useState(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
const [timeframe, setTimeframe] = useState<Timeframe>(initialTimeframe ?? forcedTf ?? '1D');
const [chartType] = useState<ChartType>('candlestick');
const [indicators, setIndicators] = useState<IndicatorConfig[]>(initialIndicators ?? []);
const [drawings] = useState<Drawing[]>([]);
const [legend, setLegend] = useState<LegendData | null>(null);
const [ctxToolbar, setCtxToolbar] = useState<{ entryId: string; screenX: number; screenY: number } | null>(null);
const [settingsModalId, setSettingsModalId] = useState<string | null>(null);
const [showMainChartModal, setShowMainChartModal] = useState(false);
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
const [showMarket, setShowMarket] = useState(false);
// 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
const managerRef = useRef<ChartManager | null>(null);
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
// symbol / timeframe 변경 시 ref 동기화 (외부 접근용)
useEffect(() => { symbolRef.current = symbol; }, [symbol]);
useEffect(() => { timeframeRef.current = timeframe; }, [timeframe]);
// 심볼 변경 시 부모(App)에 알림 → 호가창 activeSlotSymbol 동기화
const onSymbolChangeRef = useRef(onSymbolChange);
onSymbolChangeRef.current = onSymbolChange;
useEffect(() => {
onSymbolChangeRef.current?.(symbol);
}, [symbol]);
// 지표 목록 변경 시 부모(App)에 알림 → IndicatorModal 의 activeIndicators 갱신
const onIndicatorsChangeRef = useRef(onIndicatorsChange);
onIndicatorsChangeRef.current = onIndicatorsChange;
useEffect(() => {
onIndicatorsChangeRef.current?.(indicators);
}, [indicators]);
// indicators 를 ref 로도 유지 (useImperativeHandle 의 최신 값 접근용)
const indicatorsRef = useRef<IndicatorConfig[]>(indicators);
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
// ── 외부 핸들 노출 ─────────────────────────────────────────────────────
useImperativeHandle(ref, () => ({
addIndicator: (type: string, fromConfig?: IndicatorConfig) => {
setIndicators(prev => {
if (prev.some(i => i.type === type)) return prev;
const def = getIndicatorDef(type);
if (!def) return prev;
let cfg: IndicatorConfig;
if (fromConfig) {
cfg = { ...fromConfig, id: newIndId(), type: def.type };
} else {
const params = getParams(type, def.defaultParams);
const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines);
cfg = {
id: newIndId(),
type: def.type,
params,
plots,
hlines,
...(cloudColors ? { cloudColors } : {}),
};
}
if (type === 'SMA') {
cfg = normalizeSmaConfig({
...cfg,
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
});
} else if (type === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg);
}
return [...prev, cfg];
});
},
removeIndicatorByType: (type: string) => {
setIndicators(prev => prev.filter(i => i.type !== type));
},
duplicateIndicator: (sourceId: string) => {
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
},
getSymbol: () => symbolRef.current,
getTimeframe: () => timeframeRef.current,
getIndicators: () => indicatorsRef.current,
setSymbol: (s: string) => {
// 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함
if (isUpbitMarket(s)) invalidateMarketCache(s);
setSymbol(s);
setLegend(null);
},
setTimeframe: (tf: Timeframe) => { setTimeframe(tf); },
setIndicators: (inds: IndicatorConfig[]) => { setIndicators(inds); },
setCrosshairMagnet: (enabled: boolean) => {
managerRef.current?.setCrosshairMagnet(enabled);
},
setSeriesPriceLabelsEnabled: (enabled: boolean) => {
managerRef.current?.setSeriesPriceLabelsEnabled(enabled);
},
setVolumeVisible: (visible: boolean) => {
managerRef.current?.setVolumeVisible(visible);
},
}), []);
// 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영
useEffect(() => {
managerRef.current?.setCrosshairMagnet(magnetMode !== 'off');
}, [magnetMode]);
useEffect(() => {
managerRef.current?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
}, [chartSeriesPriceLabels]);
useEffect(() => {
managerRef.current?.setVolumeVisible(chartVolumeVisible);
}, [chartVolumeVisible]);
// 외부 심볼 동기화
useEffect(() => {
if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forcedSymbol]);
// 외부 타임프레임 동기화
useEffect(() => {
if (forcedTf && forcedTf !== timeframe) setTimeframe(forcedTf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forcedTf]);
// mainChartStyle → ChartManager
useEffect(() => {
managerRef.current?.updateMainSeriesStyle(mainChartStyle);
}, [mainChartStyle]);
// ── 슬롯 상태 변경 시 DB 자동 저장 (디바운스 2s) ─────────────────────────
// 멀티슬롯 모드에서 각 슬롯 독립 저장 보장 (App.tsx workspaceState는 슬롯 0만 포함)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveSlot(slotIndex, {
slotIndex,
symbol,
timeframe,
chartType: 'candlestick',
theme,
mode: 'normal',
logScale: false,
drawingsLocked: false,
drawingsVisible: true,
indicators,
drawings: [],
paneLayout: null,
mainChartStyle,
}).catch(err => console.error('[ChartSlot] saveSlot failed', err));
}, 2000);
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotIndex, symbol, timeframe, theme, indicators, mainChartStyle]);
// ── 동기화 콜백 refs (항상 최신 값을 참조하도록) ─────────────────────────
const onVisibleRangeChangeRef = useRef(onVisibleRangeChange);
const onLogicalRangeChangeRef = useRef(onLogicalRangeChange);
onVisibleRangeChangeRef.current = onVisibleRangeChange;
onLogicalRangeChangeRef.current = onLogicalRangeChange;
// 외부에서 sync 값을 적용하는 중임을 표시 (feedback loop 방지)
const isSyncingTimeRef = useRef(false);
const isSyncingLogicalRef = useRef(false);
// 데이터 로드 전 수신한 sync range 를 저장해두는 pending ref
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
useEffect(() => {
if (!syncTime) { managerRef.current?.clearCrosshairPosition(); return; }
managerRef.current?.setCrosshairAtTime(syncTime);
}, [syncTime]);
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
useEffect(() => {
if (!syncVisibleRange) return;
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) {
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
pendingSyncVisibleRef.current = syncVisibleRange;
return;
}
isSyncingTimeRef.current = true;
mgr.applyVisibleTimeRange(syncVisibleRange.from, syncVisibleRange.to);
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
}, [syncVisibleRange]);
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
useEffect(() => {
if (!syncLogicalRange) return;
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) {
pendingSyncLogicalRef.current = syncLogicalRange;
return;
}
isSyncingLogicalRef.current = true;
mgr.applyVisibleLogicalRange(syncLogicalRange.from, syncLogicalRange.to);
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
}, [syncLogicalRange]);
// 멀티 레이아웃 새로고침 시 차트 빈 화면 방지:
// CSS Grid 높이 확정 지연에 대비해 여러 시점에 pane 높이를 재적용한다.
// TradingChart 자체도 applyPaneLayout 자동 재시도를 하지만, 슬롯 레벨에서도 보강.
// ※ TradingChart를 재마운트(key 변경)하면 managerRef가 초기화되어 MarketSearchPanel 등
// 다른 상태에 영향을 줄 수 있으므로, 재마운트 대신 resetPaneHeights 재시도만 수행한다.
const slotContainerRef = useRef<HTMLDivElement | null>(null);
// 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard)
const [reloadTick, setReloadTick] = useState(0);
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
useEffect(() => {
const checks = [200, 500, 1000, 2000].map(delay =>
setTimeout(() => {
const el = slotContainerRef.current;
const mgr = managerRef.current;
if (!el || !mgr) return;
const h = el.clientHeight;
if (h <= 0) return;
if (mgr.hasMainSeries()) {
// 차트 데이터 있음 → pane 높이만 재적용
mgr.resetPaneHeights(h);
} else if (!reloadTriggeredRef.current) {
// 차트 비어 있고 아직 재시도 안 했음 → 1회만 재마운트
// (bars 로딩 지연일 수 있으므로, 늦은 타이머에서만 트리거)
if (delay >= 1000) {
reloadTriggeredRef.current = true;
setReloadTick(t => t + 1);
}
}
}, delay)
);
return () => checks.forEach(clearTimeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── 데이터 ────────────────────────────────────────────────────────────
const useUpbit = isUpbitMarket(symbol);
// 과거 데이터 추가 로드 (차트를 왼쪽으로 스크롤할 때)
const { isLoadingMore, loadMoreRef } = useHistoryLoader({
symbol, timeframe, isUpbit: useUpbit, managerRef,
});
const [simBars, setSimBars] = useState<OHLCVBar[]>(() =>
!isUpbitMarket(symbol) ? generateBars(symbol, timeframe) : []
);
useEffect(() => {
if (useUpbit) return;
setSimBars(generateBars(symbol, timeframe));
setLegend(null);
}, [symbol, timeframe, useUpbit]);
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
if (!managerRef.current) {
pendingRealtimeBarRef.current = bar;
return;
}
managerRef.current.updateBar(bar);
}, []);
const handleNewCandle = useCallback((bar: OHLCVBar) => {
if (!managerRef.current) {
pendingRealtimeBarRef.current = bar;
return;
}
managerRef.current.appendBar(bar);
}, []);
const { bars: upbitBars, latestBar, wsStatus, isLoading } = useChartRealtimeData(
symbol, timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
);
const bars = useUpbit ? upbitBars : simBars;
const lastKnownBar = useUpbit
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
: (bars.length > 0 ? bars[bars.length - 1] : null);
const currentBar = legend ?? lastKnownBar;
const isUp = currentBar ? currentBar.close >= currentBar.open : true;
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0;
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
setSettingsModalId(null);
setCtxToolbar(null);
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
saveParams(updated.type, updated.params);
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
}, [saveParams, saveVisual]);
const handleToggleHidden = useCallback((id: string) => {
setIndicators(prev => prev.map(i => i.id === id ? { ...i, hidden: !i.hidden } : i));
}, []);
const handleRemoveById = useCallback((id: string) => {
setIndicators(prev => prev.filter(i => i.id !== id));
setCtxToolbar(null);
setSettingsModalId(null);
}, []);
const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => {
setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev));
}, []);
const handleMergeIndicators = useCallback((fromId: string, intoId: string) => {
setIndicators(prev => mergeIndicators(fromId, intoId, prev));
}, []);
const handleSplitIndicatorPane = useCallback((hostId: string) => {
setIndicators(prev => splitIndicatorPane(hostId, prev));
}, []);
const [focusedIndicatorId, setFocusedIndicatorId] = useState<string | null>(null);
const handleExpandIndicator = useCallback((id: string) => {
setFocusedIndicatorId(id);
}, []);
const handleRestoreIndicators = useCallback(() => {
setFocusedIndicatorId(null);
}, []);
const handleDuplicateIndicator = useCallback((sourceId: string) => {
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
}, []);
const handleRemoveByIdSlot = useCallback((id: string) => {
setIndicators(prev => prev.filter(i => i.id !== id));
setFocusedIndicatorId(prev => prev === id ? null : prev);
setCtxToolbar(null);
setSettingsModalId(null);
}, []);
/** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) + 타임프레임 필터 */
const effectiveIndicators = useMemo(() => {
// 타임프레임 필터: timeframeVisibility[currentTf] === false 이면 제외
const tfFiltered = indicators.filter(ind =>
ind.timeframeVisibility?.[timeframe] !== false
);
if (!focusedIndicatorId) return tfFiltered;
return tfFiltered.filter(ind => {
const def = getIndicatorDef(ind.type);
return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId;
});
}, [focusedIndicatorId, indicators, timeframe]);
// ── 심볼 선택 ─────────────────────────────────────────────────────────
const handleSymbolSelect = useCallback((s: string) => {
if (isUpbitMarket(s)) invalidateMarketCache(s);
setSymbol(s);
setLegend(null);
setShowMarket(false);
}, []);
return (
<div
className={`chart-slot${active ? ' active' : ''}`}
onClick={onActivate}
>
{/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */}
<div className="slot-header" onClick={e => e.stopPropagation()}>
{/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */}
<button
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경"
onClick={() => {
if (!showMarket) {
// 클릭 시점에 슬롯 전체(.chart-slot) 컨테이너의 rect를 캡처
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
{getKoreanName(symbol) !== symbol
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
: <span className="slot-sym-kr">{symbol}</span>
}
</button>
{/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */}
{showMarket && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { handleSymbolSelect(s); }}
onClose={() => setShowMarket(false)}
anchorRect={slotAnchorRect}
/>,
document.body,
)}
{/* 타임프레임 선택 */}
<div className="slot-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
{/* 현재가 */}
{currentBar && (
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{formatPrice(currentBar.close)}
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
</span>
</span>
)}
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
{isLoading && <span className="slot-loading">...</span>}
</div>
{/* ── TradingChart ─────────────────────────────────────────────────── */}
<div
ref={slotContainerRef}
className="slot-chart-wrap"
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
>
{useUpbit && isLoading && (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" />
</div>
)}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> ...</span>
</div>
)}
<TradingChart
key={reloadTick}
bars={bars}
market={symbol}
timeframe={timeframe}
displayTimezone={displayTimezone}
chartType={chartType}
theme={theme}
mode="chart"
indicators={effectiveIndicators}
drawingTool="cursor"
drawings={drawings}
logScale={false}
drawingsLocked={false}
drawingsVisible={true}
onCrosshair={data => {
setLegend(data);
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
}}
onManagerReady={mgr => {
managerRef.current = mgr;
const pending = pendingRealtimeBarRef.current;
if (pending) {
pendingRealtimeBarRef.current = null;
const last = bars.length > 0 ? bars[bars.length - 1] : null;
if (last && pending.time === last.time) mgr.updateBar(pending);
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
else mgr.updateBar({ ...pending, time: last.time });
}
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
mgr.setVolumeVisible(chartVolumeVisible);
// Time sync: 가시 시간 범위 변화 구독
mgr.subscribeVisibleTimeRange(r => {
if (!r || isSyncingTimeRef.current) return;
onVisibleRangeChangeRef.current?.(r);
});
// Date range sync: 가시 논리 범위 변화 구독
// + 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
mgr.subscribeVisibleLogicalRange(r => {
if (!r) return;
if (!isSyncingLogicalRef.current) {
onLogicalRangeChangeRef.current?.(r);
}
// 가시 범위 왼쪽 끝이 LOAD_MORE_TRIGGER 이내이면 추가 로드
if (r.from < LOAD_MORE_TRIGGER) {
loadMoreRef.current();
}
});
}}
onDataLoaded={() => {
// reloadAll(setData + setInitialVisibleRange) 완료 후
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
const mgr = managerRef.current;
if (!mgr) return;
if (pendingSyncVisibleRef.current) {
isSyncingTimeRef.current = true;
mgr.applyVisibleTimeRange(
pendingSyncVisibleRef.current.from,
pendingSyncVisibleRef.current.to,
);
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
pendingSyncVisibleRef.current = null;
}
if (pendingSyncLogicalRef.current) {
isSyncingLogicalRef.current = true;
mgr.applyVisibleLogicalRange(
pendingSyncLogicalRef.current.from,
pendingSyncLogicalRef.current.to,
);
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
pendingSyncLogicalRef.current = null;
}
}}
magnifierEnabled={magnifierEnabled}
onMagnifierClose={onMagnifierClose}
onAddDrawing={() => {}}
onSeriesClick={(entryId, point) => {
if (!entryId) { setCtxToolbar(null); return; }
setCtxToolbar({ entryId, screenX: point.x, screenY: point.y });
}}
onSeriesDoubleClick={entryId => {
setCtxToolbar(null);
if (!entryId) return;
if (entryId === '__main__') setShowMainChartModal(true);
else setSettingsModalId(entryId);
}}
onHoverPaneLegend={() => {}}
onLeavePaneLegend={() => {}}
onTradeOrderRequest={req => onTradeOrderRequest?.(req, slotIndex)}
onReorderIndicators={handleReorderIndicators}
onMergeIndicators={handleMergeIndicators}
onSplitIndicatorPane={handleSplitIndicatorPane}
onExpandIndicator={handleExpandIndicator}
onRemoveIndicator={handleRemoveByIdSlot}
onDuplicateIndicator={handleDuplicateIndicator}
focusedIndicatorId={focusedIndicatorId}
onRestoreIndicators={handleRestoreIndicators}
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
/>
{/* 컨텍스트 툴바 */}
{ctxToolbar && (() => {
const cfg = ctxToolbar.entryId !== '__main__'
? indicators.find(i => i.id === ctxToolbar.entryId) ?? null
: null;
return (
<IndicatorContextToolbar
indicatorId={ctxToolbar.entryId}
config={cfg}
chartType={chartType}
screenX={ctxToolbar.screenX}
screenY={ctxToolbar.screenY}
onMouseEnterToolbar={() => {}}
onMouseLeaveToolbar={() => {}}
onSettings={() => {
const id = ctxToolbar.entryId;
setCtxToolbar(null);
if (id === '__main__') setShowMainChartModal(true);
else setSettingsModalId(id);
}}
onToggleHidden={() => handleToggleHidden(ctxToolbar.entryId)}
onRemove={() => handleRemoveById(ctxToolbar.entryId)}
onClose={() => setCtxToolbar(null)}
/>
);
})()}
{/* 메인 차트 설정 모달 */}
{showMainChartModal && (
<MainChartSettingsModal
style={mainChartStyle}
onSave={style => { setMainChartStyle(style); setShowMainChartModal(false); }}
onCancel={() => setShowMainChartModal(false)}
/>
)}
{/* 지표 설정 모달 */}
{settingsModalId && settingsModalId !== '__main__' && (() => {
const cfg = indicators.find(i => i.id === settingsModalId);
if (!cfg) return null;
const def = getIndicatorDef(cfg.type);
const mergedCfg = enrichIndicatorConfig({
...cfg,
params: getParams(cfg.type, def?.defaultParams),
hlines: cfg.hlines ?? def?.hlines,
});
return (
<IndicatorSettingsModal
config={mergedCfg}
chartMarket={symbol}
onSave={handleIndicatorSave}
onCancel={() => setSettingsModalId(null)}
/>
);
})()}
</div>
</div>
);
});
export default ChartSlot;
+123
View File
@@ -0,0 +1,123 @@
/**
* 보조지표 설정 — 색상 + 투명도 (스와치 클릭 시 팔레트·고급 선택기)
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { parsePlotColor, formatPlotColor, plotColorCss } from '../utils/plotColorUtils';
import { getOpacityPercentSpec } from '../utils/indicatorParamSpec';
import NumericParamInput from './NumericParamInput';
import ColorPickerPanel from './ColorPickerPanel';
export interface ColorInputProps {
value: string;
onChange: (v: string) => void;
}
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
const { hex6, alpha } = parsePlotColor(value);
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const popW = 280;
let left = rect.left;
if (left + popW > window.innerWidth - 12) {
left = window.innerWidth - popW - 12;
}
setPos({ top: rect.bottom + 6, left: Math.max(8, left) });
}, []);
useEffect(() => {
if (!open) return;
updatePosition();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
const onScroll = () => updatePosition();
window.addEventListener('keydown', onKey);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', onScroll, true);
};
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
const popover = open && typeof document !== 'undefined' ? (
<div
ref={popoverRef}
className="ism-color-popover"
style={{ top: pos.top, left: pos.left }}
role="dialog"
aria-label="색상 선택"
onMouseDown={e => e.stopPropagation()}
>
<div className="ism-color-popover-native">
<label className="ism-color-native-label">
<span className="ism-style-label"> </span>
<input
type="color"
className="ism-color-picker"
value={hex6}
onChange={e => patchHex(e.target.value)}
/>
</label>
</div>
<ColorPickerPanel hex6={hex6} onHexChange={patchHex} />
</div>
) : null;
return (
<div className="ism-color-wrap" ref={anchorRef}>
<button
type="button"
className="ism-color-swatch-btn"
title="색상 선택"
aria-expanded={open}
onClick={() => {
setOpen(v => !v);
if (!open) requestAnimationFrame(updatePosition);
}}
>
<span className="ism-color-swatch-preview" style={{ background: plotColorCss(value) }} />
</button>
<input
type="color"
className="ism-color-picker ism-color-picker--compact"
value={hex6}
title="브라우저 색상 선택"
onChange={e => patchHex(e.target.value)}
/>
<NumericParamInput
className="ism-input ism-alpha-input"
value={alpha}
spec={getOpacityPercentSpec(alpha)}
onChange={patchAlpha}
/>
<span className="ism-alpha-label">%</span>
{popover ? createPortal(popover, document.body) : null}
</div>
);
};
export default ColorInput;
@@ -0,0 +1,44 @@
/**
* 색상 선택 패널 — 기존 스와치 팔레트 + 하단 고급 색상 팔레트
*/
import React from 'react';
import { COLOR_PALETTE } from '../utils/plotColorUtils';
import AdvancedColorPicker from './AdvancedColorPicker';
export interface ColorPickerPanelProps {
hex6: string;
onHexChange: (hex6: string) => void;
/** false면 스와치 그리드만 숨김 (고급 팔레트만) */
showQuickPalette?: boolean;
className?: string;
}
const ColorPickerPanel: React.FC<ColorPickerPanelProps> = ({
hex6,
onHexChange,
showQuickPalette = true,
className = '',
}) => (
<div className={`cpp-panel ${className}`.trim()}>
{showQuickPalette && (
<div className="cpp-quick-palette">
<div className="plsp-palette cpp-palette-grid">
{COLOR_PALETTE.map(c => (
<button
key={c}
type="button"
className={`plsp-color-cell${hex6.toUpperCase() === c.toUpperCase() ? ' selected' : ''}`}
style={{ background: c }}
title={c}
onClick={() => onHexChange(c)}
/>
))}
</div>
</div>
)}
<div className="cpp-advanced-divider" aria-hidden />
<AdvancedColorPicker hex6={hex6} onChange={onHexChange} />
</div>
);
export default ColorPickerPanel;
+293
View File
@@ -0,0 +1,293 @@
/**
* 운영 현황 대시보드 — 멀티스레드 파이프라인·JVM·트래픽·모의/실거래
*/
import React, { useEffect, useState, useCallback } from 'react';
import type { Theme } from '../types';
import {
loadDashboardSummary,
type DashboardSummaryDto,
type ProcessMonitorDto,
type SystemMonitorDto,
} from '../utils/backendApi';
interface Props {
theme: Theme;
onGoChart?: (market: string) => void;
}
const fmtKrw = (n: number) =>
n >= 1e8 ? `${(n / 1e8).toFixed(2)}` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}` : n.toLocaleString('ko-KR');
const STATUS_LABEL: Record<string, string> = {
healthy: '정상',
degraded: '주의',
down: '중단',
disabled: '비활성',
};
function statusClass(s: string): string {
if (s === 'healthy') return 'dash-proc--ok';
if (s === 'degraded') return 'dash-proc--warn';
if (s === 'down') return 'dash-proc--down';
return 'dash-proc--off';
}
function formatMetricValue(v: unknown): string {
if (typeof v === 'boolean') return v ? 'Y' : 'N';
if (typeof v === 'number') {
if (Number.isInteger(v)) return v.toLocaleString('ko-KR');
return v.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
}
return String(v ?? '—');
}
function formatAgo(ms: number): string {
if (!ms) return '—';
const sec = Math.floor((Date.now() - ms) / 1000);
if (sec < 60) return `${sec}초 전`;
if (sec < 3600) return `${Math.floor(sec / 60)}분 전`;
return `${Math.floor(sec / 3600)}시간 전`;
}
const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => (
<article className={`dash-proc ${statusClass(proc.status)}`}>
<header className="dash-proc-head">
<span className="dash-proc-layer">{proc.layer}</span>
<span className={`dash-proc-badge ${statusClass(proc.status)}`}>
{STATUS_LABEL[proc.status] ?? proc.status}
</span>
</header>
<h3 className="dash-proc-name">{proc.name}</h3>
<ul className="dash-proc-metrics">
{Object.entries(proc.metrics).slice(0, 6).map(([k, v]) => (
<li key={k}>
<span>{k}</span>
<strong>{formatMetricValue(v)}</strong>
</li>
))}
</ul>
</article>
);
const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
const [data, setData] = useState<DashboardSummaryDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
loadDashboardSummary()
.then(d => { setData(d); setError(null); })
.catch(e => setError((e as Error).message))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
refresh();
const id = window.setInterval(refresh, 5_000);
return () => window.clearInterval(id);
}, [refresh]);
const app = data?.app as Record<string, unknown> | undefined;
const mon: SystemMonitorDto | undefined = data?.systemMonitor;
return (
<div className={`dashboard-page theme-${theme}`}>
<header className="dashboard-header">
<h1> </h1>
<div className="dashboard-header-actions">
{mon && (
<span className="dashboard-live">
{mon.pipelineEnabled ? 'ON' : 'OFF'}
{mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)} 갱신` : ''}
</span>
)}
<button type="button" className="stg-btn" onClick={refresh} disabled={loading}>
{loading ? '갱신 중…' : '새로고침'}
</button>
</div>
</header>
{error && <p className="dashboard-error">{error}</p>}
{data && mon && (
<div className="dashboard-grid">
{/* JVM · 트래픽 */}
<section className="dashboard-card dashboard-card--wide dash-monitor-top">
<h2>JVM · </h2>
<div className="dash-jvm-row">
<div className="dash-jvm-heap">
<div className="dash-jvm-label">
Heap {mon.jvm.heapUsedMb} / {mon.jvm.heapMaxMb} MB ({mon.jvm.heapPct}%)
</div>
<div className="dash-jvm-bar">
<div
className={`dash-jvm-fill${mon.jvm.heapPct > 85 ? ' dash-jvm-fill--warn' : ''}`}
style={{ width: `${Math.min(100, mon.jvm.heapPct)}%` }}
/>
</div>
</div>
<ul className="dashboard-kv dash-traffic-kv">
<li><span>WS /s</span><strong>{mon.traffic.wsMessagesPerSec ?? 0}</strong></li>
<li><span> /s</span><strong>{mon.traffic.ticksEnqueuedPerSec ?? 0}</strong></li>
<li><span> /s</span><strong>{mon.traffic.ticksProcessedPerSec ?? 0}</strong></li>
<li><span> /s</span><strong>{mon.traffic.ordersExecutedPerSec ?? 0}</strong></li>
<li><span></span><strong>{mon.jvm.threadCount}</strong></li>
<li><span>Non-Heap</span><strong>{mon.jvm.nonHeapUsedMb} MB</strong></li>
</ul>
</div>
</section>
{/* Ta4j 메모리 */}
<section className="dashboard-card">
<h2> (Ta4j)</h2>
<ul className="dashboard-kv">
<li><span></span><strong>{mon.memory.markets}</strong></li>
<li><span></span><strong>{mon.memory.series}</strong></li>
<li><span> </span><strong>{mon.memory.totalBars.toLocaleString('ko-KR')}</strong></li>
<li><span> </span><strong>{mon.memory.maxBarsPerSeries}</strong></li>
</ul>
</section>
{/* 이벤트 큐 */}
<section className="dashboard-card">
<h2> </h2>
<ul className="dashboard-kv">
<li><span></span><strong>{mon.queue.pending}</strong></li>
<li><span></span><strong>{mon.queue.capacity?.toLocaleString('ko-KR') ?? '—'}</strong></li>
<li><span></span><strong>{Math.round((mon.queue.fillRatio ?? 0) * 1000) / 10}%</strong></li>
<li><span></span><strong className={mon.queue.dropped > 0 ? 'warn' : ''}>{mon.queue.dropped}</strong></li>
</ul>
</section>
{/* 멀티스레드 프로세스 */}
<section className="dashboard-card dashboard-card--wide">
<h2> </h2>
<div className="dash-proc-grid">
{mon.processes.map(p => (
<ProcessCard key={p.id} proc={p} />
))}
</div>
</section>
{/* 스레드 목록 */}
<section className="dashboard-card dashboard-card--wide">
<h2> </h2>
{mon.threads.length === 0 ? (
<p className="dashboard-muted"> </p>
) : (
<table className="dashboard-table dashboard-table--compact">
<thead>
<tr><th></th><th>ID</th><th></th><th></th></tr>
</thead>
<tbody>
{mon.threads.map(t => (
<tr key={`${t.id}-${t.name}`}>
<td><code>{t.name}</code></td>
<td>{t.id}</td>
<td>{t.state}</td>
<td>{t.daemon ? 'Y' : 'N'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{/* 틱 정체 종목 */}
{mon.staleMarkets.length > 0 && (
<section className="dashboard-card dashboard-card--wide dash-stale">
<h2> ({mon.staleMarkets.length})</h2>
<table className="dashboard-table dashboard-table--compact">
<thead>
<tr><th></th><th> </th></tr>
</thead>
<tbody>
{mon.staleMarkets.slice(0, 12).map(s => (
<tr key={s.market}>
<td>
<button type="button" className="dashboard-link" onClick={() => onGoChart?.(s.market)}>
{s.market.replace('KRW-', '')}
</button>
</td>
<td>{s.lastTickAgeSec}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
<section className="dashboard-card">
<h2> </h2>
<ul className="dashboard-kv">
<li><span> </span><strong>{String(app?.tradingMode ?? '—')}</strong></li>
<li><span> </span><strong>{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
<li><span> </span><strong>{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
<li><span> </span><strong>{app?.liveStrategyCheck ? 'ON' : 'OFF'}</strong></li>
</ul>
</section>
<section className="dashboard-card">
<h2></h2>
<ul className="dashboard-kv">
<li><span></span><strong>{data.watchlistCount}</strong></li>
<li><span> </span><strong>{data.monitoredMarkets}</strong></li>
<li><span>FCM</span><strong>
{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '설정됨·서버 미연결') : 'OFF'}
</strong></li>
</ul>
</section>
<section className="dashboard-card">
<h2></h2>
<ul className="dashboard-kv">
<li><span></span><strong>{fmtKrw(data.paper.cashBalance)} KRW</strong></li>
<li><span></span><strong>{fmtKrw(data.paper.totalAsset)} KRW</strong></li>
<li><span> </span><strong>{data.paper.positions?.length ?? 0}</strong></li>
</ul>
</section>
<section className="dashboard-card">
<h2></h2>
<ul className="dashboard-kv">
<li><span>KRW</span><strong>{fmtKrw(data.live.krwBalance)}</strong></li>
<li><span></span><strong>{data.live.positions?.length ?? 0} </strong></li>
<li><span></span><strong>{data.live.configured ? 'OK' : '미설정'}</strong></li>
</ul>
</section>
<section className="dashboard-card dashboard-card--wide">
<h2> </h2>
{data.recentSignals.length === 0 ? (
<p className="dashboard-muted"> </p>
) : (
<table className="dashboard-table">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{data.recentSignals.map(s => (
<tr key={s.id}>
<td>
<button type="button" className="dashboard-link" onClick={() => onGoChart?.(s.market)}>
{s.market.replace('KRW-', '')}
</button>
</td>
<td className={s.signalType === 'BUY' ? 'buy' : 'sell'}>{s.signalType}</td>
<td>{s.price?.toLocaleString('ko-KR')}</td>
<td>{s.candleType}</td>
<td>{s.createdAt?.slice(0, 19) ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
)}
</div>
);
};
export default DashboardPage;
@@ -0,0 +1,78 @@
/**
* 드래그 가능한 모달 프레임 — 그라데이션 타이틀바(gc-popup-header) + 화면 중앙 초기 배치
*/
import React from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
export interface DraggableModalFrameProps {
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
overlayClassName?: string;
dialogClassName?: string;
bodyClassName?: string;
zIndex?: number;
width?: number | string;
closeOnBackdrop?: boolean;
}
export const DraggableModalFrame: React.FC<DraggableModalFrameProps> = ({
onClose,
title,
children,
overlayClassName = 'sp-modal-overlay',
dialogClassName = 'sp-modal',
bodyClassName = 'sp-modal-body',
zIndex = 1000,
width,
closeOnBackdrop = true,
}) => {
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div
className={overlayClassName}
style={{ zIndex }}
onMouseDown={e => {
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className={dialogClassName}
style={{
...panelStyle,
...(width != null ? { width } : {}),
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header sp-modal-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title sp-modal-title">{title}</span>
<button
type="button"
className="gc-popup-close"
onMouseDown={e => e.stopPropagation()}
onClick={onClose}
aria-label="닫기"
>
</button>
</div>
<div className={bodyClassName}>{children}</div>
</div>
</div>
);
};
export default DraggableModalFrame;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
import React, { useEffect, useRef } from 'react';
import type { Drawing } from '../types';
// 도구별 한국어 이름
const DRAWING_LABELS: Record<string, string> = {
hline: '수평선',
vline: '수직선',
trendline: '추세선',
ray: '레이',
fib: '피보나치',
fibtz: '피보나치 타임존',
measure: '가격 측정',
rect: '사각형',
channel: '채널',
pencil: '자유 드로잉',
text: '텍스트',
};
/* ── SVG Icons (IndicatorContextToolbar 와 동일 스타일) ── */
const IcEye = ({ visible }: { visible: boolean }) => visible ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z"/>
<circle cx="7" cy="7" r="2"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z" strokeOpacity="0.35"/>
<line x1="2" y1="2" x2="12" y2="12"/>
</svg>
);
const IcGear = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
<circle cx="7" cy="7" r="2.3"/>
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
</svg>
);
const IcTrash = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
<line x1="2" y1="4.5" x2="12" y2="4.5"/>
<path d="M5 4.5 V3 Q5 2 7 2 Q9 2 9 3 V4.5"/>
<path d="M3 4.5 L3.5 12 Q3.5 12.5 4 12.5 L10 12.5 Q10.5 12.5 10.5 12 L11 4.5"/>
</svg>
);
interface DrawingContextToolbarProps {
drawing: Drawing;
screenX: number;
screenY: number;
onSettings: () => void;
onToggleVisible: () => void;
onRemove: () => void;
onClose: () => void;
}
const DrawingContextToolbar: React.FC<DrawingContextToolbarProps> = ({
drawing, screenX, screenY,
onSettings, onToggleVisible, onRemove, onClose,
}) => {
const ref = useRef<HTMLDivElement>(null);
// 외부 클릭 시 닫기
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
const tid = setTimeout(() => document.addEventListener('mousedown', handler), 100);
return () => { clearTimeout(tid); document.removeEventListener('mousedown', handler); };
}, [onClose]);
const W = 220;
let left = screenX + 8;
let top = screenY + 8;
if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
if (top + 50 > window.innerHeight - 8) top = screenY - 58;
const label = DRAWING_LABELS[drawing.type] ?? drawing.type;
const isHidden = drawing.visible === false;
return (
<div
ref={ref}
className="ind-ctx-toolbar"
style={{ left, top }}
onMouseDown={e => e.stopPropagation()}
>
{/* 색상 닷 + 이름 */}
<span className="ind-ctx-name" title={label}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: drawing.color,
marginRight: 5,
verticalAlign: 'middle',
flexShrink: 0,
}}
/>
{label}
</span>
{/* 가시성 토글 */}
<button
className="ind-ctx-btn"
title={isHidden ? '표시' : '숨기기'}
onClick={onToggleVisible}
>
<IcEye visible={!isHidden} />
</button>
{/* 설정 */}
<button
className="ind-ctx-btn"
title="설정 (더블클릭)"
onClick={onSettings}
>
<IcGear />
</button>
{/* 삭제 */}
<button
className="ind-ctx-btn ind-ctx-del"
title="삭제"
onClick={onRemove}
>
<IcTrash />
</button>
</div>
);
};
export default DrawingContextToolbar;
@@ -0,0 +1,228 @@
import React, { useState } from 'react';
import type { Drawing, DrawingStyle } from '../types';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
const PRESET_COLORS = [
'#FF6D00','#FF5722','#EF5350','#E91E63','#9C27B0',
'#673AB7','#3F51B5','#2196F3','#03A9F4','#00BCD4',
'#009688','#4CAF50','#8BC34A','#CDDC39','#FFC107',
'#FF9800','#795548','#9E9E9E','#607D8B','#FFFFFF',
];
const LINE_WIDTHS = [1, 2, 3, 4] as const;
const LINE_STYLES: { value: DrawingStyle; label: string }[] = [
{ value: 'solid', label: '실선' },
{ value: 'dashed', label: '파선' },
{ value: 'dotted', label: '점선' },
];
interface Props {
drawing: Drawing;
onSave: (updated: Drawing) => void;
onCancel: () => void;
}
const DrawingSettingsModal: React.FC<Props> = ({ drawing, onSave, onCancel }) => {
const [color, setColor] = useState(drawing.color);
const [lineWidth, setLineWidth] = useState(drawing.lineWidth);
const [style, setStyle] = useState<DrawingStyle>(drawing.style);
const [text, setText] = useState(drawing.text ?? '');
const handleSave = () => {
onSave({
...drawing,
color,
lineWidth,
style,
...(drawing.type === 'text' ? { text: text.trim() || drawing.text } : {}),
});
};
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div
className="dsm-overlay"
style={{ zIndex: 1200 }}
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
>
<div
ref={panelRef}
className="dsm-dialog"
style={{
...panelStyle,
minWidth: 300,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title"> </span>
<button type="button" className="gc-popup-close" onClick={onCancel} aria-label="닫기"></button>
</div>
<div className="dsm-body">
{/* 색상 */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{PRESET_COLORS.map(c => (
<button
key={c}
onClick={() => setColor(c)}
style={{
width: 20,
height: 20,
borderRadius: '50%',
background: c,
border: color === c ? '2px solid #fff' : '2px solid transparent',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
/>
))}
{/* 커스텀 컬러 피커 */}
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
title="커스텀 색상"
style={{
width: 20,
height: 20,
borderRadius: '50%',
border: 'none',
padding: 0,
cursor: 'pointer',
background: 'none',
}}
/>
</div>
</div>
{/* 선 굵기 (pencil·text·measure 제외) */}
{drawing.type !== 'text' && drawing.type !== 'measure' && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}> </div>
<div style={{ display: 'flex', gap: 6 }}>
{LINE_WIDTHS.map(w => (
<button
key={w}
onClick={() => setLineWidth(w)}
style={{
width: 34,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: lineWidth === w ? 'var(--accent-bg, rgba(122,162,247,0.15))' : 'transparent',
border: lineWidth === w ? '1px solid var(--accent, #7aa2f7)' : '1px solid var(--border, rgba(122,162,247,0.2))',
borderRadius: 4,
cursor: 'pointer',
color: 'inherit',
}}
>
<div style={{ width: 20, height: w, background: 'currentColor', borderRadius: 1 }} />
</button>
))}
</div>
</div>
)}
{/* 선 스타일 (hline, vline, trendline, ray, channel 계열) */}
{!['text', 'measure', 'pencil', 'fib', 'rect'].includes(drawing.type) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}> </div>
<div style={{ display: 'flex', gap: 6 }}>
{LINE_STYLES.map(({ value, label }) => (
<button
key={value}
onClick={() => setStyle(value)}
style={{
flex: 1,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: style === value ? 'var(--accent-bg, rgba(122,162,247,0.15))' : 'transparent',
border: style === value ? '1px solid var(--accent, #7aa2f7)' : '1px solid var(--border, rgba(122,162,247,0.2))',
borderRadius: 4,
cursor: 'pointer',
color: 'inherit',
fontSize: 12,
}}
>
{label}
</button>
))}
</div>
</div>
)}
{/* 텍스트 (text 타입만) */}
{drawing.type === 'text' && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}></div>
<input
value={text}
onChange={e => setText(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
background: 'rgba(255,255,255,0.05)',
border: '1px solid var(--border, rgba(122,162,247,0.25))',
borderRadius: 4,
padding: '6px 8px',
color: 'inherit',
fontSize: 13,
}}
/>
</div>
)}
{/* 버튼 */}
<div className="dsm-footer">
<button
onClick={onCancel}
style={{
padding: '6px 16px',
background: 'transparent',
border: '1px solid var(--border, rgba(122,162,247,0.25))',
borderRadius: 5,
cursor: 'pointer',
color: 'inherit',
fontSize: 13,
}}
></button>
<button
onClick={handleSave}
style={{
padding: '6px 16px',
background: 'var(--accent, #7aa2f7)',
border: 'none',
borderRadius: 5,
cursor: 'pointer',
color: '#1a1b26',
fontWeight: 600,
fontSize: 13,
}}
></button>
</div>
</div>
</div>
</div>
);
};
export default DrawingSettingsModal;
+627
View File
@@ -0,0 +1,627 @@
import React, { useState, useRef, useEffect } from 'react';
import type { ChartMode } from '../types';
export interface DrawingToolbarProps {
activeTool: string;
mode: ChartMode;
onTool: (tool: string) => void;
onClearAll: () => void;
onUndo: () => void;
locked: boolean;
visible: boolean;
onToggleLock: () => void;
onToggleEye: () => void;
// 신규 확장 props (optional — App.tsx 점진 적용)
indicatorsVisible?: boolean;
magnetMode?: 'off' | 'weak' | 'strong';
snapToIndicator?: boolean;
drawingCount?: number;
indicatorCount?: number;
onMagnetMode?: (m: 'off' | 'weak' | 'strong') => void;
onSnapToIndicator?: (v: boolean) => void;
onHideDrawings?: () => void;
onHideIndicators?: () => void;
onHideAll?: () => void;
onClearIndicators?: () => void;
keepLockedOnDelete?: boolean;
onToggleKeepLocked?: () => void;
}
// ─── SVG 헬퍼 ──────────────────────────────────────────────────────────────
const S = (p: React.SVGProps<SVGSVGElement>) =>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"
stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" {...p}/>;
// ─── 아이콘 ────────────────────────────────────────────────────────────────
const IcCursor = () => <S><path d="M3 2L3 14L6.5 10.5L9.5 16L11 15.3L8 9.5L12.5 9.5Z" strokeWidth="1.4" fill="currentColor" fillOpacity="0.15"/></S>;
// 업비트 즐겨찾기 툴바 아이콘
const IcUpbitHline = () => <S strokeWidth="1.5"><line x1="2" y1="9" x2="16" y2="9"/><circle cx="9" cy="9" r="1.6" fill="currentColor" stroke="none"/></S>;
const IcUpbitVline = () => <S strokeWidth="1.5"><line x1="9" y1="2" x2="9" y2="16"/><circle cx="9" cy="9" r="1.6" fill="currentColor" stroke="none"/></S>;
const IcUpbitPeriod = () => <S strokeWidth="1.4"><line x1="5" y1="3" x2="5" y2="15"/><line x1="13" y1="3" x2="13" y2="15"/><circle cx="5" cy="9" r="1.4" fill="currentColor" stroke="none"/><circle cx="13" cy="9" r="1.4" fill="currentColor" stroke="none"/><line x1="6.5" y1="9" x2="11.5" y2="9"/><polyline points="6.5,10 5,9 6.5,8" strokeWidth="1.2"/><polyline points="11.5,10 13,9 11.5,8" strokeWidth="1.2"/></S>;
const IcUpbitFibTz = () => <S strokeWidth="1.4"><line x1="4" y1="4" x2="4" y2="16"/><line x1="8" y1="6" x2="8" y2="16" strokeOpacity="0.75"/><line x1="12" y1="3" x2="12" y2="16" strokeOpacity="0.9"/><line x1="15" y1="8" x2="15" y2="16" strokeOpacity="0.55"/><circle cx="4" cy="10" r="1.3" fill="currentColor" stroke="none"/><circle cx="8" cy="11" r="1.3" fill="currentColor" stroke="none"/><circle cx="12" cy="9" r="1.3" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/></S>;
const IcFavorite = () => <S strokeWidth="1.3"><path d="M9 2.5 L11 7 L16 7.5 L12.5 11 L13.5 16 L9 13.5 L4.5 16 L5.5 11 L2 7.5 L7 7 Z" fill="currentColor" fillOpacity="0.25"/></S>;
// 선 그룹
const IcTrendline = () => <S strokeWidth="1.5"><line x1="3" y1="15" x2="15" y2="3"/><circle cx="3" cy="15" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="3" r="1.5" fill="currentColor" stroke="none"/></S>;
const IcRay = () => <S strokeWidth="1.4"><line x1="3" y1="14" x2="16" y2="5"/><circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none"/><polyline points="14,5.5 16,5 15.5,7" strokeWidth="1.3"/></S>;
const IcInfoLine = () => <S strokeWidth="1.4"><line x1="1" y1="9" x2="17" y2="9"/><rect x="5" y="4" width="8" height="4" rx="1" strokeWidth="1.2"/><text x="9" y="7.5" fontSize="4.5" fill="currentColor" stroke="none" textAnchor="middle">i</text></S>;
const IcExtLine = () => <S strokeWidth="1.5"><line x1="1" y1="15" x2="17" y2="3"/><polyline points="15,3 17,3 17,5" strokeWidth="1.3"/><polyline points="3,15 1,15 1,13" strokeWidth="1.3"/></S>;
const IcTrendAngle = () => <S strokeWidth="1.4"><line x1="3" y1="15" x2="15" y2="7"/><path d="M3 15 Q8 15 10 11" strokeDasharray="2 1.5" strokeOpacity="0.7"/><text x="9" y="15" fontSize="6" fill="currentColor" stroke="none">θ</text></S>;
const IcHline = () => <S strokeWidth="1.4"><line x1="1" y1="9" x2="17" y2="9"/><line x1="1" y1="5" x2="17" y2="5" strokeOpacity="0.4" strokeDasharray="2 2"/><line x1="1" y1="13" x2="17" y2="13" strokeOpacity="0.4" strokeDasharray="2 2"/></S>;
const IcHlineRay = () => <S strokeWidth="1.4"><line x1="3" y1="9" x2="17" y2="9"/><circle cx="3" cy="9" r="1.5" fill="currentColor" stroke="none"/><polyline points="15,7 17,9 15,11" strokeWidth="1.3"/></S>;
const IcVline = () => <S strokeWidth="1.4"><line x1="9" y1="1" x2="9" y2="17"/><polyline points="7,4 9,1 11,4"/><polyline points="7,14 9,17 11,14"/></S>;
const IcCrossLine = () => <S strokeWidth="1.4"><line x1="2" y1="9" x2="16" y2="9"/><line x1="9" y1="2" x2="9" y2="16"/><circle cx="9" cy="9" r="1.8" fill="currentColor" stroke="none" fillOpacity="0.7"/></S>;
const IcChannel = () => <S strokeWidth="1.4"><line x1="2" y1="13" x2="16" y2="5"/><line x1="2" y1="16" x2="16" y2="8" strokeOpacity="0.5" strokeDasharray="3 2"/></S>;
const IcParaCh = () => <S strokeWidth="1.4"><line x1="2" y1="12" x2="16" y2="5"/><line x1="2" y1="15" x2="16" y2="8"/></S>;
const IcDisjCh = () => <S strokeWidth="1.3"><line x1="2" y1="13" x2="9" y2="8"/><line x1="2" y1="15" x2="9" y2="10" strokeOpacity="0.6"/><line x1="10" y1="10" x2="16" y2="6"/><line x1="10" y1="13" x2="16" y2="9" strokeOpacity="0.6"/></S>;
const IcFlatTop = () => <S strokeWidth="1.4"><line x1="2" y1="5" x2="16" y2="5"/><path d="M2 5 L4 9 L7 5 L10 11 L13 5 L16 5" strokeOpacity="0.7"/><line x1="2" y1="14" x2="16" y2="14" strokeOpacity="0.5"/></S>;
// 피보나치 그룹
const IcFib = () => <S strokeWidth="1.3"><line x1="2" y1="3" x2="16" y2="3"/><line x1="2" y1="7" x2="16" y2="7" strokeOpacity="0.6"/><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="12" x2="16" y2="12" strokeOpacity="0.6"/><line x1="2" y1="15" x2="16" y2="15"/><circle cx="2" cy="3" r="1.5" fill="currentColor" stroke="none"/><circle cx="2" cy="15" r="1.5" fill="currentColor" stroke="none"/></S>;
const IcFibExt = () => <S strokeWidth="1.3"><line x1="2" y1="6" x2="16" y2="6" strokeOpacity="0.5"/><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="12" x2="16" y2="12" strokeOpacity="0.5"/><line x1="2" y1="15" x2="16" y2="15"/><line x1="2" y1="3" x2="16" y2="3" strokeOpacity="0.3" strokeDasharray="2 2"/><circle cx="2" cy="9" r="1.5" fill="currentColor" stroke="none"/><circle cx="2" cy="15" r="1.5" fill="currentColor" stroke="none"/></S>;
const IcFibCh = () => <S strokeWidth="1.3"><line x1="2" y1="14" x2="16" y2="5"/><line x1="2" y1="11" x2="16" y2="2" strokeOpacity="0.6"/><line x1="2" y1="17" x2="16" y2="8" strokeOpacity="0.4"/></S>;
const IcFibTZ = () => <S strokeWidth="1.3"><line x1="2" y1="2" x2="2" y2="16"/><line x1="5" y1="2" x2="5" y2="16" strokeOpacity="0.7"/><line x1="8" y1="2" x2="8" y2="16" strokeOpacity="0.5"/><line x1="11" y1="2" x2="11" y2="16" strokeOpacity="0.7"/><line x1="15" y1="2" x2="15" y2="16" strokeOpacity="0.4"/></S>;
const IcFibFan = () => <S strokeWidth="1.3"><line x1="2" y1="16" x2="16" y2="3"/><line x1="2" y1="16" x2="16" y2="7" strokeOpacity="0.7"/><line x1="2" y1="16" x2="16" y2="11" strokeOpacity="0.5"/><line x1="2" y1="16" x2="16" y2="14" strokeOpacity="0.3"/></S>;
const IcFibTrendT = () => <S strokeWidth="1.3"><line x1="3" y1="14" x2="15" y2="6"/><line x1="5" y1="6" x2="5" y2="14" strokeOpacity="0.6"/><line x1="8" y1="6" x2="8" y2="14" strokeOpacity="0.7"/><line x1="11" y1="6" x2="11" y2="14" strokeOpacity="0.5"/></S>;
const IcFibCircles = () => <S strokeWidth="1.3"><circle cx="9" cy="9" r="2"/><circle cx="9" cy="9" r="5"/><circle cx="9" cy="9" r="7.5" strokeOpacity="0.4"/></S>;
const IcFibSpiral = () => <S strokeWidth="1.3"><path d="M9 9 Q12 6 13 9 Q14 13 9 14 Q4 14 4 9 Q4 4 9 3 Q15 3 16 9" strokeOpacity="0.8"/></S>;
const IcFibArc = () => <S strokeWidth="1.3"><line x1="2" y1="14" x2="16" y2="14" strokeOpacity="0.5"/><path d="M2 14 Q9 5 16 14" strokeOpacity="0.9"/><path d="M2 14 Q9 2 16 14" strokeOpacity="0.6"/></S>;
const IcFibWedge = () => <S strokeWidth="1.3"><path d="M2 15 L16 9 L16 15 Z"/><path d="M2 15 L16 6" strokeOpacity="0.5"/></S>;
const IcPitchfork = () => <S strokeWidth="1.4"><line x1="3" y1="9" x2="10" y2="7"/><line x1="10" y1="7" x2="16" y2="4"/><line x1="10" y1="7" x2="16" y2="10"/><circle cx="3" cy="9" r="1.5" fill="currentColor" stroke="none"/></S>;
const IcPitchSchiff = () => <S strokeWidth="1.4"><line x1="3" y1="10" x2="9" y2="7"/><line x1="9" y1="7" x2="16" y2="3" strokeDasharray="3 2"/><line x1="9" y1="7" x2="16" y2="11"/></S>;
const IcPitchInside = () => <S strokeWidth="1.4"><line x1="3" y1="9" x2="11" y2="7"/><line x1="11" y1="7" x2="16" y2="5" strokeOpacity="0.6"/><line x1="11" y1="7" x2="16" y2="9" strokeOpacity="0.6"/></S>;
const IcGannBox = () => <S strokeWidth="1.3"><rect x="3" y="4" width="12" height="10" rx="0.5"/><line x1="3" y1="4" x2="15" y2="14" strokeOpacity="0.6"/><line x1="15" y1="4" x2="3" y2="14" strokeOpacity="0.4"/></S>;
const IcGannSq = () => <S strokeWidth="1.3"><rect x="4" y="4" width="10" height="10" rx="0.5"/><line x1="4" y1="4" x2="14" y2="14" strokeOpacity="0.6"/><line x1="14" y1="4" x2="4" y2="14" strokeOpacity="0.6"/></S>;
const IcGannFan = () => <S strokeWidth="1.3"><line x1="2" y1="16" x2="16" y2="2"/><line x1="2" y1="16" x2="16" y2="4" strokeOpacity="0.6"/><line x1="2" y1="16" x2="16" y2="8" strokeOpacity="0.5"/><line x1="2" y1="16" x2="16" y2="13" strokeOpacity="0.4"/></S>;
// 패턴 그룹
const IcXABCD = () => <S strokeWidth="1.4"><path d="M2 15 L6 5 L10 13 L13 7 L16 11"/><circle cx="2" cy="15" r="1.2" fill="currentColor" stroke="none"/><circle cx="6" cy="5" r="1.2" fill="currentColor" stroke="none"/><circle cx="10" cy="13" r="1.2" fill="currentColor" stroke="none"/><circle cx="13" cy="7" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="11" r="1.2" fill="currentColor" stroke="none"/></S>;
const IcCypher = () => <S strokeWidth="1.4"><path d="M2 14 L7 4 L11 12 L14 6 L16 10"/><circle cx="2" cy="14" r="1.2" fill="currentColor" stroke="none"/><circle cx="7" cy="4" r="1.2" fill="currentColor" stroke="none"/><circle cx="11" cy="12" r="1.2" fill="currentColor" stroke="none"/><circle cx="14" cy="6" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="10" r="1.2" fill="currentColor" stroke="none"/></S>;
const IcABCD = () => <S strokeWidth="1.4"><path d="M3 14 L8 4 L13 12 L16 6"/><circle cx="3" cy="14" r="1.2" fill="currentColor" stroke="none"/><circle cx="8" cy="4" r="1.2" fill="currentColor" stroke="none"/><circle cx="13" cy="12" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="6" r="1.2" fill="currentColor" stroke="none"/></S>;
const IcHeadShould = () => <S strokeWidth="1.4"><path d="M2 13 L5 8 L7 11 L9 4 L11 11 L13 8 L16 13"/></S>;
const IcWedgePat = () => <S strokeWidth="1.4"><path d="M2 5 L16 7"/><path d="M2 14 L16 11"/><path d="M5 5.4 L5 13.2" strokeOpacity="0.4" strokeDasharray="2 2"/><path d="M9 5.9 L9 12.5" strokeOpacity="0.4" strokeDasharray="2 2"/><path d="M13 6.4 L13 11.8" strokeOpacity="0.4" strokeDasharray="2 2"/></S>;
const IcThreeDrives = () => <S strokeWidth="1.4"><path d="M2 13 L5 8 L8 11 L11 6 L14 9 L16 6"/><circle cx="5" cy="8" r="1.2" fill="currentColor" stroke="none"/><circle cx="11" cy="6" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="6" r="1.2" fill="currentColor" stroke="none"/></S>;
const IcElliottImp = () => <S strokeWidth="1.3"><path d="M2 14 L5 6 L8 12 L11 4 L14 10 L16 8"/><text x="3" y="17" fontSize="4" fill="currentColor" stroke="none">1 2 3 4 5</text></S>;
const IcElliottCorr = () => <S strokeWidth="1.3"><path d="M2 6 L7 14 L12 8 L16 14"/><text x="3" y="4" fontSize="4.5" fill="currentColor" stroke="none">A B C</text></S>;
const IcElliottTri = () => <S strokeWidth="1.3"><path d="M2 8 L5 14 L8 9 L11 14 L14 10 L16 14"/><text x="2" y="4" fontSize="3.5" fill="currentColor" stroke="none">ABCDE</text></S>;
const IcElliottDbl = () => <S strokeWidth="1.3"><path d="M2 14 L5 8 L8 13 L11 7 L14 12 L16 10"/><text x="2" y="4" fontSize="4" fill="currentColor" stroke="none">W X Y</text></S>;
// 프로젝션 그룹
const IcLongPos = () => <S strokeWidth="1.3"><rect x="3" y="5" width="12" height="5" rx="0.5" fill="rgba(76,175,80,0.3)" stroke="#4CAF50"/><rect x="3" y="10" width="12" height="4" rx="0.5" fill="rgba(239,83,80,0.2)" stroke="#EF5350" strokeDasharray="2 1.5"/><line x1="3" y1="10" x2="15" y2="10" stroke="#9E9E9E"/></S>;
const IcShortPos = () => <S strokeWidth="1.3"><rect x="3" y="9" width="12" height="5" rx="0.5" fill="rgba(239,83,80,0.3)" stroke="#EF5350"/><rect x="3" y="5" width="12" height="4" rx="0.5" fill="rgba(76,175,80,0.2)" stroke="#4CAF50" strokeDasharray="2 1.5"/><line x1="3" y1="9" x2="15" y2="9" stroke="#9E9E9E"/></S>;
const IcForecast = () => <S strokeWidth="1.3"><line x1="2" y1="14" x2="9" y2="8"/><line x1="9" y1="8" x2="16" y2="5" strokeDasharray="3 2" strokeOpacity="0.7"/><circle cx="9" cy="8" r="2" fill="currentColor" stroke="none" fillOpacity="0.5"/></S>;
const IcBarPattern = () => <S strokeWidth="1.3"><rect x="2" y="3" width="6" height="12" rx="0.5" strokeOpacity="0.6"/><rect x="2" y="5" width="6" height="8" rx="0.5" fill="currentColor" fillOpacity="0.2"/><line x1="5" y1="3" x2="5" y2="5"/><line x1="5" y1="13" x2="5" y2="15"/><line x1="10" y1="3" x2="16" y2="6" strokeDasharray="2 1.5" strokeOpacity="0.6"/><line x1="10" y1="13" x2="16" y2="14" strokeDasharray="2 1.5" strokeOpacity="0.6"/></S>;
const IcGhostFeed = () => <S strokeWidth="1.3"><rect x="2" y="5" width="6" height="8" rx="0.5" strokeOpacity="0.4" strokeDasharray="2 1.5"/><rect x="10" y="4" width="6" height="10" rx="0.5" strokeOpacity="0.4" strokeDasharray="2 1.5"/><line x1="5" y1="5" x2="5" y2="3" strokeOpacity="0.4"/><line x1="13" y1="4" x2="13" y2="2" strokeOpacity="0.4"/></S>;
const IcProjection = () => <S strokeWidth="1.3"><path d="M2 13 L8 8"/><path d="M8 8 Q12 5 16 7" strokeDasharray="3 2" strokeOpacity="0.7"/><circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" fillOpacity="0.5"/><circle cx="2" cy="13" r="1.5" fill="currentColor" stroke="none"/></S>;
const IcVWAP = () => <S strokeWidth="1.3"><path d="M2 9 Q6 6 9 9 Q12 12 16 9" strokeOpacity="0.8"/><circle cx="2" cy="9" r="2" fill="currentColor" stroke="none" fillOpacity="0.7"/><line x1="2" y1="6" x2="2" y2="12" strokeOpacity="0.5"/></S>;
const IcVolProfile = () => <S strokeWidth="1.3"><line x1="3" y1="2" x2="3" y2="16"/><line x1="3" y1="5" x2="9" y2="5" strokeWidth="2.5" strokeOpacity="0.5"/><line x1="3" y1="8" x2="14" y2="8" strokeWidth="2.5" strokeOpacity="0.7"/><line x1="3" y1="11" x2="11" y2="11" strokeWidth="2.5" strokeOpacity="0.5"/><line x1="3" y1="14" x2="7" y2="14" strokeWidth="2.5" strokeOpacity="0.4"/></S>;
const IcPriceRange = () => <S strokeWidth="1.4"><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="6" x2="2" y2="12"/><line x1="16" y1="6" x2="16" y2="12"/></S>;
// 도형 그룹
const IcRect = () => <S strokeWidth="1.4"><rect x="3" y="5" width="12" height="8" rx="0.5"/></S>;
const IcRotRect = () => <S strokeWidth="1.4"><path d="M9 2 L16 7 L13 16 L3 14 L2 7 Z"/></S>;
const IcCircle = () => <S strokeWidth="1.4"><circle cx="9" cy="9" r="6.5"/></S>;
const IcEllipse = () => <S strokeWidth="1.4"><ellipse cx="9" cy="9" rx="7" ry="4.5"/></S>;
const IcTriangle = () => <S strokeWidth="1.4"><path d="M9 3 L16 15 L2 15 Z"/></S>;
const IcParallelo = () => <S strokeWidth="1.4"><path d="M4 14 L7 4 L16 4 L13 14 Z"/></S>;
const IcPolyline = () => <S strokeWidth="1.4"><path d="M2 14 L6 6 L10 11 L14 5"/><circle cx="2" cy="14" r="1.5" fill="currentColor" stroke="none"/><circle cx="14" cy="5" r="1.5" fill="currentColor" stroke="none"/></S>;
const IcArc = () => <S strokeWidth="1.4"><path d="M2 15 Q9 1 16 15"/></S>;
// 브러시 그룹
const IcPencil = () => <S strokeWidth="1.4"><path d="M3 15L3.5 11L13 2.5Q14.5 1 16 2.5Q17.5 4 16 5.5L6.5 15Z"/><line x1="11" y1="4" x2="14" y2="7"/></S>;
const IcHighlight = () => <S><path d="M3 13 Q9 8 15 13" strokeOpacity="0.5" strokeWidth="5" stroke="#FFEB3B" fill="none"/><path d="M3 13 Q9 8 15 13" strokeWidth="1.4" stroke="currentColor" fill="none"/></S>;
const IcArrowMark = () => <S strokeWidth="1.4"><line x1="3" y1="14" x2="15" y2="5"/><circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none"/><polyline points="11,4 15,5 14,9" strokeWidth="1.3" fill="currentColor" fillOpacity="0.5"/></S>;
const IcArrowAnch = () => <S strokeWidth="1.4"><circle cx="4" cy="14" r="2" fill="currentColor" stroke="none" fillOpacity="0.5"/><line x1="4" y1="12" x2="14" y2="5" strokeDasharray="2 1.5"/><polyline points="11,4 14,5 13,8" fill="currentColor" fillOpacity="0.6"/></S>;
const IcArrowUp = () => <S strokeWidth="1.4"><path d="M9 14 L9 7"/><path d="M5 10 L9 6 L13 10" fill="currentColor" fillOpacity="0.5"/></S>;
const IcArrowDown = () => <S strokeWidth="1.4"><path d="M9 4 L9 11"/><path d="M5 8 L9 12 L13 8" fill="currentColor" fillOpacity="0.5"/></S>;
const IcArrowLeft = () => <S strokeWidth="1.4"><path d="M14 9 L7 9"/><path d="M10 5 L6 9 L10 13" fill="currentColor" fillOpacity="0.5"/></S>;
const IcArrowRight = () => <S strokeWidth="1.4"><path d="M4 9 L11 9"/><path d="M8 5 L12 9 L8 13" fill="currentColor" fillOpacity="0.5"/></S>;
// 텍스트 그룹
const IcText = () => <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><text x="3" y="14" fontSize="13" fontWeight="700" fill="currentColor" fontFamily="sans-serif">T</text></svg>;
const IcFixedText = () => <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><text x="3" y="14" fontSize="13" fontWeight="700" fill="currentColor" fontFamily="sans-serif">T</text><circle cx="15" cy="15" r="3" fill="currentColor" fillOpacity="0.5" stroke="none"/></svg>;
const IcNote = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1.5"/><line x1="5" y1="7" x2="13" y2="7" strokeOpacity="0.7"/><line x1="5" y1="9.5" x2="13" y2="9.5" strokeOpacity="0.7"/><line x1="5" y1="12" x2="10" y2="12" strokeOpacity="0.7"/><circle cx="16" cy="3" r="2.5" stroke="none" fill="currentColor" fillOpacity="0.5"/></S>;
const IcPriceNote = () => <S strokeWidth="1.3"><line x1="2" y1="9" x2="8" y2="9"/><rect x="8" y="5" width="8" height="8" rx="1"/><text x="12" y="10.5" fontSize="5" fill="currentColor" stroke="none" textAnchor="middle">$</text></S>;
const IcPin = () => <S strokeWidth="1.3"><path d="M9 3 Q12 3 12 7 Q12 10 9 15 Q6 10 6 7 Q6 3 9 3 Z"/><circle cx="9" cy="7" r="1.8" stroke="none" fill="currentColor" fillOpacity="0.6"/></S>;
const IcTable = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1"/><line x1="2" y1="7" x2="16" y2="7"/><line x1="2" y1="11" x2="16" y2="11"/><line x1="7" y1="3" x2="7" y2="15"/><line x1="12" y1="3" x2="12" y2="15"/></S>;
const IcCallout = () => <S strokeWidth="1.3"><rect x="2" y="3" width="12" height="8" rx="1.5"/><path d="M7 11 L5 15 L10 11"/></S>;
const IcComment = () => <S strokeWidth="1.3"><path d="M2 3 Q2 2 3 2 L15 2 Q16 2 16 3 L16 11 Q16 12 15 12 L8 12 L5 15 L5 12 L3 12 Q2 12 2 11 Z"/></S>;
const IcPriceLabel = () => <S strokeWidth="1.3"><path d="M2 9 L6 9 L8 6 L12 6 L14 9 L12 12 L8 12 L6 9"/></S>;
const IcGuide = () => <S strokeWidth="1.3"><line x1="1" y1="9" x2="17" y2="9" strokeDasharray="3 2" strokeOpacity="0.8"/><circle cx="9" cy="9" r="3" strokeOpacity="0.6"/></S>;
const IcFlag = () => <S strokeWidth="1.3"><line x1="5" y1="3" x2="5" y2="15"/><path d="M5 3 L15 6 L5 9 Z" fill="currentColor" fillOpacity="0.4"/></S>;
// 측정 그룹
const IcMeasure = () => <S strokeWidth="1.4"><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="6" x2="2" y2="12"/><line x1="16" y1="6" x2="16" y2="12"/></S>;
const IcDateRange = () => <S strokeWidth="1.3"><rect x="2" y="4" width="14" height="12" rx="1"/><line x1="2" y1="8" x2="16" y2="8"/><line x1="6" y1="2" x2="6" y2="6"/><line x1="12" y1="2" x2="12" y2="6"/></S>;
const IcDatePriceR = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1" strokeOpacity="0.7"/><line x1="2" y1="9" x2="16" y2="9" strokeOpacity="0.6"/><line x1="9" y1="3" x2="9" y2="15" strokeOpacity="0.6"/></S>;
const IcDataWindow = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1"/><line x1="2" y1="7" x2="16" y2="7"/><line x1="7" y1="3" x2="7" y2="7"/></S>;
const IcZoom = () => <S strokeWidth="1.4"><circle cx="8" cy="8" r="5"/><line x1="12" y1="12" x2="16" y2="16"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="8" y1="6" x2="8" y2="10"/></S>;
const IcMagnifier = () => <S strokeWidth="1.4"><circle cx="8" cy="8" r="5"/><line x1="12" y1="12" x2="16" y2="16"/></S>;
// 하단 버튼 아이콘
const IcMagnetWeak = () => <S strokeWidth="1.4"><path d="M5 14 L5 8 Q5 3 9 3 Q13 3 13 8 L13 14"/><line x1="3" y1="14" x2="7" y2="14" strokeWidth="2"/><line x1="11" y1="14" x2="15" y2="14" strokeWidth="2"/></S>;
const IcMagnetStrg = () => <S strokeWidth="1.4"><path d="M5 14 L5 8 Q5 3 9 3 Q13 3 13 8 L13 14"/><line x1="3" y1="14" x2="7" y2="14" strokeWidth="2"/><line x1="11" y1="14" x2="15" y2="14" strokeWidth="2"/><line x1="9" y1="9" x2="9" y2="13" strokeDasharray="1.5 1" strokeOpacity="0.7"/></S>;
const IcLock = () => <S strokeWidth="1.4"><rect x="4" y="9" width="10" height="7" rx="1"/><path d="M6 9V6Q6 3 9 3Q12 3 12 6V9"/></S>;
const IcEye = () => <S strokeWidth="1.4"><path d="M1 9Q5 3 9 3Q13 3 17 9Q13 15 9 15Q5 15 1 9Z"/><circle cx="9" cy="9" r="2.5"/></S>;
const IcEyeOff = () => <S strokeWidth="1.4"><path d="M1 9Q5 3 9 3Q13 3 17 9Q13 15 9 15Q5 15 1 9Z" strokeOpacity="0.3"/><line x1="3" y1="3" x2="15" y2="15"/></S>;
const IcTrash = () => <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"><line x1="3" y1="5" x2="13" y2="5"/><path d="M6 5V3Q6 2 8 2Q10 2 10 3V5"/><path d="M4 5L4.5 14Q4.5 14.5 5 14.5L11 14.5Q11.5 14.5 11.5 14L12 5"/></svg>;
const IcUndo = () => <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M3 7C3 4 5.5 2 8.5 2C11.5 2 14 4 14 7C14 10 11.5 12 8.5 12"/><polyline points="1,5 3,7 5,5"/></svg>;
const IcLayers = () => <S strokeWidth="1.4"><path d="M2 12 L9 15 L16 12"/><path d="M2 9 L9 12 L16 9"/><path d="M2 6 L9 9 L16 6"/></S>;
// ─── 타입 ──────────────────────────────────────────────────────────────────
type FlyoutItem =
| { kind: 'tool'; id: string; title: string; shortcut?: string; icon: React.ReactNode }
| { kind: 'sep'; label?: string };
interface GroupDef {
id: number;
label: string;
defaultIcon: React.ReactNode;
items: FlyoutItem[];
}
// ─── 그룹 정의 ──────────────────────────────────────────────────────────────
const T = (id: string, title: string, icon: React.ReactNode, shortcut?: string): FlyoutItem =>
({ kind: 'tool', id, title, icon, shortcut });
const SEP = (label?: string): FlyoutItem => ({ kind: 'sep', label });
/** 업비트 차트 좌측 즐겨찾기 플로팅 툴바와 동일 4종 */
export const FAVORITE_DRAWING_TOOLS = ['hline', 'vline', 'date_range', 'fibtz'] as const;
const GROUPS: GroupDef[] = [
{
id: 0, label: '즐겨찾기', defaultIcon: <IcFavorite />,
items: [
T('hline', '가로줄', <IcUpbitHline />, '⌥H'),
T('vline', '세로줄', <IcUpbitVline />, '⌥V'),
T('date_range', '기간', <IcUpbitPeriod />),
T('fibtz', '피보나치 타임존', <IcUpbitFibTz />),
],
},
{
id: 1, label: '라인', defaultIcon: <IcTrendline />,
items: [
T('trendline', '추세줄', <IcTrendline />, '⌥T'),
T('ray', '빛', <IcRay />),
T('info_line', '인포 라인', <IcInfoLine />),
T('extended_line', '익스텐디드 라인', <IcExtLine />),
T('trend_angle', '추세각', <IcTrendAngle />),
SEP(),
T('hline', '가로줄', <IcHline />, '⌥H'),
T('hline_ray', '가로빛', <IcHlineRay />, '⌥J'),
T('vline', '수직선', <IcVline />, '⌥V'),
T('cross_line', '크로스 라인', <IcCrossLine />, '⌥C'),
SEP('채널'),
T('channel', '추세 채널', <IcChannel />),
T('parallel_channel','패러렐 채널', <IcParaCh />),
T('disjoint_channel','디스조인트 채널',<IcDisjCh />),
T('flat_top_bottom','플랫 탑/바텀', <IcFlatTop />),
],
},
{
id: 2, label: '피보나치', defaultIcon: <IcFib />,
items: [
T('fib', '피보나치 되돌림', <IcFib />, '⌥F'),
T('fib_ext', '추세기반 피보나치 확장', <IcFibExt />),
T('fib_channel', '피보나치 채널', <IcFibCh />),
T('fibtz', '피보나치 타임존', <IcFibTZ />),
T('fib_speed_fan', '스피드 리지스턴스 팬', <IcFibFan />),
T('fib_trend_time','추세기반 피보나치 시간', <IcFibTrendT />),
T('fib_circles', '피보나치 서클', <IcFibCircles />),
T('fib_spiral', '피보나치 스파이럴', <IcFibSpiral />),
T('fib_speed_arc', '스피드 리지스턴스 아크', <IcFibArc />),
T('fib_wedge', '피보나치 웻지', <IcFibWedge />),
SEP('피치포크'),
T('pitchfork', '앤드류스 피치포크', <IcPitchfork />),
T('pitchfork_schiff', '쉬프 피치포크', <IcPitchSchiff />),
T('pitchfork_inside', '인사이드 피치포크', <IcPitchInside />),
SEP('갠'),
T('gann_box', '갠 박스', <IcGannBox />),
T('gann_square', '갠 스퀘어', <IcGannSq />),
T('gann_fan', '갠 팬', <IcGannFan />),
],
},
{
id: 3, label: '패턴', defaultIcon: <IcXABCD />,
items: [
T('xabcd', 'XABCD 패턴', <IcXABCD />),
T('cypher', '사이퍼 패턴', <IcCypher />),
T('head_shoulders', '헤드 앤 숄더', <IcHeadShould />),
T('abcd', 'ABCD 패턴', <IcABCD />),
T('wedge_pattern', '세모 패턴', <IcWedgePat />),
T('three_drives', '쓰리 드라이브 패턴', <IcThreeDrives />),
SEP('엘리엇 웨이브'),
T('elliott_impulse', '엘리엇 임펄스 파동 (12345)', <IcElliottImp />),
T('elliott_correction', '엘리엇 코렉션 파동 (ABC)', <IcElliottCorr />),
T('elliott_triangle_wave', '엘리엇 트라이앵글 웨이브 (ABCDE)',<IcElliottTri />),
T('elliott_double_combo', '엘리엇 다블콤보 파동 (WXY)', <IcElliottDbl />),
],
},
{
id: 4, label: '프로젝션', defaultIcon: <IcLongPos />,
items: [
T('long_position', '매수 포지션', <IcLongPos />),
T('short_position', '숏 포지션', <IcShortPos />),
T('forecast', '예측', <IcForecast />),
T('bar_pattern', '봉패턴', <IcBarPattern />),
T('ghost_feed', '고스트피드', <IcGhostFeed />),
T('projection_tool','프로젝션', <IcProjection />),
SEP('볼륨-기반'),
T('anchored_vwap', '앵커드 VWAP', <IcVWAP />),
T('fixed_volume_profile', '픽스트 레인지 볼륨 프로파일', <IcVolProfile />),
SEP('계측기'),
T('price_range', '가격범위', <IcPriceRange />),
T('date_range', '날짜 범위', <IcDateRange />),
T('date_price_range','날짜&가격 범위', <IcDatePriceR />),
],
},
{
id: 5, label: '도형', defaultIcon: <IcRect />,
items: [
T('rect', '네모', <IcRect />, '⌥⇧R'),
T('rotated_rect', '회전 사각형', <IcRotRect />),
T('circle', '원', <IcCircle />),
T('ellipse', '타원', <IcEllipse />),
T('triangle', '삼각형', <IcTriangle />),
T('parallelogram', '평행사변형', <IcParallelo />),
T('polyline', '다각형', <IcPolyline />),
T('arc', '호', <IcArc />),
],
},
{
id: 6, label: '브러시', defaultIcon: <IcPencil />,
items: [
T('pencil', '붓', <IcPencil />),
T('highlight', '하이라이터', <IcHighlight />),
SEP('화살'),
T('arrow_mark', '화살표', <IcArrowMark />),
T('anchored_arrow', '화살표', <IcArrowAnch />),
T('arrow_mark_up', '위화살표', <IcArrowUp />),
T('arrow_mark_down', '아래화살표', <IcArrowDown />),
T('arrow_mark_left', '왼화살표', <IcArrowLeft />),
T('arrow_mark_right', '오른화살표', <IcArrowRight />),
SEP('세이프'),
T('rect', '네모', <IcRect />, '⌥⇧R'),
T('circle', '원', <IcCircle />),
T('triangle', '삼각형', <IcTriangle />),
T('ellipse', '타원', <IcEllipse />),
],
},
{
id: 7, label: '텍스트 & 노트', defaultIcon: <IcText />,
items: [
T('text', '텍스트', <IcText />, 'T'),
T('fixed_text', '고정위치문자', <IcFixedText />),
T('note_text', '노트', <IcNote />),
T('price_note', '프라이스 노트', <IcPriceNote />),
T('pin_mark', '핀', <IcPin />),
T('table_tool', '테이블', <IcTable />),
T('callout', '콜아웃', <IcCallout />),
T('comment_mark', '코멘트', <IcComment />),
T('price_label', '가격라벨', <IcPriceLabel />),
T('guide_line', '길잡이', <IcGuide />),
T('flag_mark', '플래그 마크', <IcFlag />),
],
},
{
id: 8, label: '측정', defaultIcon: <IcMeasure />,
items: [
T('measure', '가격 측정', <IcMeasure />, 'M'),
T('date_range', '날짜 범위', <IcDateRange />),
T('date_price_range', '날짜&가격 범위', <IcDatePriceR />),
T('data_window', '데이터 창', <IcDataWindow />),
],
},
{
id: 9, label: '확대/축소', defaultIcon: <IcZoom />,
items: [
T('zoom', '확대/축소', <IcZoom />),
T('magnifier', '돋보기', <IcMagnifier />),
],
},
];
/** 차트 모드에서도 사용 가능한 확대 도구 (드로잉 툴바는 chart 모드에서 기본 비활성) */
export const CHART_ZOOM_TOOLS = new Set(['zoom', 'magnifier']);
// ─── Component ────────────────────────────────────────────────────────────────
const DrawingToolbar: React.FC<DrawingToolbarProps> = ({
activeTool, mode, onTool, onClearAll, onUndo,
locked, visible, onToggleLock, onToggleEye,
indicatorsVisible = true,
magnetMode = 'off',
snapToIndicator = false,
drawingCount = 0,
indicatorCount = 0,
onMagnetMode,
onSnapToIndicator,
onHideDrawings,
onHideIndicators,
onHideAll,
onClearIndicators,
keepLockedOnDelete = false,
onToggleKeepLocked,
}) => {
const disabled = mode === 'chart';
const canSelectTool = (toolId: string) => !disabled || CHART_ZOOM_TOOLS.has(toolId);
const [selectedPerGroup, setSelectedPerGroup] = useState<Record<number, string>>({});
const [openGroup, setOpenGroup] = useState<number | null>(null);
const [flyoutKind, setFlyoutKind] = useState<'group' | 'magnet' | 'eye' | 'trash' | null>(null);
const [flyoutPos, setFlyoutPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (openTimer.current) clearTimeout(openTimer.current);
if (closeTimer.current) clearTimeout(closeTimer.current);
}, []);
const getRepIcon = (group: GroupDef): React.ReactNode => {
const selId = selectedPerGroup[group.id];
if (selId) {
const it = group.items.find(i => i.kind === 'tool' && i.id === selId);
if (it && it.kind === 'tool') return it.icon;
}
const active = group.items.find(i => i.kind === 'tool' && i.id === activeTool);
if (active && active.kind === 'tool') return active.icon;
return group.defaultIcon;
};
const isGroupActive = (group: GroupDef) =>
group.items.some(i => i.kind === 'tool' && i.id === activeTool);
const openFlyout = (kind: 'group' | 'magnet' | 'eye' | 'trash', groupId: number | null, el: HTMLElement) => {
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
if (openTimer.current) clearTimeout(openTimer.current);
openTimer.current = setTimeout(() => {
const rect = el.getBoundingClientRect();
setFlyoutPos({ top: rect.top, left: rect.right + 4 });
setOpenGroup(groupId);
setFlyoutKind(kind);
}, 80);
};
const scheduleClose = () => {
if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; }
closeTimer.current = setTimeout(() => { setFlyoutKind(null); setOpenGroup(null); }, 200);
};
const cancelClose = () => {
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
};
const selectTool = (groupId: number, toolId: string) => {
if (!canSelectTool(toolId)) return;
setSelectedPerGroup(prev => ({ ...prev, [groupId]: toolId }));
setFlyoutKind(null); setOpenGroup(null);
if (openTimer.current) clearTimeout(openTimer.current);
if (closeTimer.current) clearTimeout(closeTimer.current);
onTool(toolId);
};
const handleGroupClick = (group: GroupDef) => {
if (disabled && group.id !== 9) return;
const selId = selectedPerGroup[group.id];
const toolId = (selId && group.items.find(i => i.kind === 'tool' && i.id === selId))
? selId
: (group.items.find(i => i.kind === 'tool' && i.id === activeTool) as { kind: 'tool'; id: string } | undefined)?.id
?? (group.items.find(i => i.kind === 'tool') as { kind: 'tool'; id: string } | undefined)?.id;
if (toolId && canSelectTool(toolId)) onTool(toolId);
};
const closeFlyout = () => { setFlyoutKind(null); setOpenGroup(null); };
// 현재 마그넷 아이콘
const magnetIcon = magnetMode === 'strong' ? <IcMagnetStrg /> : <IcMagnetWeak />;
const eyeIsOff = !visible && !indicatorsVisible;
return (
<div className={`tv-drawing-sidebar ${disabled ? 'chart-mode' : 'trading-mode'}`}>
{/* ── 커서 ── */}
<button
className={`tv-side-btn ${activeTool === 'cursor' && !disabled ? 'active' : ''}`}
title="커서 (Esc)"
onClick={() => !disabled && onTool('cursor')}
>
<IcCursor />
</button>
<div className="tv-side-sep" />
{/* ── 툴 그룹 (즐겨찾기 → 라인 …) ── */}
{GROUPS.map(group => (
<div key={group.id} className="tv-side-group">
<button
className={`tv-side-btn tv-side-group-btn${group.id === 0 ? ' tv-side-group-btn--favorite' : ''} ${isGroupActive(group) && (!disabled || group.id === 9) ? 'active' : ''}`}
onMouseEnter={e => openFlyout('group', group.id, e.currentTarget)}
onMouseLeave={scheduleClose}
onClick={() => handleGroupClick(group)}
title={group.label}
>
{getRepIcon(group)}
<span className="tv-side-expand-arrow" />
</button>
</div>
))}
<div className="tv-side-spacer" />
<div className="tv-side-sep" />
{/* ── 하단 버튼: 마그넷 ── */}
<button
className={`tv-side-btn ${magnetMode !== 'off' ? 'active' : ''}`}
title="마그넷"
onMouseEnter={e => openFlyout('magnet', null, e.currentTarget)}
onMouseLeave={scheduleClose}
>
{magnetIcon}
</button>
{/* ── 드로잉 잠금 ── */}
<button
className={`tv-side-btn ${locked ? 'active tv-side-lock-on' : ''}`}
title={locked ? '잠금 해제' : '드로잉 잠금'}
onClick={() => !disabled && onToggleLock()}
>
<IcLock />
</button>
{/* ── 표시/숨기기 토글 (플라이아웃) ── */}
<button
className={`tv-side-btn ${eyeIsOff ? 'tv-side-eye-off active' : (!visible || !indicatorsVisible) ? 'tv-side-eye-partial' : ''}`}
title="표시/숨기기"
onMouseEnter={e => openFlyout('eye', null, e.currentTarget)}
onMouseLeave={scheduleClose}
onClick={() => !disabled && onToggleEye()}
>
{eyeIsOff ? <IcEyeOff /> : <IcEye />}
</button>
<div className="tv-side-sep" />
{/* ── 실행 취소 ── */}
<button className="tv-side-btn" title="실행 취소 (Ctrl+Z)" onClick={() => !disabled && onUndo()}>
<IcUndo />
</button>
{/* ── 삭제 (플라이아웃) ── */}
<button
className="tv-side-btn tv-side-clear"
title="삭제"
onMouseEnter={e => openFlyout('trash', null, e.currentTarget)}
onMouseLeave={scheduleClose}
onClick={() => !disabled && onClearAll()}
>
<IcTrash />
</button>
{/* ── 레이어 ── */}
<button className="tv-side-btn" title="레이어">
<IcLayers />
</button>
{/* ════════════════════════════════════════════
── 플라이아웃 팝업 (fixed 위치)
════════════════════════════════════════════ */}
{flyoutKind !== null && (!disabled || (flyoutKind === 'group' && openGroup === 9)) && (
<div
className="tv-side-flyout"
style={{ top: flyoutPos.top, left: flyoutPos.left }}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
{/* ── 툴 그룹 플라이아웃 ── */}
{flyoutKind === 'group' && (() => {
const group = GROUPS.find(g => g.id === openGroup);
if (!group) return null;
return (
<>
<div className="tv-side-flyout-header">{group.label}</div>
{group.items.map((item, idx) =>
item.kind === 'sep' ? (
<div key={`sep-${idx}`} className="tv-side-flyout-sep">
{item.label && <span className="tv-side-flyout-sec-label">{item.label}</span>}
</div>
) : (
<button
key={item.id}
className={`tv-side-flyout-item ${activeTool === item.id ? 'active' : ''}${item.kind === 'tool' && !canSelectTool(item.id) ? ' disabled' : ''}`}
disabled={item.kind === 'tool' && !canSelectTool(item.id)}
onClick={() => item.kind === 'tool' && selectTool(group.id, item.id)}
>
<span className="tv-side-flyout-icon">{item.icon}</span>
<span className="tv-side-flyout-title">{item.title}</span>
{item.shortcut && <span className="tv-side-flyout-shortcut">{item.shortcut}</span>}
</button>
)
)}
</>
);
})()}
{/* ── 마그넷 플라이아웃 ── */}
{flyoutKind === 'magnet' && (
<>
<div className="tv-side-flyout-header"></div>
{(['weak','strong'] as const).map(m => (
<button
key={m}
className={`tv-side-flyout-item ${magnetMode === m ? 'active' : ''}`}
onClick={() => { onMagnetMode?.(m); closeFlyout(); }}
>
<span className="tv-side-flyout-icon">{m === 'weak' ? <IcMagnetWeak /> : <IcMagnetStrg />}</span>
<span className="tv-side-flyout-title">{m === 'weak' ? '위크 마그넷' : '스트롱 마그넷'}</span>
</button>
))}
{magnetMode !== 'off' && (
<button className="tv-side-flyout-item" onClick={() => { onMagnetMode?.('off'); closeFlyout(); }}>
<span className="tv-side-flyout-icon"><IcMagnetWeak /></span>
<span className="tv-side-flyout-title" style={{ opacity: 0.5 }}> </span>
</button>
)}
<div className="tv-side-flyout-sep" />
<div className="tv-side-flyout-toggle-row">
<span className="tv-side-flyout-title"> </span>
<button
className={`tv-flyout-toggle ${snapToIndicator ? 'on' : ''}`}
onClick={() => onSnapToIndicator?.(!snapToIndicator)}
/>
</div>
</>
)}
{/* ── 눈 플라이아웃 ── */}
{flyoutKind === 'eye' && (
<>
<div className="tv-side-flyout-header">/</div>
<button className="tv-side-flyout-item" onClick={() => { onHideDrawings?.(); closeFlyout(); }}>
<span className="tv-side-flyout-icon"><IcEye /></span>
<span className="tv-side-flyout-title"> </span>
</button>
<button className="tv-side-flyout-item" onClick={() => { onHideIndicators?.(); closeFlyout(); }}>
<span className="tv-side-flyout-icon"><IcEye /></span>
<span className="tv-side-flyout-title"> </span>
</button>
<button className="tv-side-flyout-item" onClick={() => { onHideAll?.(); closeFlyout(); }}>
<span className="tv-side-flyout-icon"><IcEyeOff /></span>
<span className="tv-side-flyout-title"> </span>
</button>
</>
)}
{/* ── 삭제 플라이아웃 ── */}
{flyoutKind === 'trash' && (
<>
<div className="tv-side-flyout-header"></div>
<button
className="tv-side-flyout-item"
onClick={() => { onClearAll(); closeFlyout(); }}
>
<span className="tv-side-flyout-icon"><IcTrash /></span>
<span className="tv-side-flyout-title">{drawingCount} </span>
</button>
<button
className="tv-side-flyout-item"
onClick={() => { onClearIndicators?.(); closeFlyout(); }}
>
<span className="tv-side-flyout-icon"><IcLayers /></span>
<span className="tv-side-flyout-title">{indicatorCount} </span>
</button>
<button
className="tv-side-flyout-item"
onClick={() => { onClearAll(); onClearIndicators?.(); closeFlyout(); }}
>
<span className="tv-side-flyout-icon"><IcTrash /></span>
<span className="tv-side-flyout-title">{drawingCount} &amp; {indicatorCount} </span>
</button>
<div className="tv-side-flyout-sep" />
<div className="tv-side-flyout-toggle-row">
<span className="tv-side-flyout-title"> </span>
<button
className={`tv-flyout-toggle ${keepLockedOnDelete ? 'on' : ''}`}
onClick={() => onToggleKeepLocked?.()}
/>
</div>
</>
)}
</div>
)}
</div>
);
};
export default DrawingToolbar;
@@ -0,0 +1,359 @@
import React, { useState } from 'react';
import type { Drawing, FibTZSettings, FibTZLevel, FibTZBaseline, DrawingStyle } from '../types';
import { DEFAULT_FIB_TZ_SETTINGS } from '../types';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
const LINE_STYLES: { value: DrawingStyle; label: string }[] = [
{ value: 'solid', label: '━━' },
{ value: 'dashed', label: '╌╌' },
{ value: 'dotted', label: '···' },
];
const LINE_WIDTHS = [1, 2, 3, 4] as const;
// ─── Sub-components ──────────────────────────────────────────────────────────
function ColorCell({
color,
onChange,
}: {
color: string;
onChange: (c: string) => void;
}) {
return (
<label
title="색상 선택"
style={{
display: 'block',
width: 28,
height: 22,
borderRadius: 3,
background: color,
border: '1px solid rgba(255,255,255,0.2)',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
}}
>
<input
type="color"
value={color}
onChange={e => onChange(e.target.value)}
style={{ opacity: 0, position: 'absolute', inset: 0, width: '100%', height: '100%', cursor: 'pointer' }}
/>
</label>
);
}
function StyleSelect({
value,
onChange,
}: {
value: DrawingStyle;
onChange: (s: DrawingStyle) => void;
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value as DrawingStyle)}
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(122,162,247,0.25)',
borderRadius: 3,
color: 'inherit',
fontSize: 12,
padding: '2px 4px',
width: 54,
cursor: 'pointer',
}}
>
{LINE_STYLES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
);
}
function WidthSelect({
value,
onChange,
}: {
value: number;
onChange: (w: number) => void;
}) {
return (
<select
value={value}
onChange={e => onChange(Number(e.target.value))}
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(122,162,247,0.25)',
borderRadius: 3,
color: 'inherit',
fontSize: 12,
padding: '2px 4px',
width: 44,
cursor: 'pointer',
}}
>
{LINE_WIDTHS.map(w => (
<option key={w} value={w}>{w}</option>
))}
</select>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
drawing: Drawing;
onSave: (updated: Drawing) => void;
onCancel: () => void;
}
const FibTZSettingsModal: React.FC<Props> = ({ drawing, onSave, onCancel }) => {
const init = drawing.fibtzSettings ?? DEFAULT_FIB_TZ_SETTINGS;
const [baseline, setBaseline] = useState<FibTZBaseline>({ ...init.baseline });
const [levels, setLevels] = useState<FibTZLevel[]>(
init.levels.map(l => ({ ...l }))
);
const updateBaseline = (patch: Partial<FibTZBaseline>) =>
setBaseline(prev => ({ ...prev, ...patch }));
const updateLevel = (idx: number, patch: Partial<FibTZLevel>) =>
setLevels(prev => prev.map((l, i) => i === idx ? { ...l, ...patch } : l));
const handleReset = () => {
setBaseline({ ...DEFAULT_FIB_TZ_SETTINGS.baseline });
setLevels(DEFAULT_FIB_TZ_SETTINGS.levels.map(l => ({ ...l })));
};
const handleSave = () => {
const settings: FibTZSettings = { baseline, levels };
onSave({ ...drawing, fibtzSettings: settings });
};
const cellStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const headerStyle: React.CSSProperties = {
fontSize: 10,
color: 'rgba(192,202,245,0.55)',
textAlign: 'center',
paddingBottom: 6,
};
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div
className="dsm-overlay"
style={{ zIndex: 1200 }}
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
>
<div
ref={panelRef}
className="dsm-dialog"
style={{
...panelStyle,
width: 460,
maxHeight: '90vh',
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title"> </span>
<button type="button" className="gc-popup-close" onClick={onCancel} aria-label="닫기"></button>
</div>
<div className="dsm-body" style={{ color: 'var(--text, #c0caf5)' }}>
{/* ── 기준선 (0) ── */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'rgba(192,202,245,0.6)', marginBottom: 8, fontWeight: 600 }}>
( )
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '28px 28px 54px 44px',
gap: 8,
alignItems: 'center',
background: 'rgba(255,255,255,0.04)',
borderRadius: 5,
padding: '8px 10px',
}}
>
{/* 항상 활성화 표시 */}
<div style={{ ...cellStyle }}>
<div style={{
width: 14, height: 14, borderRadius: 3,
background: 'var(--accent, #7aa2f7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
<polyline points="1.5,4.5 3.5,7 7.5,2" stroke="#fff" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
</div>
<ColorCell color={baseline.color} onChange={c => updateBaseline({ color: c })} />
<StyleSelect value={baseline.style} onChange={s => updateBaseline({ style: s })} />
<WidthSelect value={baseline.lineWidth} onChange={w => updateBaseline({ lineWidth: w })} />
</div>
</div>
{/* ── 레벨 목록 ── */}
<div>
<div style={{ fontSize: 11, color: 'rgba(192,202,245,0.6)', marginBottom: 6, fontWeight: 600 }}>
</div>
{/* 헤더 행 */}
<div
style={{
display: 'grid',
gridTemplateColumns: '28px 60px 28px 54px 44px',
gap: 8,
padding: '0 10px',
marginBottom: 4,
}}
>
<div style={headerStyle}></div>
<div style={headerStyle}></div>
<div style={headerStyle}></div>
<div style={headerStyle}></div>
<div style={headerStyle}></div>
</div>
{/* 레벨 행들 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{levels.map((lv, idx) => (
<div
key={idx}
style={{
display: 'grid',
gridTemplateColumns: '28px 60px 28px 54px 44px',
gap: 8,
alignItems: 'center',
background: idx % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent',
borderRadius: 4,
padding: '5px 10px',
}}
>
{/* 표시 체크박스 */}
<div style={cellStyle}>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<input
type="checkbox"
checked={lv.enabled}
onChange={e => updateLevel(idx, { enabled: e.target.checked })}
style={{ display: 'none' }}
/>
<div style={{
width: 14, height: 14, borderRadius: 3,
background: lv.enabled ? 'var(--accent, #7aa2f7)' : 'rgba(255,255,255,0.1)',
border: lv.enabled ? 'none' : '1px solid rgba(255,255,255,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{lv.enabled && (
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
<polyline points="1.5,4.5 3.5,7 7.5,2" stroke="#fff" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)}
</div>
</label>
</div>
{/* 값 (편집 가능) */}
<input
type="number"
value={lv.value}
min={1}
onChange={e => updateLevel(idx, { value: Math.max(1, Number(e.target.value)) })}
style={{
width: '100%',
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(122,162,247,0.25)',
borderRadius: 3,
color: 'inherit',
fontSize: 12,
padding: '3px 5px',
textAlign: 'center',
}}
/>
{/* 색상 */}
<ColorCell color={lv.color} onChange={c => updateLevel(idx, { color: c })} />
{/* 스타일 */}
<StyleSelect value={lv.style} onChange={s => updateLevel(idx, { style: s })} />
{/* 두께 */}
<WidthSelect value={lv.lineWidth} onChange={w => updateLevel(idx, { lineWidth: w })} />
</div>
))}
</div>
</div>
{/* 버튼 영역 */}
<div className="dsm-footer" style={{ marginTop: 18 }}>
<button
onClick={handleReset}
style={{
padding: '6px 14px',
background: 'transparent',
border: '1px solid rgba(122,162,247,0.3)',
borderRadius: 5,
cursor: 'pointer',
color: 'inherit',
fontSize: 13,
}}
></button>
<button
onClick={onCancel}
style={{
padding: '6px 14px',
background: 'transparent',
border: '1px solid rgba(122,162,247,0.3)',
borderRadius: 5,
cursor: 'pointer',
color: 'inherit',
fontSize: 13,
}}
></button>
<button
onClick={handleSave}
style={{
padding: '6px 16px',
background: 'var(--accent, #7aa2f7)',
border: 'none',
borderRadius: 5,
cursor: 'pointer',
color: '#1a1b26',
fontWeight: 600,
fontSize: 13,
}}
></button>
</div>
</div>
</div>
</div>
);
};
export default FibTZSettingsModal;
@@ -0,0 +1,227 @@
/**
* 보조지표 일괄 설정 — Main 16종 + 전체 보조지표, 검색 필터
*/
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import {
buildAllIndicatorDraftMap,
applyAllDraftToChart,
allDraftListFromMap,
indicatorTypesOnChart,
type MainIndicatorDraftMap,
} from '../utils/indicatorMainConfig';
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
import {
getSettingsIndicatorTypes,
filterSettingsIndicatorTypes,
partitionFilteredTypes,
} from '../utils/indicatorSettingsList';
import IndicatorSettingsListSearch from './IndicatorSettingsListSearch';
import IndicatorSettingsListRow from './IndicatorSettingsListRow';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab';
export interface IndicatorBulkSettingsModalProps {
open: boolean;
indicators: IndicatorConfig[];
chartMarket?: string;
onApply: (result: {
chartIndicators: IndicatorConfig[];
allConfigs: IndicatorConfig[];
}) => void;
onLiveChartChange: (chartIndicators: IndicatorConfig[]) => void;
onClose: () => void;
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
getVisualConfig: (
type: string,
defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
}
const ALL_TYPES = getSettingsIndicatorTypes();
const IndicatorBulkSettingsModal: React.FC<IndicatorBulkSettingsModalProps> = ({
open,
indicators,
chartMarket = '',
onApply,
onLiveChartChange,
onClose,
getParams,
getVisualConfig,
}) => {
const [draftMap, setDraftMap] = useState<MainIndicatorDraftMap>({});
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const {
panelRef: dialogRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
const onChartTypes = useMemo(() => indicatorTypesOnChart(indicators), [indicators]);
const filtered = useMemo(
() => filterSettingsIndicatorTypes(ALL_TYPES, search),
[search],
);
const { main: filteredMain, other: filteredOther } = useMemo(
() => partitionFilteredTypes(filtered),
[filtered],
);
const prevOpenRef = useRef(false);
useEffect(() => {
if (open && !prevOpenRef.current) {
setDraftMap(buildAllIndicatorDraftMap(indicators, getParams, getVisualConfig));
setExpandedTypes(new Set());
setSearch('');
}
prevOpenRef.current = open;
}, [open, indicators, getParams, getVisualConfig]);
const setTypeEnabled = useCallback((type: string, enabled: boolean) => {
const next = new Set(onChartTypes);
if (enabled) next.add(type);
else next.delete(type);
onLiveChartChange(applyAllDraftToChart(indicators, draftMap, next));
}, [onChartTypes, indicators, draftMap, onLiveChartChange]);
const updateType = useCallback((type: string, updated: IndicatorConfig) => {
setDraftMap(prev => ({ ...prev, [type]: updated }));
}, []);
const toggleExpand = useCallback((type: string) => {
setExpandedTypes(prev => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
const handleResetAll = useCallback(() => {
if (!window.confirm('목록에 있는 모든 보조지표 설정을 기본값으로 되돌릴까요?')) return;
setDraftMap(prev => {
const next = { ...prev };
for (const type of ALL_TYPES) {
const cfg = next[type];
if (!cfg) continue;
const reset = resetConfigToDefaults(cfg);
const active = indicators.find(i => i.type === type);
next[type] = {
...reset,
id: onChartTypes.has(type) && active ? active.id : `template_${type}`,
};
}
return next;
});
}, [indicators, onChartTypes]);
const handleApply = useCallback(() => {
const allConfigs = allDraftListFromMap(draftMap);
const chartIndicators = applyAllDraftToChart(indicators, draftMap, onChartTypes);
onApply({ chartIndicators, allConfigs });
onClose();
}, [draftMap, onChartTypes, indicators, onApply, onClose]);
const renderRows = (types: string[]) => types.map(type => {
const cfg = draftMap[type];
if (!cfg) return null;
return (
<IndicatorSettingsListRow
key={type}
type={type}
config={cfg}
enabled={onChartTypes.has(type)}
chartMarket={chartMarket}
expanded={expandedTypes.has(type)}
variant="bulk"
onToggleExpand={() => toggleExpand(type)}
onToggleEnabled={on => setTypeEnabled(type, on)}
onChange={updated => updateType(type, updated)}
/>
);
});
if (!open) return null;
return (
<div className="ibsm-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div
ref={dialogRef}
className="ibsm-dialog ibsm-dialog-wide"
onMouseDown={e => e.stopPropagation()}
style={{
...panelStyle,
zIndex: 5001,
cursor: dragging ? 'grabbing' : undefined,
}}
>
<div
className="gc-popup-header ibsm-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title ibsm-header-title">📊 </span>
<div className="ibsm-header-actions">
<button type="button" className="ibsm-icon-btn" onClick={handleResetAll} title="전체 기본값"></button>
<button type="button" className="gc-popup-close ibsm-icon-btn" onClick={onClose} title="닫기"></button>
</div>
</div>
<IndicatorSettingsListSearch
value={search}
onChange={setSearch}
totalCount={ALL_TYPES.length}
filteredCount={filtered.length}
className="ibsm-search-block"
/>
<p className="ibsm-hint">
<strong>Main</strong> {MAIN_INDICATOR_TYPES.length} . ·,
· . <strong></strong> DB .
</p>
<div className="ibsm-body">
{filtered.length === 0 ? (
<div className="no-results ind-settings-no-results">
&apos;{search}&apos;
</div>
) : (
<>
{filteredMain.length > 0 && (
<section className="ind-settings-section">
<h3 className="ind-settings-section-title">
Main <span className="ind-settings-section-count">{filteredMain.length}</span>
</h3>
{renderRows(filteredMain)}
</section>
)}
{filteredOther.length > 0 && (
<section className="ind-settings-section">
<h3 className="ind-settings-section-title">
<span className="ind-settings-section-count">{filteredOther.length}</span>
</h3>
{renderRows(filteredOther)}
</section>
)}
</>
)}
</div>
<div className="ibsm-footer">
<button type="button" className="ism-btn-cancel" onClick={onClose}></button>
<button type="button" className="ism-btn-ok" onClick={handleApply}></button>
</div>
</div>
</div>
);
};
export default IndicatorBulkSettingsModal;
@@ -0,0 +1,154 @@
import React, { useEffect, useRef } from 'react';
import type { IndicatorConfig } from '../types';
import { getIndicatorChartTitle } from '../utils/indicatorRegistry';
import { formatBbLegendLabel } from '../utils/bollingerConfig';
interface IndicatorContextToolbarProps {
indicatorId: string;
config: IndicatorConfig | null;
chartType?: string;
screenX: number;
screenY: number;
onSettings: () => void;
onToggleHidden: () => void;
onRemove: () => void;
onClose: () => void;
/** 툴바 자체에 마우스 진입 시 hide 타이머 취소 */
onMouseEnterToolbar?: () => void;
/** 툴바에서 마우스 이탈 시 hide 타이머 재시작 */
onMouseLeaveToolbar?: () => void;
}
/* SVG Icons */
const IcEye = ({ hidden }: { hidden: boolean }) => hidden ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z" strokeOpacity="0.35"/>
<line x1="2" y1="2" x2="12" y2="12"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z"/>
<circle cx="7" cy="7" r="2"/>
</svg>
);
const IcGear = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
<circle cx="7" cy="7" r="2.3"/>
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
</svg>
);
const IcTrash = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
<line x1="2" y1="4.5" x2="12" y2="4.5"/>
<path d="M5 4.5 V3 Q5 2 7 2 Q9 2 9 3 V4.5"/>
<path d="M3 4.5 L3.5 12 Q3.5 12.5 4 12.5 L10 12.5 Q10.5 12.5 10.5 12 L11 4.5"/>
</svg>
);
const IcMore = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<circle cx="2.5" cy="7" r="1.3"/>
<circle cx="7" cy="7" r="1.3"/>
<circle cx="11.5"cy="7" r="1.3"/>
</svg>
);
/** 인디케이터 파라미터 요약 문자열 (예: "RSI (14)") */
function paramSummary(config: IndicatorConfig): string {
if (config.type === 'BollingerBands') {
return formatBbLegendLabel(config.params ?? {});
}
const name = getIndicatorChartTitle(config.type);
const nums = Object.values(config.params)
.filter(v => typeof v === 'number')
.slice(0, 2)
.join(', ');
return nums ? `${name} (${nums})` : name;
}
const IndicatorContextToolbar: React.FC<IndicatorContextToolbarProps> = ({
indicatorId, config, chartType, screenX, screenY,
onSettings, onToggleHidden, onRemove, onClose,
onMouseEnterToolbar, onMouseLeaveToolbar,
}) => {
const ref = useRef<HTMLDivElement>(null);
// hover 모드(onMouseEnterToolbar 존재)에선 hover leave 로만 닫힘
// 클릭 모드에선 외부 클릭 시 닫기
useEffect(() => {
if (onMouseEnterToolbar) return; // hover 모드 → 외부 클릭 핸들러 불필요
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
const tid = setTimeout(() => document.addEventListener('mousedown', handler), 100);
return () => { clearTimeout(tid); document.removeEventListener('mousedown', handler); };
}, [onClose, onMouseEnterToolbar]);
// pane 좌측 상단 기준 (screenX = pane left, screenY = pane top)
const W = 240;
const isMain = indicatorId === '__main__';
/** 메인 캔들 pane 상단 tv-legend(OHLCV) 행 아래에 배치 */
const TOP_GAP_MAIN = 38;
let left = screenX + 8;
let top = screenY + (isMain ? TOP_GAP_MAIN : 8);
// 화면 오른쪽 경계 보정
if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
const isHidden = config?.hidden === true;
const label = isMain
? (chartType === 'candlestick' ? '캔들스틱' : chartType ?? '메인 차트')
: (config ? paramSummary(config) : indicatorId);
return (
<div
ref={ref}
className="ind-ctx-toolbar"
style={{ left, top }}
onMouseDown={e => e.stopPropagation()}
onMouseEnter={onMouseEnterToolbar}
onMouseLeave={onMouseLeaveToolbar}
>
{/* 이름 */}
<span className="ind-ctx-name" title={label}>{label}</span>
{/* 가시성 토글 */}
{!isMain && (
<button
className="ind-ctx-btn"
title={isHidden ? '표시' : '숨기기'}
onClick={onToggleHidden}
>
<IcEye hidden={isHidden} />
</button>
)}
{/* 설정 */}
<button
className="ind-ctx-btn"
title="설정 (더블클릭)"
onClick={onSettings}
>
<IcGear />
</button>
{/* 삭제 */}
{!isMain && (
<button
className="ind-ctx-btn ind-ctx-del"
title="삭제"
onClick={onRemove}
>
<IcTrash />
</button>
)}
{/* 더보기 */}
<button className="ind-ctx-btn" title="더보기">
<IcMore />
</button>
</div>
);
};
export default IndicatorContextToolbar;
@@ -0,0 +1,197 @@
/**
* 설정 화면 — 보조지표 기본값 (Main 16종 상단 + 전체 지표, 검색 필터)
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import { buildMainIndicatorConfig } from '../utils/indicatorMainConfig';
import { indicatorTypesOnChart } from '../utils/indicatorMainConfig';
import IndicatorSettingsForm from './IndicatorSettingsForm';
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
import {
getSettingsIndicatorTypes,
filterSettingsIndicatorTypes,
partitionFilteredTypes,
} from '../utils/indicatorSettingsList';
import IndicatorSettingsListSearch from './IndicatorSettingsListSearch';
import IndicatorSettingsListRow from './IndicatorSettingsListRow';
import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab';
export interface IndicatorMainDefaultsPanelProps {
chartIndicators: IndicatorConfig[];
onAddToChart: (type: string, config?: IndicatorConfig) => void;
onRemoveFromChart: (type: string) => void;
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
saveParams: (type: string, params: Record<string, number | string | boolean>) => void;
getVisualConfig: (
type: string,
defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
saveVisual: (
type: string,
plots?: PlotDef[],
hlines?: HLineDef[],
cloudColors?: IchimokuCloudColors,
) => void;
}
function persistDefaults(
updated: IndicatorConfig,
saveParams: IndicatorMainDefaultsPanelProps['saveParams'],
saveVisual: IndicatorMainDefaultsPanelProps['saveVisual'],
) {
saveParams(updated.type, updated.params);
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
}
const ALL_TYPES = getSettingsIndicatorTypes();
const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
chartIndicators,
onAddToChart,
onRemoveFromChart,
getParams,
saveParams,
getVisualConfig,
saveVisual,
}) => {
const onChartTypes = useMemo(() => indicatorTypesOnChart(chartIndicators), [chartIndicators]);
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(() => new Set());
const [configs, setConfigs] = useState<Record<string, IndicatorConfig>>({});
const [search, setSearch] = useState('');
const filtered = useMemo(
() => filterSettingsIndicatorTypes(ALL_TYPES, search),
[search],
);
const { main: filteredMain, other: filteredOther } = useMemo(
() => partitionFilteredTypes(filtered),
[filtered],
);
useEffect(() => {
const next: Record<string, IndicatorConfig> = {};
for (const type of ALL_TYPES) {
const cfg = buildMainIndicatorConfig(type, chartIndicators, getParams, getVisualConfig);
if (cfg) next[type] = cfg;
}
setConfigs(next);
}, [chartIndicators, getParams, getVisualConfig]);
const toggleExpand = useCallback((type: string) => {
setExpandedTypes(prev => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
const handleConfigChange = useCallback((type: string, updated: IndicatorConfig) => {
setConfigs(prev => ({ ...prev, [type]: updated }));
}, []);
const handleToggleChart = useCallback((type: string, enabled: boolean) => {
if (enabled) {
onAddToChart(type, configs[type]);
} else {
onRemoveFromChart(type);
}
}, [configs, onAddToChart, onRemoveFromChart]);
const handleRowDefaults = useCallback((type: string) => {
const base = configs[type];
if (!base) return;
const reset = resetConfigToDefaults(base);
persistDefaults(reset, saveParams, saveVisual);
handleConfigChange(type, reset);
}, [configs, saveParams, saveVisual, handleConfigChange]);
const handleResetAll = useCallback(() => {
if (!window.confirm('목록에 있는 모든 보조지표 기본값을 초기화할까요?')) return;
const next: Record<string, IndicatorConfig> = {};
for (const type of ALL_TYPES) {
const base = configs[type] ?? buildMainIndicatorConfig(type, [], getParams, getVisualConfig);
if (!base) continue;
const reset = resetConfigToDefaults(base);
next[type] = reset;
persistDefaults(reset, saveParams, saveVisual);
}
setConfigs(next);
}, [configs, getParams, getVisualConfig, saveParams, saveVisual]);
const renderRows = (types: string[]) => types.map(type => {
const cfg = configs[type];
if (!cfg) return null;
return (
<IndicatorSettingsListRow
key={type}
type={type}
config={cfg}
enabled={onChartTypes.has(type)}
expanded={expandedTypes.has(type)}
variant="settings"
onToggleExpand={() => toggleExpand(type)}
onToggleEnabled={on => handleToggleChart(type, on)}
onChange={updated => {
persistDefaults(updated, saveParams, saveVisual);
handleConfigChange(type, updated);
}}
onRowDefaults={() => handleRowDefaults(type)}
/>
);
});
return (
<>
<div className="stg-ind-intro">
<p>
<strong>Main</strong> {MAIN_INDICATOR_TYPES.length} , .
· , · DB .
</p>
<button type="button" className="stg-ind-reset-all" onClick={handleResetAll}>
</button>
</div>
<IndicatorSettingsListSearch
value={search}
onChange={setSearch}
totalCount={ALL_TYPES.length}
filteredCount={filtered.length}
className="stg-ind-search-block"
/>
<div className="stg-ind-list">
{filtered.length === 0 ? (
<div className="no-results ind-settings-no-results">
&apos;{search}&apos;
</div>
) : (
<>
{filteredMain.length > 0 && (
<section className="ind-settings-section">
<h3 className="ind-settings-section-title">
Main <span className="ind-settings-section-count">{filteredMain.length}</span>
</h3>
{renderRows(filteredMain)}
</section>
)}
{filteredOther.length > 0 && (
<section className="ind-settings-section">
<h3 className="ind-settings-section-title">
<span className="ind-settings-section-count">{filteredOther.length}</span>
</h3>
{renderRows(filteredOther)}
</section>
)}
</>
)}
</div>
</>
);
};
export default IndicatorMainDefaultsPanel;
+191
View File
@@ -0,0 +1,191 @@
import React, { useState, useMemo } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { INDICATOR_REGISTRY, type IndicatorDef, type IndicatorCategory } from '../utils/indicatorRegistry';
import type { IndicatorConfig } from '../types';
import { getNumericParamSpec } from '../utils/indicatorParamSpec';
import NumericParamInput from './NumericParamInput';
interface IndicatorModalProps {
activeIndicators: IndicatorConfig[];
onAdd: (def: IndicatorDef, params: Record<string, number | string | boolean>) => void;
onRemove: (id: string) => void;
onClose: () => void;
}
const CATEGORIES: IndicatorCategory[] = [
'Moving Averages', 'Channels & Bands', 'Oscillators', 'Momentum',
'Trend', 'Volatility', 'Volume', 'Candlestick Patterns',
];
const IndicatorModal: React.FC<IndicatorModalProps> = ({ activeIndicators, onAdd, onRemove, onClose }) => {
const [search, setSearch] = useState('');
const [category, setCategory] = useState<IndicatorCategory | 'All'>('All');
const [selected, setSelected] = useState<IndicatorDef | null>(null);
const [params, setParams] = useState<Record<string, number | string | boolean>>({});
const filtered = useMemo(() => {
return INDICATOR_REGISTRY.filter(d => {
const matchCat = category === 'All' || d.category === category;
const matchQ = !search || d.name.toLowerCase().includes(search.toLowerCase()) || d.shortName.toLowerCase().includes(search.toLowerCase());
return matchCat && matchQ;
});
}, [search, category]);
const select = (def: IndicatorDef) => {
setSelected(def);
setParams({ ...def.defaultParams });
};
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
const handleAdd = () => {
if (!selected) return;
onAdd(selected, params);
setSelected(null);
};
const handleRemoveAll = (type: string) => {
activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id));
};
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div className="modal-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div
ref={panelRef}
className="indicator-modal"
style={{
...panelStyle,
zIndex: 5001,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header modal-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title modal-title">📈 </span>
<span className="modal-subtitle">{INDICATOR_REGISTRY.length} </span>
<button type="button" className="gc-popup-close modal-close" onClick={onClose}></button>
</div>
<div className="modal-search">
<input
className="modal-search-input"
placeholder="지표 검색..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="modal-cats">
{(['All', ...CATEGORIES] as Array<'All' | IndicatorCategory>).map(c => (
<button
key={c}
className={`cat-btn ${category === c ? 'active' : ''}`}
onClick={() => setCategory(c)}
>{c === 'All' ? `전체 (${INDICATOR_REGISTRY.length})` : c} {c !== 'All' && `(${INDICATOR_REGISTRY.filter(d => d.category === c).length})`}</button>
))}
</div>
<div className="modal-body">
<div className="indicator-list">
{filtered.map(def => (
<div
key={def.type}
className={`indicator-item ${selected?.type === def.type ? 'selected' : ''} ${isActive(def.type) ? 'in-use' : ''}`}
onClick={() => select(def)}
>
<div className="indicator-item-left">
<span className="indicator-name">{def.name}</span>
<span className="indicator-desc">{def.description}</span>
</div>
<div className="indicator-item-right">
{def.overlay && <span className="badge overlay"></span>}
{def.returnsMarkers && <span className="badge markers"></span>}
{isActive(def.type) ? (
<button className="rm-btn" onClick={e => { e.stopPropagation(); handleRemoveAll(def.type); }}></button>
) : (
<button className="add-btn" onClick={e => { e.stopPropagation(); select(def); onAdd(def, { ...def.defaultParams }); }}>+</button>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="no-results">'{search}' </div>
)}
</div>
{selected && (
<div className="indicator-settings">
<div className="settings-title">{selected.name} </div>
<div className="settings-desc">{selected.description}</div>
{Object.entries(params).map(([key, val]) => (
<div key={key} className="param-row">
<label className="param-label">{key}</label>
{typeof val === 'boolean' ? (
<input type="checkbox" checked={val as boolean} onChange={e => setParams(p => ({ ...p, [key]: e.target.checked }))} />
) : typeof val === 'number' ? (
<NumericParamInput
className="param-input"
value={val as number}
spec={getNumericParamSpec(selected.type, key, val as number)}
onChange={v => setParams(p => ({ ...p, [key]: v }))}
/>
) : (
<input
className="param-input"
type="text"
value={val as string}
onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
/>
)}
</div>
))}
<div className="settings-actions">
<button className="settings-add-btn" onClick={handleAdd}> </button>
<button className="settings-cancel" onClick={() => setSelected(null)}></button>
</div>
{selected.plots.length > 0 && (
<div className="plot-preview">
{selected.plots.map(p => (
<div key={p.id} className="plot-legend-row">
<span className="plot-color" style={{ background: p.color }} />
<span className="plot-title">{p.title}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{activeIndicators.length > 0 && (
<div className="modal-active">
<div className="active-title"> ({activeIndicators.length})</div>
<div className="active-list">
{activeIndicators.map(a => (
<span key={a.id} className="active-tag">
{a.type}
<button onClick={() => onRemove(a.id)}></button>
</span>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default IndicatorModal;
@@ -0,0 +1,368 @@
/**
* 보조지표 설정 폼 — 개별 설정 모달·일괄 설정 팝업에서 동일 UI/로직 공유
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { IndicatorConfig, Timeframe } from '../types';
import { getIndicatorDef } from '../utils/indicatorRegistry';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import { getPlotLabel } from '../utils/indicatorLabels';
import { getNumericParamSpec } from '../utils/indicatorParamSpec';
import NumericParamInput from './NumericParamInput';
import SmaPeriodInput from './SmaPeriodInput';
import {
SMA_DEFAULT_SRC,
SMA_MA_COUNT,
smaPeriodKey,
smaPlotId,
} from '../utils/smaConfig';
import PlotLineStylePicker from './PlotLineStylePicker';
import {
GlobalParamRow,
IchimokuCloudSection,
BollingerSymbolSection,
BollingerBackgroundRow,
SettingsOutputFooter,
UnifiedIndicatorBody,
} from './IndicatorSettingsSections';
import {
createEditorSnapshot,
configFromEditorSnapshot,
resetConfigToDefaults,
type IndicatorSettingsEditorSnapshot,
} from '../utils/indicatorSettingsEditor';
export const ALL_TIMEFRAMES: { tf: Timeframe; label: string }[] = [
{ tf: '1m', label: '1분' },
{ tf: '3m', label: '3분' },
{ tf: '5m', label: '5분' },
{ tf: '15m', label: '15분' },
{ tf: '30m', label: '30분' },
{ tf: '1h', label: '1시간' },
{ tf: '4h', label: '4시간' },
{ tf: '1D', label: '일봉' },
{ tf: '1W', label: '주봉' },
{ tf: '1M', label: '월봉' },
];
export interface IndicatorSettingsFormProps {
config: IndicatorConfig;
chartMarket?: string;
/** embedded: 일괄 설정 카드 안 */
variant?: 'modal' | 'embedded';
/**
* modal: 편집 중 부모 setState 없이 ref만 갱신 (포커스 유지).
* embedded: 변경 즉시 onChange 호출.
*/
deferParentSync?: boolean;
/** @deprecated 탭 제거 — 통합 화면만 사용 */
inputsOnly?: boolean;
/** 개별 설정 모달: 지표 전체 표시 여부 */
chartHidden?: boolean;
onChartHiddenChange?: (showOnChart: boolean) => void;
onChange: (updated: IndicatorConfig) => void;
}
function configFingerprint(c: IndicatorConfig): string {
return JSON.stringify({
id: c.id,
params: c.params,
plots: c.plots,
plotVisibility: c.plotVisibility,
hlines: c.hlines,
hlinesBackground: c.hlinesBackground,
bandBackground: c.bandBackground,
cloudColors: c.cloudColors,
lastValueVisible: c.lastValueVisible,
timeframeVisibility: c.timeframeVisibility,
hidden: c.hidden,
});
}
const IndicatorSettingsForm: React.FC<IndicatorSettingsFormProps> = ({
config,
chartMarket = '',
variant = 'modal',
deferParentSync = variant === 'modal',
inputsOnly = false,
chartHidden = false,
onChartHiddenChange,
onChange,
}) => {
const def = getIndicatorDef(config.type);
const isSma = config.type === 'SMA';
const isIchimoku = config.type === 'IchimokuCloud';
const isBollinger = config.type === 'BollingerBands';
const [snap, setSnap] = useState(() => createEditorSnapshot(config));
const baseRef = useRef(config);
const fpRef = useRef(configFingerprint(config));
const onChangeRef = useRef(onChange);
const internalEmitRef = useRef(false);
onChangeRef.current = onChange;
const emitDraft = useCallback((next: IndicatorSettingsEditorSnapshot) => {
const updated = configFromEditorSnapshot(baseRef.current, next);
baseRef.current = updated;
if (deferParentSync) {
onChangeRef.current(updated);
return;
}
internalEmitRef.current = true;
onChangeRef.current(updated);
}, [deferParentSync]);
const scheduleDraftEmit = useCallback((next: IndicatorSettingsEditorSnapshot) => {
if (deferParentSync) {
emitDraft(next);
return;
}
emitDraft(next);
}, [deferParentSync, emitDraft]);
useEffect(() => {
if (deferParentSync) return;
if (internalEmitRef.current) {
internalEmitRef.current = false;
fpRef.current = configFingerprint(config);
baseRef.current = config;
return;
}
const fp = configFingerprint(config);
if (fp !== fpRef.current) {
fpRef.current = fp;
baseRef.current = config;
setSnap(createEditorSnapshot(config));
}
}, [config, deferParentSync]);
const emit = useCallback((next: IndicatorSettingsEditorSnapshot) => {
setSnap(next);
scheduleDraftEmit(next);
}, [scheduleDraftEmit]);
const patch = useCallback((partial: Partial<IndicatorSettingsEditorSnapshot>) => {
setSnap(prev => {
const next = { ...prev, ...partial };
scheduleDraftEmit(next);
return next;
});
}, [scheduleDraftEmit]);
const { params, plots, plotVis, hlines, hlinesBg, lastValueVisible, timeframeVis, cloudColors, bandBg } = snap;
const hasHLines = (def?.hlines ?? []).length > 0 || hlines.length > 0;
const handleHLineVisible = useCallback((idx: number, visible: boolean) =>
patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, visible } : h) }), [hlines, patch]);
const handleHLinePrice = useCallback((idx: number, price: number) =>
patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, price } : h) }), [hlines, patch]);
const handleHLinePlotStyle = useCallback((idx: number, p: Partial<HLineDef>) =>
patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, ...p } : h) }), [hlines, patch]);
const handleParamChange = useCallback((key: string, raw: string | boolean) => {
const oldVal = params[key];
const defVal = def?.defaultParams?.[key];
let nextParams: Record<string, number | string | boolean>;
if (typeof oldVal === 'number' || typeof defVal === 'number') {
const n = parseFloat(raw as string);
const fallback = typeof oldVal === 'number' ? oldVal : (defVal as number);
nextParams = { ...params, [key]: isNaN(n) ? fallback : n };
} else if (typeof oldVal === 'boolean' || typeof defVal === 'boolean') {
nextParams = { ...params, [key]: raw as boolean };
} else {
nextParams = { ...params, [key]: raw as string };
}
let nextPlotVis = plotVis;
if (key === 'maType' && (config.type === 'CCI' || config.type === 'RSI')) {
nextPlotVis = { ...plotVis, plot1: raw !== 'None' };
}
patch({ params: nextParams, plotVis: nextPlotVis });
}, [params, plotVis, def, config.type, patch]);
const handlePlotStyle = useCallback((idx: number, p: Partial<PlotDef>) => {
patch({ plots: plots.map((pl, i) => i === idx ? { ...pl, ...p } : pl) });
}, [plots, patch]);
const handlePlotToggle = useCallback((plotId: string, enabled: boolean) => {
patch({ plotVis: { ...plotVis, [plotId]: enabled } });
}, [plotVis, patch]);
const handleSmaPeriod = useCallback((maIndex: number, v: number) => {
patch({ params: { ...params, [smaPeriodKey(maIndex)]: v } });
}, [params, patch]);
const handleSmaToggle = useCallback((plotIndex: number, enabled: boolean) => {
patch({ plotVis: { ...plotVis, [smaPlotId(plotIndex)]: enabled } });
}, [plotVis, patch]);
const handleDefaults = useCallback(() => {
const reset = resetConfigToDefaults(config);
baseRef.current = reset;
fpRef.current = configFingerprint(reset);
const nextSnap = createEditorSnapshot(reset);
setSnap(nextSnap);
onChange(reset);
}, [config, onChange]);
const footerProps = {
lastValueVisible,
onLastValueVisible: (v: boolean) => patch({ lastValueVisible: v }),
timeframeVis,
onTimeframeVis: (tf: Timeframe, v: boolean) =>
patch({ timeframeVis: { ...timeframeVis, [tf]: v } }),
timeframes: ALL_TIMEFRAMES,
chartHidden,
onChartHidden: onChartHiddenChange,
};
const sectionClass = variant === 'embedded'
? 'ism-section unified-settings-section ibsm-embedded-form'
: 'ism-section unified-settings-section';
return (
<div className={sectionClass}>
{variant === 'embedded' && (
<div className="ibsm-form-toolbar">
<button type="button" className="ibsm-form-defaults" onClick={handleDefaults}>
</button>
</div>
)}
{isSma && (
<div className="sma-inputs-section">
<GlobalParamRow
indicatorType="SMA"
paramKey="src"
value={String(params.src ?? SMA_DEFAULT_SRC)}
onChange={(_, raw) => patch({ params: { ...params, src: raw as string } })}
/>
<div className="ism-sma-divider" />
{Array.from({ length: SMA_MA_COUNT }, (_, i) => {
const maNum = i + 1;
const plotId = smaPlotId(i);
const periodKey = smaPeriodKey(maNum);
const enabled = plotVis[plotId] !== false;
const plot = plots[i];
return (
<div key={plotId} className={`ism-row ism-sma-ma-row${enabled ? '' : ' ism-sma-ma-off'}`}>
<label className="ism-toggle ism-sma-ma-toggle">
<input
type="checkbox"
checked={enabled}
onChange={e => handleSmaToggle(i, e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
<label className="ism-label ism-sma-ma-label">{getPlotLabel(`MA${maNum}`)}</label>
<div
className="ism-control ism-sma-ma-control"
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<SmaPeriodInput
value={Number(params[periodKey] ?? 1)}
disabled={!enabled}
onCommit={v => handleSmaPeriod(maNum, v)}
/>
{plot && !inputsOnly && (
<PlotLineStylePicker
disabled={!enabled}
title={`MA${maNum}`}
value={{
color: plot.color,
lineWidth: plot.lineWidth,
lineStyle: plot.lineStyle,
}}
onChange={p => handlePlotStyle(i, p)}
/>
)}
</div>
</div>
);
})}
{!inputsOnly && <SettingsOutputFooter {...footerProps} />}
</div>
)}
{isIchimoku && (
<UnifiedIndicatorBody
config={config}
params={params}
plots={plots}
plotVis={plotVis}
hlines={[]}
hlinesBg={hlinesBg}
hasHLines={false}
lastValueVisible={lastValueVisible}
timeframeVis={timeframeVis}
timeframes={ALL_TIMEFRAMES}
onParamChange={handleParamChange}
onPlotToggle={handlePlotToggle}
onPlotStyle={handlePlotStyle}
onHLineVisible={handleHLineVisible}
onHLinePrice={handleHLinePrice}
onHLineStyle={handleHLinePlotStyle}
onHlinesBg={v => patch({ hlinesBg: v })}
onLastValueVisible={v => patch({ lastValueVisible: v })}
onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })}
chartHidden={chartHidden}
onChartHidden={onChartHiddenChange}
cloudSection={inputsOnly ? undefined : (
<IchimokuCloudSection
cloudColors={cloudColors}
onChange={p => patch({ cloudColors: { ...cloudColors, ...p } })}
/>
)}
inputsOnly={inputsOnly}
showOutputFooter={!inputsOnly}
/>
)}
{!isSma && !isIchimoku && (
<UnifiedIndicatorBody
config={config}
params={params}
plots={plots}
plotVis={plotVis}
hlines={hlines}
hlinesBg={hlinesBg}
hasHLines={hasHLines}
lastValueVisible={lastValueVisible}
timeframeVis={timeframeVis}
timeframes={ALL_TIMEFRAMES}
onParamChange={handleParamChange}
onPlotToggle={handlePlotToggle}
onPlotStyle={handlePlotStyle}
onHLineVisible={handleHLineVisible}
onHLinePrice={handleHLinePrice}
onHLineStyle={handleHLinePlotStyle}
onHlinesBg={v => patch({ hlinesBg: v })}
onLastValueVisible={v => patch({ lastValueVisible: v })}
onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })}
chartHidden={chartHidden}
onChartHidden={onChartHiddenChange}
inputsOnly={inputsOnly}
showOutputFooter={!inputsOnly && !isBollinger}
symbolSection={isBollinger ? (
<BollingerSymbolSection
symbolMode={String(params.symbolMode ?? 'main')}
refSymbol={String(params.refSymbol ?? '')}
chartMarket={chartMarket}
onChange={p => {
const next = { ...params };
if (p.symbolMode !== undefined) next.symbolMode = p.symbolMode;
if (p.refSymbol !== undefined) next.refSymbol = p.refSymbol;
patch({ params: next });
}}
/>
) : undefined}
afterPlotsSection={isBollinger ? (
<BollingerBackgroundRow value={bandBg} onChange={v => patch({ bandBg: v })} />
) : undefined}
/>
)}
</div>
);
};
export default IndicatorSettingsForm;
@@ -0,0 +1,108 @@
import React from 'react';
import type { IndicatorConfig } from '../types';
import { getIndicatorDef } from '../utils/indicatorRegistry';
import { getIndicatorListLabels } from '../utils/indicatorSettingsList';
import IndicatorSettingsForm from './IndicatorSettingsForm';
export interface IndicatorSettingsListRowProps {
type: string;
config: IndicatorConfig;
enabled: boolean;
chartMarket?: string;
expanded: boolean;
variant: 'bulk' | 'settings';
onToggleExpand: () => void;
onToggleEnabled: (enabled: boolean) => void;
onChange: (updated: IndicatorConfig) => void;
onRowDefaults?: () => void;
}
const IndicatorSettingsListRow: React.FC<IndicatorSettingsListRowProps> = ({
type,
config,
enabled,
chartMarket = '',
expanded,
variant,
onToggleExpand,
onToggleEnabled,
onChange,
onRowDefaults,
}) => {
const labels = getIndicatorListLabels(type);
const def = getIndicatorDef(type);
const cardClass =
variant === 'bulk'
? `ibsm-card ibsm-main-row${enabled ? ' ibsm-main-row--active' : ''}${expanded ? ' ibsm-card-expanded' : ''}`
: `stg-ind-card${enabled ? ' stg-ind-card--on-chart' : ''}${expanded ? ' stg-ind-card--expanded' : ''}`;
const headClass = variant === 'bulk' ? 'ibsm-card-main ibsm-main-row-head' : 'stg-ind-card-head';
const infoClass = variant === 'bulk' ? 'ibsm-main-row-info' : 'stg-ind-card-titles';
const koClass = variant === 'bulk' ? 'ibsm-main-row-ko' : 'stg-ind-card-name';
const enClass = variant === 'bulk' ? 'ibsm-main-row-en' : 'stg-ind-card-sub';
const badgesClass = variant === 'bulk' ? 'ibsm-main-row-badges' : 'stg-ind-card-badges';
const onBadgeClass = variant === 'bulk' ? 'ibsm-main-badge ibsm-main-badge--on' : 'stg-ind-badge stg-ind-badge--on';
const offBadgeClass = variant === 'bulk' ? 'ibsm-main-badge' : 'stg-ind-badge';
const expandClass = variant === 'bulk' ? 'ibsm-expand' : 'stg-ind-card-expand';
const expandWrapClass = variant === 'bulk' ? 'ibsm-card-expand' : 'stg-ind-card-body';
return (
<div className={cardClass}>
<div className={headClass}>
<div className={infoClass}>
<span className={koClass}>{labels.ko}</span>
<span className={enClass}>{labels.en}</span>
</div>
<div className={badgesClass}>
{def?.overlay && <span className="ind-panel-badge"></span>}
{def?.returnsMarkers && <span className="ind-panel-badge"></span>}
{enabled ? (
<span className={onBadgeClass}> </span>
) : (
<span className={offBadgeClass}></span>
)}
</div>
<label
className={`ibsm-toggle${variant === 'settings' ? ' stg-ind-toggle' : ''}`}
title={enabled ? '차트에서 제거' : '차트에 추가'}
onClick={e => e.stopPropagation()}
>
<input
type="checkbox"
checked={enabled}
onChange={e => onToggleEnabled(e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
{variant === 'settings' && onRowDefaults && (
<button type="button" className="stg-ind-card-defaults" onClick={onRowDefaults}>
</button>
)}
<button
type="button"
className={expandClass}
onClick={onToggleExpand}
aria-expanded={expanded}
title={expanded ? '접기' : '설정 펼치기'}
>
{expanded ? '▲' : '▼'}
</button>
</div>
{expanded && (
<div className={expandWrapClass}>
<IndicatorSettingsForm
config={config}
chartMarket={chartMarket}
variant="embedded"
onChange={onChange}
/>
</div>
)}
</div>
);
};
export default IndicatorSettingsListRow;
@@ -0,0 +1,36 @@
import React from 'react';
export interface IndicatorSettingsListSearchProps {
value: string;
onChange: (value: string) => void;
totalCount: number;
filteredCount: number;
className?: string;
}
/** 보조지표 설정 목록 상단 검색 (지표 추가 팝업과 동일 UX) */
const IndicatorSettingsListSearch: React.FC<IndicatorSettingsListSearchProps> = ({
value,
onChange,
totalCount,
filteredCount,
className = '',
}) => (
<div className={`ind-settings-search-wrap ${className}`.trim()}>
<input
type="search"
className="modal-search-input ind-settings-search-input"
placeholder="지표 검색... (이름·한글명·약어)"
value={value}
onChange={e => onChange(e.target.value)}
aria-label="보조지표 검색"
/>
<span className="ind-settings-search-meta">
{value.trim()
? `검색 결과 ${filteredCount} / ${totalCount}`
: `전체 ${totalCount}`}
</span>
</div>
);
export default IndicatorSettingsListSearch;
@@ -0,0 +1,124 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { IndicatorConfig } from '../types';
import { getIndicatorDef, enrichIndicatorConfig } from '../utils/indicatorRegistry';
import IndicatorSettingsForm from './IndicatorSettingsForm';
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
interface IndicatorSettingsModalProps {
config: IndicatorConfig;
chartMarket?: string;
onSave: (updated: IndicatorConfig) => void;
onCancel: () => void;
}
/** 차트·설정 화면 공통 — 입력·스타일·가시성 단일 화면 (탭 없음) */
const IndicatorSettingsModal: React.FC<IndicatorSettingsModalProps> = ({
config,
chartMarket = '',
onSave,
onCancel,
}) => {
const def = getIndicatorDef(config.type);
const draftRef = useRef(enrichIndicatorConfig(config));
const [formRevision, setFormRevision] = useState(0);
const [chartHidden, setChartHidden] = useState(() => draftRef.current.hidden ?? false);
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
const openedIdRef = React.useRef<string | null>(null);
useEffect(() => {
if (openedIdRef.current !== config.id) {
openedIdRef.current = config.id;
draftRef.current = enrichIndicatorConfig(config);
setChartHidden(draftRef.current.hidden ?? false);
setFormRevision(r => r + 1);
}
}, [config]);
const title = def
? `${def.koreanName} (${def.name})`
: config.type;
const handleSave = useCallback(() => {
onSave(enrichIndicatorConfig(draftRef.current));
}, [onSave]);
const handleDefaults = useCallback(() => {
draftRef.current = resetConfigToDefaults(draftRef.current);
setChartHidden(draftRef.current.hidden ?? false);
setFormRevision(r => r + 1);
}, []);
const handleHidden = useCallback((showOnChart: boolean) => {
draftRef.current = { ...draftRef.current, hidden: !showOnChart };
setChartHidden(!showOnChart);
}, []);
const handleDraftRef = useCallback((updated: IndicatorConfig) => {
draftRef.current = updated;
}, []);
return (
<div
className="ism-overlay"
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
>
<div
ref={panelRef}
className="ism-dialog ism-dialog-unified"
style={{
...panelStyle,
zIndex: 5001,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title">{title}</span>
<button type="button" className="gc-popup-close" onClick={onCancel}></button>
</div>
<div
className="ism-body ism-body-unified"
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<IndicatorSettingsForm
key={`${config.id}-${formRevision}`}
config={draftRef.current}
chartMarket={chartMarket}
variant="modal"
deferParentSync
chartHidden={chartHidden}
onChartHiddenChange={handleHidden}
onChange={handleDraftRef}
/>
</div>
<div className="ism-footer">
<button type="button" className="ism-btn-defaults" onClick={handleDefaults}>
<span style={{ opacity: 0.6, fontSize: 11 }}></span>
</button>
<div className="ism-footer-right">
<button type="button" className="ism-btn-cancel" onClick={onCancel}></button>
<button type="button" className="ism-btn-ok" onClick={handleSave}></button>
</div>
</div>
</div>
</div>
);
};
export default IndicatorSettingsModal;
@@ -0,0 +1,595 @@
import React from 'react';
import type { IndicatorConfig, HlinesBackground, Timeframe } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import { getHLineLabel } from '../utils/indicatorRegistry';
import { getParamLabel, getPlotLabel, getSrcOptionLabel } from '../utils/indicatorLabels';
import { getIchimokuPlotTitle } from '../utils/ichimokuConfig';
import {
getNumericParamSpec,
getHlinePriceSpec,
} from '../utils/indicatorParamSpec';
import {
getGlobalParamKeys,
getPlotParamKeys,
plotParamsOnGlobalRowOnly,
plotRowHasNoInputs,
} from '../utils/indicatorSettingsLayout';
import NumericParamInput from './NumericParamInput';
import PlotLineStylePicker from './PlotLineStylePicker';
import ColorInput from './ColorInput';
import { colorToRgbaString, cloudColorOpacityPercent } from '../utils/ichimokuConfig';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
const SRC_OPTIONS = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4'];
const MA_TYPE_OPTIONS_CCI = ['None', 'SMA', 'EMA', 'WMA'] as const;
const MA_TYPE_OPTIONS_OBV = ['SMA', 'EMA', 'WMA'] as const;
/** 업비트 OBV 드롭다운 표기 */
const OBV_MA_TYPE_LABELS: Record<string, string> = {
SMA: '단순이동평균',
EMA: '지수 이동 평균',
WMA: '가중이동평균',
};
const MA_TYPE_LABELS: Record<string, string> = {
None: '없음 (None)',
SMA: '단순이동평균 (SMA)',
EMA: '지수이동평균 (EMA)',
WMA: '가중이동평균 (WMA)',
'SMMA (RMA)': 'RMA (SMMA)',
VWMA: '거래량가중이동평균 (VWMA)',
};
function isMaTypeParam(indicatorType: string, key: string, value: unknown): boolean {
if (key !== 'maType' || typeof value !== 'string') return false;
return indicatorType === 'CCI' || indicatorType === 'OBV' || indicatorType === 'RSI';
}
function maTypeOptionsFor(indicatorType: string): readonly string[] {
if (indicatorType === 'OBV') return MA_TYPE_OPTIONS_OBV;
return MA_TYPE_OPTIONS_CCI;
}
function isSrcParam(key: string, value: unknown): boolean {
return (key === 'src' || key.toLowerCase().includes('src')) && typeof value === 'string';
}
export { ColorInput };
/* ── 단일 파라미터 컨트롤 ─────────────────────────────────────── */
export const ParamField: React.FC<{
indicatorType: string;
paramKey: string;
value: number | string | boolean;
disabled?: boolean;
onChange: (key: string, raw: string | boolean) => void;
}> = ({ indicatorType, paramKey, value, disabled, onChange }) => {
if (typeof value === 'boolean') {
return (
<label className="ism-toggle">
<input
type="checkbox"
checked={value}
disabled={disabled}
onChange={e => onChange(paramKey, e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
);
}
if (isMaTypeParam(indicatorType, paramKey, value)) {
return (
<select
className="ism-select ism-param-inline-select"
value={value as string}
disabled={disabled}
onChange={e => onChange(paramKey, e.target.value)}
>
{maTypeOptionsFor(indicatorType).map(o => (
<option key={o} value={o}>
{indicatorType === 'OBV'
? (OBV_MA_TYPE_LABELS[o] ?? o)
: (MA_TYPE_LABELS[o] ?? o)}
</option>
))}
</select>
);
}
if (isSrcParam(paramKey, value)) {
return (
<select
className="ism-select ism-param-inline-select"
value={value as string}
disabled={disabled}
onChange={e => onChange(paramKey, e.target.value)}
>
{SRC_OPTIONS.map(o => (
<option key={o} value={o}>{getSrcOptionLabel(o)}</option>
))}
</select>
);
}
if (typeof value === 'number') {
return (
<NumericParamInput
className="ism-input ism-narrow ism-sma-period-input"
value={value}
spec={getNumericParamSpec(indicatorType, paramKey, value)}
disabled={disabled}
onChange={v => onChange(paramKey, String(v))}
/>
);
}
return (
<input
type="text"
className="ism-input ism-narrow"
value={value as string}
disabled={disabled}
onChange={e => onChange(paramKey, e.target.value)}
/>
);
};
/* ── 플롯 설정 행 (on/off · 이름 · 입력 · 색상·선) ─────────────── */
export const PlotSettingsRow: React.FC<{
indicatorType: string;
plot: PlotDef;
plotIndex: number;
plots: PlotDef[];
params: Record<string, number | string | boolean>;
enabled: boolean;
inputsOnly?: boolean;
onToggle: (plotId: string, on: boolean) => void;
onParamChange: (key: string, raw: string | boolean) => void;
onPlotStyle: (plotIndex: number, patch: Partial<PlotDef>) => void;
}> = ({
indicatorType, plot, plotIndex, plots, params, enabled, inputsOnly,
onToggle, onParamChange, onPlotStyle,
}) => {
const paramKeys = getPlotParamKeys(indicatorType, plot.id, plotIndex, plots, params);
const noInputs = plotRowHasNoInputs(indicatorType, plot.id, plotIndex, plots, params);
const globalOnlyInputs = plotParamsOnGlobalRowOnly(indicatorType, plot.id);
const label = indicatorType === 'IchimokuCloud'
? getIchimokuPlotTitle(plot.id)
: getPlotLabel(plot.title);
const isHistogram = plot.type === 'histogram';
return (
<div className={`ism-row ism-sma-ma-row${enabled ? '' : ' ism-sma-ma-off'}`}>
<label className="ism-toggle ism-sma-ma-toggle">
<input
type="checkbox"
checked={enabled}
onChange={e => onToggle(plot.id, e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
<label className="ism-label ism-sma-ma-label">{label}</label>
<div className="ism-control ism-sma-ma-control">
{noInputs && globalOnlyInputs ? (
<span className="ism-hlines-bg-spacer" aria-hidden />
) : noInputs ? (
<span className="ism-ichimoku-auto-param" title="상위 설정 또는 자동 계산"></span>
) : (
<div className="ism-plot-param-inputs">
{paramKeys.map(key => (
<ParamField
key={key}
indicatorType={indicatorType}
paramKey={key}
value={params[key]}
disabled={!enabled}
onChange={onParamChange}
/>
))}
</div>
)}
{!inputsOnly && (
isHistogram ? (
<ColorInput
value={plot.color}
onChange={v => onPlotStyle(plotIndex, { color: v })}
/>
) : (
<PlotLineStylePicker
disabled={!enabled}
title={label}
value={{
color: plot.color,
lineWidth: plot.lineWidth,
lineStyle: plot.lineStyle,
}}
onChange={patch => onPlotStyle(plotIndex, patch)}
/>
)
)}
</div>
</div>
);
};
/* ── 전역 파라미터 행 (토글 없음) ─────────────────────────────── */
export const GlobalParamRow: React.FC<{
indicatorType: string;
paramKey: string;
value: number | string | boolean;
onChange: (key: string, raw: string | boolean) => void;
}> = ({ indicatorType, paramKey, value, onChange }) => (
<div className="ism-row ism-global-param-row">
<span className="ism-label ism-sma-ma-label">{getParamLabel(paramKey, indicatorType)}</span>
<div className="ism-control ism-sma-ma-control">
<ParamField
indicatorType={indicatorType}
paramKey={paramKey}
value={value}
onChange={onChange}
/>
</div>
</div>
);
/* ── 수평선 행 ───────────────────────────────────────────────── */
export const HLineSettingsRow: React.FC<{
indicatorType: string;
hl: HLineDef;
idx: number;
allPrices: number[];
inputsOnly?: boolean;
onVisible: (idx: number, v: boolean) => void;
onPrice: (idx: number, v: number) => void;
onStyle: (idx: number, patch: Partial<HLineDef>) => void;
}> = ({ indicatorType, hl, idx, allPrices, inputsOnly, onVisible, onPrice, onStyle }) => {
const label = hl.label ?? getHLineLabel(hl.price, allPrices);
const isOn = hl.visible !== false;
return (
<div className={`ism-row ism-sma-ma-row${isOn ? '' : ' ism-sma-ma-off'}`}>
<label className="ism-toggle ism-sma-ma-toggle">
<input type="checkbox" checked={isOn} onChange={e => onVisible(idx, e.target.checked)} />
<span className="ism-toggle-slider" />
</label>
<label className="ism-label ism-sma-ma-label">{label}</label>
<div className="ism-control ism-sma-ma-control">
<NumericParamInput
className="ism-input ism-narrow ism-sma-period-input"
value={hl.price}
spec={getHlinePriceSpec(indicatorType, hl.price)}
disabled={!isOn}
onChange={v => onPrice(idx, v)}
/>
{!inputsOnly && (
<PlotLineStylePicker
disabled={!isOn}
title={label}
value={{
color: hl.color,
lineWidth: hl.lineWidth,
lineStyle: hl.lineStyle ?? 'dashed',
}}
onChange={patch => onStyle(idx, patch)}
/>
)}
</div>
</div>
);
};
/* ── 출력 · 타임프레임 (하단 공통) ───────────────────────────── */
export const SettingsOutputFooter: React.FC<{
lastValueVisible: boolean;
onLastValueVisible: (v: boolean) => void;
timeframeVis: Partial<Record<Timeframe, boolean>>;
onTimeframeVis: (tf: Timeframe, v: boolean) => void;
timeframes: { tf: Timeframe; label: string }[];
/** 개별 설정 모달: 지표 전체 숨김 */
chartHidden?: boolean;
onChartHidden?: (showOnChart: boolean) => void;
}> = ({
lastValueVisible, onLastValueVisible, timeframeVis, onTimeframeVis, timeframes,
chartHidden, onChartHidden,
}) => (
<>
<div className="ism-ichimoku-cloud-divider" />
<div className="ism-output-section-title"> · </div>
{onChartHidden != null && (
<div className="ism-row ism-ichimoku-extra-row">
<span className="ism-label"> </span>
<div className="ism-control">
<label className="ism-toggle">
<input
type="checkbox"
checked={chartHidden !== true}
onChange={e => onChartHidden(e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
</div>
</div>
)}
<div className="ism-row ism-ichimoku-extra-row">
<span className="ism-label"> ·</span>
<div className="ism-control">
<label className="ism-toggle" title="이 지표만 우측 가격축 금액·설명 표시 (차트 설정 전역 스위치가 켜져 있을 때 적용)">
<input
type="checkbox"
checked={lastValueVisible}
onChange={e => onLastValueVisible(e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
</div>
</div>
<div className="ism-tf-grid ism-ichimoku-tf-grid">
{timeframes.map(({ tf, label }) => (
<label key={tf} className="ism-tf-item">
<input
type="checkbox"
checked={timeframeVis[tf] !== false}
onChange={e => onTimeframeVis(tf, e.target.checked)}
/>
<span className="ism-tf-label">{label}</span>
</label>
))}
</div>
</>
);
/* ── 일목 구름 색상 ─────────────────────────────────────────── */
export const IchimokuCloudSection: React.FC<{
cloudColors: IchimokuCloudColors;
onChange: (patch: Partial<IchimokuCloudColors>) => void;
}> = ({ cloudColors, onChange }) => (
<>
<div className="ism-ichimoku-cloud-divider" />
<div className="ism-ichimoku-cloud-section">
<h4 className="ism-ichimoku-cloud-title"> </h4>
<p className="ism-ichimoku-cloud-hint">
1·2 .
</p>
<div className="ism-ichimoku-cloud-grid">
<div className="ism-ichimoku-cloud-card">
<span className="ism-ichimoku-cloud-card-label"> (1 &gt; 2)</span>
<div className="ism-ichimoku-cloud-card-row">
<ColorInput
value={cloudColors.bullishColor}
onChange={v => onChange({ bullishColor: v })}
/>
</div>
<div className="ism-ichimoku-cloud-meta">
{colorToRgbaString(cloudColors.bullishColor)}
{' · '} {cloudColorOpacityPercent(cloudColors.bullishColor)}%
</div>
</div>
<div className="ism-ichimoku-cloud-card">
<span className="ism-ichimoku-cloud-card-label"> (1 &lt; 2)</span>
<div className="ism-ichimoku-cloud-card-row">
<ColorInput
value={cloudColors.bearishColor}
onChange={v => onChange({ bearishColor: v })}
/>
</div>
<div className="ism-ichimoku-cloud-meta">
{colorToRgbaString(cloudColors.bearishColor)}
{' · '} {cloudColorOpacityPercent(cloudColors.bearishColor)}%
</div>
</div>
</div>
</div>
</>
);
/* ── 볼린저밴드 백그라운드 (업비트 모습 탭) ───────────────────── */
export const BollingerBackgroundRow: React.FC<{
value: HlinesBackground;
onChange: (v: HlinesBackground) => void;
}> = ({ value, onChange }) => (
<div className={`ism-row ism-sma-ma-row${value.visible ? '' : ' ism-sma-ma-off'}`}>
<label className="ism-toggle ism-sma-ma-toggle">
<input
type="checkbox"
checked={value.visible}
onChange={e => onChange({ ...value, visible: e.target.checked })}
/>
<span className="ism-toggle-slider" />
</label>
<span className="ism-label ism-sma-ma-label"> </span>
<div className="ism-control ism-sma-ma-control">
<span className="ism-ichimoku-auto-param ism-hlines-bg-spacer" aria-hidden />
<ColorInput
value={value.color}
onChange={c => onChange({ ...value, color: c })}
/>
</div>
</div>
);
/* ── 수평선 배경 ─────────────────────────────────────────────── */
export const HlinesBackgroundRow: React.FC<{
value: HlinesBackground;
onChange: (v: HlinesBackground) => void;
}> = ({ value, onChange }) => (
<>
<div className="ism-ichimoku-cloud-divider" />
<div className="ism-row ism-sma-ma-row">
<label className="ism-toggle ism-sma-ma-toggle">
<input
type="checkbox"
checked={value.visible}
onChange={e => onChange({ ...value, visible: e.target.checked })}
/>
<span className="ism-toggle-slider" />
</label>
<span className="ism-label ism-sma-ma-label"> </span>
<div className="ism-control ism-sma-ma-control">
<span className="ism-ichimoku-auto-param ism-hlines-bg-spacer" aria-hidden />
<ColorInput
value={value.color}
onChange={c => onChange({ ...value, color: c })}
/>
</div>
</div>
</>
);
/* ── 볼린저밴드 심볼 (업비트 인풋) ───────────────────────────── */
export const BollingerSymbolSection: React.FC<{
symbolMode: string;
refSymbol: string;
chartMarket: string;
onChange: (patch: { symbolMode?: string; refSymbol?: string }) => void;
}> = ({ symbolMode, refSymbol, chartMarket, onChange }) => {
const isOther = symbolMode === 'other';
return (
<div className="ism-bb-symbol-section">
<div className="ism-output-section-title"></div>
<label className="ism-bb-symbol-option">
<input
type="radio"
name="bb-symbol-mode"
checked={!isOther}
onChange={() => onChange({ symbolMode: 'main' })}
/>
<span> </span>
{chartMarket ? <span className="ism-bb-symbol-hint">{chartMarket}</span> : null}
</label>
<label className="ism-bb-symbol-option ism-bb-symbol-other">
<input
type="radio"
name="bb-symbol-mode"
checked={isOther}
onChange={() => onChange({ symbolMode: 'other' })}
/>
<span> </span>
<input
type="text"
className="ism-input ism-bb-ref-symbol"
placeholder="KRW-BTC"
value={refSymbol}
disabled={!isOther}
onChange={e => onChange({ refSymbol: e.target.value.toUpperCase() })}
/>
</label>
</div>
);
};
/* ── 통합 본문 (일반 지표) ───────────────────────────────────── */
export const UnifiedIndicatorBody: React.FC<{
config: IndicatorConfig;
params: Record<string, number | string | boolean>;
plots: PlotDef[];
plotVis: Record<string, boolean>;
hlines: HLineDef[];
hlinesBg: HlinesBackground;
hasHLines: boolean;
lastValueVisible: boolean;
timeframeVis: Partial<Record<Timeframe, boolean>>;
timeframes: { tf: Timeframe; label: string }[];
onParamChange: (key: string, raw: string | boolean) => void;
onPlotToggle: (plotId: string, on: boolean) => void;
onPlotStyle: (idx: number, patch: Partial<PlotDef>) => void;
onHLineVisible: (idx: number, v: boolean) => void;
onHLinePrice: (idx: number, v: number) => void;
onHLineStyle: (idx: number, patch: Partial<HLineDef>) => void;
onHlinesBg: (v: HlinesBackground) => void;
onLastValueVisible: (v: boolean) => void;
onTimeframeVis: (tf: Timeframe, v: boolean) => void;
cloudSection?: React.ReactNode;
symbolSection?: React.ReactNode;
/** 플롯 행 아래 추가 섹션 (BB 백그라운드 등) */
afterPlotsSection?: React.ReactNode;
/** false면 가격 스케일·타임프레임 보임 설정 숨김 (BB) */
showOutputFooter?: boolean;
inputsOnly?: boolean;
chartHidden?: boolean;
onChartHidden?: (showOnChart: boolean) => void;
}> = (props) => {
const {
config, params, plots, plotVis, hlines, hlinesBg, hasHLines,
lastValueVisible, timeframeVis, timeframes,
onParamChange, onPlotToggle, onPlotStyle,
onHLineVisible, onHLinePrice, onHLineStyle, onHlinesBg,
onLastValueVisible, onTimeframeVis, cloudSection, symbolSection,
afterPlotsSection,
showOutputFooter = true,
inputsOnly = false,
chartHidden,
onChartHidden,
} = props;
const globalKeys = getGlobalParamKeys(config.type, params, plots);
const hlinePrices = hlines.map(h => h.price);
return (
<div className="ism-section unified-settings-section">
{symbolSection}
{symbolSection && globalKeys.length > 0 && <div className="ism-sma-divider" />}
{globalKeys.map(key => (
<GlobalParamRow
key={key}
indicatorType={config.type}
paramKey={key}
value={params[key]}
onChange={onParamChange}
/>
))}
{globalKeys.length > 0 && plots.length > 0 && <div className="ism-sma-divider" />}
{plots.map((plot, idx) => (
<PlotSettingsRow
key={plot.id}
indicatorType={config.type}
plot={plot}
plotIndex={idx}
plots={plots}
params={params}
enabled={plotVis[plot.id] !== false}
onToggle={onPlotToggle}
onParamChange={onParamChange}
onPlotStyle={onPlotStyle}
inputsOnly={inputsOnly}
/>
))}
{cloudSection}
{afterPlotsSection}
{hlines.length > 0 && (
<>
<div className="ism-sma-divider" />
<div className="ism-output-section-title"></div>
{hlines.map((hl, idx) => (
<HLineSettingsRow
key={idx}
indicatorType={config.type}
hl={hl}
idx={idx}
allPrices={hlinePrices}
onVisible={onHLineVisible}
onPrice={onHLinePrice}
onStyle={onHLineStyle}
inputsOnly={inputsOnly}
/>
))}
</>
)}
{hasHLines && !inputsOnly && <HlinesBackgroundRow value={hlinesBg} onChange={onHlinesBg} />}
{showOutputFooter && (
<SettingsOutputFooter
lastValueVisible={lastValueVisible}
onLastValueVisible={onLastValueVisible}
timeframeVis={timeframeVis}
onTimeframeVis={onTimeframeVis}
timeframes={timeframes}
chartHidden={chartHidden}
onChartHidden={onChartHidden}
/>
)}
</div>
);
};
@@ -0,0 +1,239 @@
/**
* 보조지표 설정 — 스타일 탭 (플롯·수평선 색상/굵기/타입)
* 타이틀 열 너비를 섹션 내 최장 라벨에 맞춰 컨트롤 상하 정렬
*/
import React, { useLayoutEffect, useRef } from 'react';
import type { PlotDef, HLineDef, HLineStyle } from '../utils/indicatorRegistry';
import { getHLineLabel } from '../utils/indicatorRegistry';
import { getPlotLabel } from '../utils/indicatorLabels';
import { getHlinePriceSpec } from '../utils/indicatorParamSpec';
import NumericParamInput from './NumericParamInput';
import { ColorInput } from './IndicatorSettingsSections';
import { LINE_WIDTH_OPTIONS, LINE_STYLE_OPTIONS } from '../utils/plotColorUtils';
import { IchimokuCloudSection } from './IndicatorSettingsSections';
import { getIchimokuPlotTitle, type IchimokuCloudColors } from '../utils/ichimokuConfig';
/** 스타일 탭 선 유형 드롭다운 (업비트 표기) */
const LINE_STYLE_TAB_LABELS: Record<HLineStyle, string> = {
solid: '라인',
dashed: '점선',
dotted: '도트',
};
export interface IndicatorSettingsStyleSectionProps {
indicatorType: string;
plots: PlotDef[];
hlines: HLineDef[];
cloudColors?: IchimokuCloudColors;
showIchimokuCloud?: boolean;
onPlotStyle: (idx: number, patch: Partial<PlotDef>) => void;
onHLineStyle: (idx: number, patch: Partial<HLineDef>) => void;
onHLinePrice?: (idx: number, price: number) => void;
onCloudColors?: (patch: Partial<IchimokuCloudColors>) => void;
}
function measureTitleColumn(sectionEl: HTMLElement | null, measureEl: HTMLElement | null) {
if (!sectionEl || !measureEl) return;
let maxW = 0;
measureEl.querySelectorAll<HTMLElement>('.ism-plot-title').forEach(el => {
maxW = Math.max(maxW, el.offsetWidth);
});
if (maxW > 0) {
sectionEl.style.setProperty('--ism-plot-title-width', `${Math.ceil(maxW)}px`);
}
}
const PlotStyleRow: React.FC<{
label: string;
plot: PlotDef;
disabled?: boolean;
onStyle: (patch: Partial<PlotDef>) => void;
}> = ({ label, plot, disabled, onStyle }) => {
const isHistogram = plot.type === 'histogram';
const lineWidth = plot.lineWidth ?? 1;
const lineStyle = plot.lineStyle ?? 'solid';
return (
<div className="ism-style-row">
<div className="ism-plot-title-cell">
<span className="ism-plot-title">{label}</span>
</div>
<div className="ism-style-field">
<span className="ism-style-label"></span>
<ColorInput
value={plot.color}
onChange={v => onStyle({ color: v })}
/>
</div>
<div className="ism-style-field">
<span className="ism-style-label"></span>
{isHistogram ? (
<span className="ism-style-field-spacer ism-num-combo-spacer" aria-hidden />
) : (
<select
className="ism-select ism-style-width-select"
value={lineWidth}
disabled={disabled}
onChange={e => onStyle({ lineWidth: Number(e.target.value) })}
>
{LINE_WIDTH_OPTIONS.map(w => (
<option key={w} value={w}>{w}</option>
))}
</select>
)}
</div>
<div className="ism-style-field">
<span className="ism-style-label"></span>
{isHistogram ? (
<span className="ism-style-type-static"></span>
) : (
<select
className="ism-select ism-style-type-select"
value={lineStyle}
disabled={disabled}
onChange={e => onStyle({ lineStyle: e.target.value as HLineStyle })}
>
{LINE_STYLE_OPTIONS.map(s => (
<option key={s} value={s}>{LINE_STYLE_TAB_LABELS[s]}</option>
))}
</select>
)}
</div>
</div>
);
};
const HLineStyleRow: React.FC<{
indicatorType: string;
label: string;
hl: HLineDef;
onStyle: (patch: Partial<HLineDef>) => void;
onPrice?: (price: number) => void;
}> = ({ indicatorType, label, hl, onStyle, onPrice }) => {
const isOn = hl.visible !== false;
const lineWidth = hl.lineWidth ?? 1;
const lineStyle = hl.lineStyle ?? 'dashed';
return (
<div className={`ism-hline-row${isOn ? '' : ' ism-hline-row--off'}`}>
<div className="ism-hline-name-cell">
<span className="ism-hline-label-text">{label}</span>
</div>
<div className="ism-hline-cell">
{onPrice != null ? (
<NumericParamInput
className="ism-input ism-narrow"
value={hl.price}
spec={getHlinePriceSpec(indicatorType, hl.price)}
disabled={!isOn}
onChange={onPrice}
/>
) : (
<span className="ism-hline-price-readonly">{hl.price}</span>
)}
</div>
<div className="ism-hline-cell ism-hline-cell--style">
<ColorInput value={hl.color} onChange={v => onStyle({ color: v })} />
<select
className="ism-select ism-style-width-select"
value={lineWidth}
disabled={!isOn}
title="굵기"
onChange={e => onStyle({ lineWidth: Number(e.target.value) })}
>
{LINE_WIDTH_OPTIONS.map(w => (
<option key={w} value={w}>{w}</option>
))}
</select>
<select
className="ism-select ism-style-type-select"
value={lineStyle}
disabled={!isOn}
title="타입"
onChange={e => onStyle({ lineStyle: e.target.value as HLineStyle })}
>
{LINE_STYLE_OPTIONS.map(s => (
<option key={s} value={s}>{LINE_STYLE_TAB_LABELS[s]}</option>
))}
</select>
</div>
</div>
);
};
const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps> = ({
indicatorType,
plots,
hlines,
cloudColors,
showIchimokuCloud,
onPlotStyle,
onHLineStyle,
onHLinePrice,
onCloudColors,
}) => {
const sectionRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const hlinePrices = hlines.map(h => h.price);
const plotLabel = (p: PlotDef) => indicatorType === 'IchimokuCloud'
? getIchimokuPlotTitle(p.id)
: getPlotLabel(p.title);
const titleLabels = [
...plots.map(plotLabel),
...hlines.map(h => h.label ?? getHLineLabel(h.price, hlinePrices)),
];
useLayoutEffect(() => {
measureTitleColumn(sectionRef.current, measureRef.current);
}, [titleLabels.join('\0'), plots.length, hlines.length]);
return (
<div className="ism-section ism-style-tab-section">
<div ref={measureRef} className="ism-plot-title-measure" aria-hidden>
{titleLabels.map((t, i) => (
<span key={i} className="ism-plot-title">{t}</span>
))}
</div>
<div ref={sectionRef} className="ism-style-section">
{plots.map((plot, idx) => (
<PlotStyleRow
key={plot.id}
label={plotLabel(plot)}
plot={plot}
onStyle={patch => onPlotStyle(idx, patch)}
/>
))}
{hlines.length > 0 && (
<>
<hr className="ism-hline-divider" />
<div className="ism-hline-header-row">
<span></span>
<span></span>
<span> · · </span>
</div>
{hlines.map((hl, idx) => (
<HLineStyleRow
key={idx}
indicatorType={indicatorType}
label={hl.label ?? getHLineLabel(hl.price, hlinePrices)}
hl={hl}
onStyle={patch => onHLineStyle(idx, patch)}
onPrice={onHLinePrice ? v => onHLinePrice(idx, v) : undefined}
/>
))}
</>
)}
</div>
{showIchimokuCloud && cloudColors && onCloudColors && (
<IchimokuCloudSection cloudColors={cloudColors} onChange={onCloudColors} />
)}
</div>
);
};
export default IndicatorSettingsStyleSection;
@@ -0,0 +1,51 @@
/**
* 보조지표 설정 — 가시성 탭
*/
import React from 'react';
import type { Timeframe } from '../types';
import { SettingsOutputFooter } from './IndicatorSettingsSections';
export interface IndicatorSettingsVisibilitySectionProps {
hidden: boolean;
onHidden: (v: boolean) => void;
lastValueVisible: boolean;
onLastValueVisible: (v: boolean) => void;
timeframeVis: Partial<Record<Timeframe, boolean>>;
onTimeframeVis: (tf: Timeframe, v: boolean) => void;
timeframes: { tf: Timeframe; label: string }[];
}
const IndicatorSettingsVisibilitySection: React.FC<IndicatorSettingsVisibilitySectionProps> = ({
hidden,
onHidden,
lastValueVisible,
onLastValueVisible,
timeframeVis,
onTimeframeVis,
timeframes,
}) => (
<div className="ism-section ism-visibility-tab-section">
<div className="ism-row">
<span className="ism-label"> </span>
<div className="ism-control">
<label className="ism-toggle">
<input
type="checkbox"
checked={!hidden}
onChange={e => onHidden(!e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
</div>
</div>
<SettingsOutputFooter
lastValueVisible={lastValueVisible}
onLastValueVisible={onLastValueVisible}
timeframeVis={timeframeVis}
onTimeframeVis={onTimeframeVis}
timeframes={timeframes}
/>
</div>
);
export default IndicatorSettingsVisibilitySection;
+162
View File
@@ -0,0 +1,162 @@
/**
* LayoutPicker
* TradingView 스타일 레이아웃 선택 팝업.
*/
import React, { useEffect, useRef } from 'react';
import type { LayoutDef, SyncOptions } from '../utils/layoutTypes';
import { LAYOUTS } from '../utils/layoutTypes';
// grid-template-areas 문자열을 파싱해 셀 rect 배열 반환
function parseAreas(def: LayoutDef, bw: number, bh: number, pad: number) {
const areaGrid: string[][] = def.areas
.split('"')
.map(s => s.trim())
.filter(Boolean)
.map(row => row.split(/\s+/).filter(Boolean));
if (areaGrid.length === 0) return [];
const rowCount = areaGrid.length;
const colCount = areaGrid[0]?.length ?? 1;
const colW = bw / colCount;
const rowH = bh / rowCount;
const cellMap = new Map<string, { c1: number; r1: number; c2: number; r2: number }>();
for (let ri = 0; ri < areaGrid.length; ri++) {
for (let ci = 0; ci < (areaGrid[ri]?.length ?? 0); ci++) {
const name = areaGrid[ri][ci];
if (!name || name === '.') continue;
const existing = cellMap.get(name);
if (!existing) {
cellMap.set(name, { c1: ci, r1: ri, c2: ci + 1, r2: ri + 1 });
} else {
cellMap.set(name, {
c1: Math.min(existing.c1, ci),
r1: Math.min(existing.r1, ri),
c2: Math.max(existing.c2, ci + 1),
r2: Math.max(existing.r2, ri + 1),
});
}
}
}
return Array.from(cellMap.values()).map(r => ({
x: pad + r.c1 * colW,
y: pad + r.r1 * rowH,
w: (r.c2 - r.c1) * colW,
h: (r.r2 - r.r1) * rowH,
}));
}
// ── 레이아웃 미니 아이콘 (SVG) ────────────────────────────────────────────────
const LayoutIcon: React.FC<{ def: LayoutDef; size?: number }> = ({ def, size = 34 }) => {
const S = size;
const PAD = 2;
const BW = S - PAD * 2;
const BH = S - PAD * 2;
const rects = parseAreas(def, BW, BH, PAD);
return (
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} fill="none">
<rect x={PAD} y={PAD} width={BW} height={BH} rx="2"
stroke="currentColor" strokeWidth="1" opacity="0.4" />
{rects.map((r, i) => (
<rect key={i}
x={r.x + 1} y={r.y + 1}
width={r.w - 2} height={r.h - 2}
rx="1"
stroke="currentColor" strokeWidth="1"
fill="currentColor" fillOpacity="0.15"
/>
))}
</svg>
);
};
// ── 토글 스위치 ──────────────────────────────────────────────────────────────
const Toggle: React.FC<{ checked: boolean; onChange: () => void }> = ({ checked, onChange }) => (
<button
className={`lp-toggle${checked ? ' on' : ''}`}
onClick={onChange}
aria-pressed={checked}
/>
);
// 행 그룹: 같은 count 단위로 묶음
const ROW_GROUPS = [1, 2, 3, 4, 5, 6, 8];
// ── Main Component ───────────────────────────────────────────────────────────
interface LayoutPickerProps {
currentId: string;
syncOptions: SyncOptions;
onSelect: (def: LayoutDef) => void;
onSyncChange: (key: keyof SyncOptions, value: boolean) => void;
onClose: () => void;
}
const LayoutPicker: React.FC<LayoutPickerProps> = ({
currentId, syncOptions, onSelect, onSyncChange, onClose,
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
setTimeout(() => document.addEventListener('mousedown', handler), 80);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
return (
<div ref={ref} className="lp-popup">
{/* 레이아웃 그리드 */}
<div className="lp-grid-section">
{ROW_GROUPS.map(count => {
const group = LAYOUTS.filter(l => l.count === count);
if (group.length === 0) return null;
return (
<div key={count} className="lp-row">
<span className="lp-row-num">{count}</span>
<div className="lp-row-icons">
{group.map(def => (
<button
key={def.id}
className={`lp-icon-btn${currentId === def.id ? ' active' : ''}`}
title={def.label}
onClick={() => { onSelect(def); }}
>
<LayoutIcon def={def} size={34} />
</button>
))}
</div>
</div>
);
})}
</div>
<div className="lp-divider" />
{/* SYNC IN LAYOUT */}
<div className="lp-sync-section">
<div className="lp-sync-label">SYNC IN LAYOUT</div>
{([
['symbol', 'Symbol'],
['interval', 'Interval'],
['crosshair', 'Crosshair'],
['time', 'Time'],
['dateRange', 'Date range'],
] as [keyof SyncOptions, string][]).map(([key, label]) => (
<div key={key} className="lp-sync-row">
<span className="lp-sync-name">{label}</span>
<Toggle
checked={syncOptions[key]}
onChange={() => onSyncChange(key, !syncOptions[key])}
/>
</div>
))}
</div>
</div>
);
};
export default LayoutPicker;
@@ -0,0 +1,76 @@
/**
* 실시간 전략 STOMP 구독 — 화면과 무관하게 동작, 시그널은 알림 Context로 전달
*/
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { useLiveStrategyMarkers, type LiveMarker } from '../hooks/useLiveStrategyMarkers';
import { useTradeNotification } from '../contexts/TradeNotificationContext';
export interface LiveSignalNotifierHandle {
clearMarkers: () => void;
}
interface Props {
markets: string[];
/** 종목별 평가 분봉 — STOMP 구독 경로 */
marketSubscriptions?: { market: string; candleType: string }[];
activeMarket: string;
enabled: boolean;
popupEnabled: boolean;
strategyId: number | undefined;
executionType: string;
strategyName: string | null;
onMarkersChange: (markers: LiveMarker[]) => void;
/** 차트 화면일 때만 마커를 차트에 반영 */
chartMarkersActive: boolean;
}
export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(function LiveSignalNotifier({
markets,
marketSubscriptions,
activeMarket,
enabled,
popupEnabled: _popupEnabled,
strategyName,
executionType,
onMarkersChange,
chartMarkersActive,
}, ref) {
const { addNotification, refreshHistory } = useTradeNotification();
const onMarkersRef = useRef(onMarkersChange);
onMarkersRef.current = onMarkersChange;
useEffect(() => {
if (!enabled || markets.length === 0) return;
const id = window.setInterval(() => { void refreshHistory(); }, 20_000);
return () => window.clearInterval(id);
}, [enabled, markets.length, refreshHistory]);
const { clearMarkers } = useLiveStrategyMarkers({
markets,
subscriptions: marketSubscriptions,
activeMarket,
enabled: enabled && markets.length > 0,
onMarkersChange: markers => {
if (chartMarkersActive) onMarkersRef.current(markers);
},
onSignal: (marker: LiveMarker) => {
addNotification({
market: marker.market,
signalType: marker.signal,
price: marker.price,
candleTime: marker.time,
strategyName,
executionType,
candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m',
});
// STOMP만 수신·DB 저장 누락 시 배지 동기화
window.setTimeout(() => { void refreshHistory(); }, 800);
},
});
useImperativeHandle(ref, () => ({ clearMarkers }), [clearMarkers]);
return null;
});
export default LiveSignalNotifier;
@@ -0,0 +1,217 @@
/**
* LiveStrategyPanel
*
* 실시간 전략 체크 — 전역 설정 UI.
* 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다.
*/
import React, { useState, useCallback } from 'react';
import type { Theme } from '../types';
import {
saveLiveStrategySettings,
type LiveStrategySettingsDto,
} from '../utils/backendApi';
interface Strategy {
id: number;
name: string;
}
interface LiveStrategyPanelProps {
theme: Theme;
market: string;
strategies: Strategy[];
settings: LiveStrategySettingsDto;
watchlistCount?: number;
monitoredMarkets?: string[];
onClearMarkers?: () => void;
onClose?: () => void;
onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
}
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
theme, market, strategies, settings,
watchlistCount = 0, monitoredMarkets = [],
onClearMarkers, onClose, onSettingsChange,
}) => {
const [saving, setSaving] = useState(false);
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
const prev = settings;
onSettingsChange?.(next);
setSaving(true);
try {
const saved = await saveLiveStrategySettings(next);
if (saved) {
onSettingsChange?.(saved);
onClearMarkers?.();
} else {
onSettingsChange?.(prev);
window.alert('실시간 전략 설정 저장에 실패했습니다. 네트워크·서버 상태를 확인해 주세요.');
}
} catch {
onSettingsChange?.(prev);
window.alert('실시간 전략 설정 저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
}, [settings, market, onClearMarkers, onSettingsChange]);
const isOn = settings.isLiveCheck;
const execType = settings.executionType;
const stratId = settings.strategyId;
const monCount = monitoredMarkets.length;
return (
<div className={`lsp-wrap lsp-wrap--${theme}`}>
<div className="lsp-header">
<svg className="lsp-icon" width="13" height="13" viewBox="0 0 14 14"
fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="7" r="5.5"/>
<polyline points="5,7 7,9 10,5"/>
</svg>
<span className="lsp-title"> </span>
{saving && <span className="lsp-saving"> </span>}
{onClose && (
<button className="lsp-close-btn" onClick={onClose} title="닫기">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<line x1="1" y1="1" x2="10" y2="10"/>
<line x1="10" y1="1" x2="1" y2="10"/>
</svg>
</button>
)}
</div>
<div className="lsp-monitor-stats">
<span> {watchlistCount}</span>
<span className="lsp-monitor-sep">·</span>
<span className={monCount > 0 ? 'lsp-monitor-on' : ''}>
{monCount}
</span>
</div>
{isOn && stratId && watchlistCount > 0 && (
<p className="lsp-monitor-hint">
.
</p>
)}
{monCount > 0 && (
<div className="lsp-monitor-list" title={monitoredMarkets.join(', ')}>
{monitoredMarkets.slice(0, 8).map(m => (
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
))}
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
</div>
)}
{isOn && !stratId && (
<p className="lsp-monitor-hint"> {watchlistCount} .</p>
)}
<div className="lsp-row">
<span className="lsp-label"> </span>
<label className="lsp-toggle">
<input
type="checkbox"
checked={isOn}
onChange={e => persist({ isLiveCheck: e.target.checked })}
/>
<span className="lsp-toggle-slider" />
</label>
</div>
<div className={`lsp-row${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<select
className="lsp-select"
disabled={!isOn}
value={stratId ?? ''}
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<div className="lsp-radio-group">
<label className="lsp-radio">
<input
type="radio"
name="execType"
value="CANDLE_CLOSE"
checked={execType === 'CANDLE_CLOSE'}
disabled={!isOn}
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> 1 </span>
</span>
</label>
<label className="lsp-radio">
<input
type="radio"
name="execType"
value="REALTIME_TICK"
checked={execType === 'REALTIME_TICK'}
disabled={!isOn}
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> (3)</span>
<span className="lsp-radio-desc"> , 3 </span>
</span>
</label>
</div>
</div>
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<div className="lsp-radio-group">
<label className="lsp-radio">
<input
type="radio"
name="lsp-posMode"
value="LONG_ONLY"
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> </span>
</span>
</label>
<label className="lsp-radio">
<input
type="radio"
name="lsp-posMode"
value="SIGNAL_ONLY"
checked={settings.positionMode === 'SIGNAL_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> , </span>
</span>
</label>
</div>
</div>
{isOn && (
<div className="lsp-status">
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
{stratId
? `${strategies.find(s => s.id === stratId)?.name ?? '전략'} · 관심 ${monCount}종목`
: '전략을 선택하세요'}
</div>
)}
</div>
);
};
export default LiveStrategyPanel;
+103
View File
@@ -0,0 +1,103 @@
/**
* 로그인 모달 — 기본값 admin / admin
*/
import React, { useEffect, useState } from 'react';
import { loginUser, type LoginResponse } from '../utils/backendApi';
const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin';
interface Props {
open: boolean;
onClose: () => void;
onSuccess: (session: LoginResponse) => void;
}
const LoginModal: React.FC<Props> = ({ open, onClose, onSuccess }) => {
const [username, setUsername] = useState(DEFAULT_USERNAME);
const [password, setPassword] = useState(DEFAULT_PASSWORD);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
setUsername(DEFAULT_USERNAME);
setPassword(DEFAULT_PASSWORD);
setError(null);
setLoading(false);
}, [open]);
if (!open) return null;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await loginUser(username.trim(), password);
if (!res) {
setError('로그인에 실패했습니다. 아이디와 비밀번호를 확인하세요.');
return;
}
onSuccess(res);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="login-modal-backdrop" onClick={onClose}>
<div
className="login-modal"
role="dialog"
aria-labelledby="login-modal-title"
onClick={e => e.stopPropagation()}
>
<div className="login-modal-header">
<h2 id="login-modal-title"></h2>
<button type="button" className="login-modal-close" onClick={onClose} aria-label="닫기">×</button>
</div>
<p className="login-modal-hint">
. () .
</p>
<form className="login-modal-form" onSubmit={submit}>
<label className="login-modal-field">
<span></span>
<input
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loading}
/>
</label>
<label className="login-modal-field">
<span></span>
<input
type="text"
autoComplete="off"
className="login-modal-field--visible"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
/>
</label>
{error && <p className="login-modal-error">{error}</p>}
<div className="login-modal-actions">
<button type="button" className="login-modal-btn login-modal-btn--ghost" onClick={onClose} disabled={loading}>
</button>
<button type="submit" className="login-modal-btn login-modal-btn--primary" disabled={loading}>
{loading ? '로그인 중…' : '로그인'}
</button>
</div>
</form>
</div>
</div>
);
};
export default LoginModal;
@@ -0,0 +1,131 @@
/**
* MainChartSettingsModal
* 메인 차트(캔들스틱) 설정 다이얼로그 — TradingView Settings 와 유사.
* 탭: Symbol (캔들 색상) / 스타일 / 가시성 (미래 확장용)
*/
import React, { useState } from 'react';
import type { MainChartStyle } from '../types';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
interface Props {
style: MainChartStyle;
onSave: (style: MainChartStyle) => void;
onCancel: () => void;
}
type Tab = 'symbol';
const TABS: { id: Tab; label: string }[] = [
{ id: 'symbol', label: '심볼 (Symbol)' },
];
const ColorSwatch: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
const hex6 = value.slice(0, 7);
return (
<label className="mcs-swatch" title={hex6}>
<input
type="color"
value={hex6}
onChange={e => onChange(e.target.value)}
/>
<span style={{ background: hex6 }} />
</label>
);
};
const MainChartSettingsModal: React.FC<Props> = ({ style, onSave, onCancel }) => {
const [tab, setTab] = useState<Tab>('symbol');
const [draft, setDraft] = useState<MainChartStyle>({ ...style });
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
const set = (key: keyof MainChartStyle) => (v: string) =>
setDraft(prev => ({ ...prev, [key]: v }));
const handleOk = () => onSave(draft);
const handleDefaults = () => setDraft({
upColor: '#ff6b6b',
downColor: '#4dabf7',
borderUpColor: '#ff6b6b',
borderDownColor: '#4dabf7',
wickUpColor: '#ff6b6b',
wickDownColor: '#4dabf7',
});
return (
<div className="mcs-overlay" onMouseDown={e => e.target === e.currentTarget && onCancel()}>
<div
ref={panelRef}
className="mcs-dialog"
style={{
...panelStyle,
zIndex: 3001,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title"> (Settings)</span>
<button type="button" className="gc-popup-close" onClick={onCancel}></button>
</div>
<div className="mcs-tabs">
{TABS.map(t => (
<button
key={t.id}
className={`mcs-tab${tab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="mcs-body">
{tab === 'symbol' && (
<>
<div className="mcs-section-label"> (CANDLES)</div>
<div className="mcs-row">
<span className="mcs-row-label"> (Up)</span>
<div className="mcs-row-colors">
<ColorSwatch value={draft.upColor} onChange={set('upColor')} />
<ColorSwatch value={draft.borderUpColor} onChange={set('borderUpColor')} />
<ColorSwatch value={draft.wickUpColor} onChange={set('wickUpColor')} />
</div>
</div>
<div className="mcs-row">
<span className="mcs-row-label"> (Down)</span>
<div className="mcs-row-colors">
<ColorSwatch value={draft.downColor} onChange={set('downColor')} />
<ColorSwatch value={draft.borderDownColor} onChange={set('borderDownColor')} />
<ColorSwatch value={draft.wickDownColor} onChange={set('wickDownColor')} />
</div>
</div>
</>
)}
</div>
<div className="mcs-footer">
<button className="mcs-btn-defaults" onClick={handleDefaults}></button>
<div className="mcs-footer-right">
<button className="mcs-btn-cancel" onClick={onCancel}></button>
<button className="mcs-btn-ok" onClick={handleOk}></button>
</div>
</div>
</div>
</div>
);
};
export default MainChartSettingsModal;
+415
View File
@@ -0,0 +1,415 @@
/**
* 좌측 마켓 패널
*
* 탭 구성:
* 원화 - KRW 마켓 목록, 현재가(KRW)
* BTC - KRW 마켓 목록, 현재가(USD 환산)
* 보유 - 보유 종목 목록 (localStorage 관리)
* 관심 - 즐겨찾기 목록 (localStorage 관리)
*/
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
import type { TickerData, ChangeDir } from '../hooks/useMarketTicker';
import {
getFavorites, saveFavorites, toggleFavorite,
getHoldings, addHolding, removeHolding,
} from '../utils/marketStorage';
// ─── 타입 ──────────────────────────────────────────────────────────────────
type Tab = 'krw' | 'btc' | 'hold' | 'fav';
type SortKey = 'name' | 'price' | 'change';
type SortDir = 'asc' | 'desc';
export interface MarketPanelProps {
open: boolean;
tickers: Map<string, TickerData>;
loading: boolean;
usdRate: number; // KRW per 1 USD (from KRW-USDT)
activeSymbol: string;
onSymbolSelect: (symbol: string) => void;
}
// ─── 가격 포맷 헬퍼 ────────────────────────────────────────────────────────
function fmtKrw(price: number | null | undefined): string {
if (price == null || !isFinite(price)) return '-';
if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
if (price >= 0.01) return price.toFixed(4);
return price.toFixed(8);
}
function fmtUsd(krwPrice: number | null | undefined, rate: number): string {
if (krwPrice == null || !isFinite(krwPrice) || !rate) return '-';
const usd = krwPrice / rate;
if (usd >= 10_000) return '$' + usd.toLocaleString('en-US', { maximumFractionDigits: 0 });
if (usd >= 1) return '$' + usd.toFixed(2);
if (usd >= 0.001) return '$' + usd.toFixed(4);
return '$' + usd.toFixed(6);
}
function fmtPct(rate: number | null | undefined): string {
if (rate == null || !isFinite(rate)) return '-';
const sign = rate >= 0 ? '+' : '';
return `${sign}${(rate * 100).toFixed(2)}%`;
}
// ─── 방향 지시자 삼각형 ────────────────────────────────────────────────────
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
if (change === 'RISE') return <span className="mp-arrow mp-arrow--rise"></span>;
if (change === 'FALL') return <span className="mp-arrow mp-arrow--fall"></span>;
return <span className="mp-arrow mp-arrow--even"></span>;
});
// ─── 개별 행 ───────────────────────────────────────────────────────────────
interface RowProps {
ticker: TickerData | undefined;
market: string;
isActive: boolean;
isFav: boolean;
isHold: boolean;
showUsd: boolean;
showHoldBtn: boolean;
usdRate: number;
onSelect: () => void;
onToggleFav: (e: React.MouseEvent) => void;
onToggleHold: (e: React.MouseEvent) => void;
onRemoveHold?: (e: React.MouseEvent) => void;
}
const MarketRow = memo(function MarketRow({
ticker, market, isActive, isFav, isHold,
showUsd, showHoldBtn, usdRate,
onSelect, onToggleFav, onToggleHold, onRemoveHold,
}: RowProps) {
const change: ChangeDir = ticker?.change ?? 'EVEN';
const code = market.replace(/^KRW-/, '');
// ── 가격 변동 플래시 (frontend_golden TickerItem 로직) ─────────────────
const [flashClass, setFlashClass] = useState('');
const prevPriceRef = useRef<number | null>(null);
useEffect(() => {
const price = ticker?.tradePrice ?? null;
if (price === null) return;
const prev = prevPriceRef.current;
if (prev !== null && prev !== price) {
const cls = price > prev ? 'mp-row--flash-rise' : 'mp-row--flash-fall';
setFlashClass(cls);
const t = setTimeout(() => setFlashClass(''), 700);
prevPriceRef.current = price;
return () => clearTimeout(t);
}
prevPriceRef.current = price;
}, [ticker?.tradePrice]);
const colorCls = change === 'RISE' ? 'mp-up' : change === 'FALL' ? 'mp-dn' : 'mp-even';
return (
<div
className={`mp-row ${isActive ? 'mp-row--active' : ''} ${flashClass}`}
onClick={onSelect}
>
{/* 즐겨찾기 별 */}
<button
className={`mp-star ${isFav ? 'mp-star--on' : ''}`}
onClick={onToggleFav}
title={isFav ? '관심 해제' : '관심 등록'}
>
{isFav ? '★' : '☆'}
</button>
{/* 이름 + 방향 지시자 */}
<div className="mp-name">
<span className="mp-korean-row">
<ChangeArrow change={change} />
<span className="mp-korean">{ticker?.koreanName ?? code}</span>
</span>
<span className="mp-code">{code}/KRW</span>
</div>
{/* 현재가 */}
<span className={`mp-price ${colorCls}`}>
{ticker
? (showUsd ? fmtUsd(ticker.tradePrice, usdRate) : fmtKrw(ticker.tradePrice))
: '-'}
</span>
{/* 전일대비 */}
<span className={`mp-change ${colorCls}`}>
{ticker ? fmtPct(ticker.changeRate) : '-'}
</span>
{/* 보유 토글 / 제거 버튼 */}
{onRemoveHold ? (
<button className="mp-hold-rm" onClick={onRemoveHold} title="보유 종목 제거">×</button>
) : showHoldBtn ? (
<button
className={`mp-hold-btn ${isHold ? 'mp-hold-btn--on' : ''}`}
onClick={onToggleHold}
title={isHold ? '보유 해제' : '보유 등록'}
>
{isHold ? '₩' : '+₩'}
</button>
) : null}
</div>
);
});
// ─── 메인 패널 ─────────────────────────────────────────────────────────────
export const MarketPanel: React.FC<MarketPanelProps> = ({
open, tickers, loading, usdRate, activeSymbol, onSymbolSelect,
}) => {
const [tab, setTab] = useState<Tab>('krw');
const [search, setSearch] = useState('');
const [favorites, setFavs] = useState<string[]>(getFavorites);
const [holdings, setHolds] = useState<string[]>(getHoldings);
const [holdSearch, setHoldSrch] = useState('');
const [showHoldAdd, setShowHoldAdd] = useState(false);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('desc');
const searchRef = useRef<HTMLInputElement>(null);
// 패널 열릴 때 검색창 포커스
useEffect(() => { if (open) setTimeout(() => searchRef.current?.focus(), 250); }, [open]);
// 검색 초기화 (탭 전환 시)
const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []);
// 컬럼 정렬 토글
const handleSort = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
return key;
}
setSortDir('desc');
return key;
});
}, []);
// 즐겨찾기 토글
const handleToggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
e.stopPropagation();
const row = tickers.get(market);
try {
const next = await toggleFavorite(market, { koreanName: row?.koreanName });
setFavs(next);
} catch (err) {
console.warn('[MarketPanel] watchlist toggle failed', err);
}
}, [tickers]);
// 보유 토글
const handleToggleHold = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
setHolds(prev =>
prev.includes(market) ? removeHolding(market) : addHolding(market)
);
}, []);
// 보유 제거 (보유 탭)
const handleRemoveHold = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
setHolds(removeHolding(market));
}, []);
// 보유 추가 검색에서 선택
const handleAddHolding = useCallback((market: string) => {
setHolds(addHolding(market));
setHoldSrch('');
setShowHoldAdd(false);
}, []);
// 탭별 마켓 목록 계산
const allKrwMarkets = useMemo(() => {
const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
// 24h 거래대금 내림차순 정렬
return keys.sort((a, b) =>
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
);
}, [tickers]);
const filtered = useMemo(() => {
let base: string[] = [];
switch (tab) {
case 'krw': case 'btc': base = allKrwMarkets; break;
case 'hold': base = holdings.filter(m => tickers.has(m) || true); break;
case 'fav': base = favorites; break;
}
if (search) {
const q = search.toUpperCase();
base = base.filter(m => {
const code = m.replace(/^KRW-/, '');
const name = tickers.get(m)?.koreanName ?? '';
return code.includes(q) || name.includes(search);
});
}
if (!sortKey) return base;
const mul = sortDir === 'asc' ? 1 : -1;
return [...base].sort((a, b) => {
const ta = tickers.get(a);
const tb = tickers.get(b);
switch (sortKey) {
case 'name': {
const na = ta?.koreanName ?? a.replace(/^KRW-/, '');
const nb = tb?.koreanName ?? b.replace(/^KRW-/, '');
return mul * na.localeCompare(nb, 'ko');
}
case 'price':
return mul * ((ta?.tradePrice ?? 0) - (tb?.tradePrice ?? 0));
case 'change':
return mul * ((ta?.changeRate ?? 0) - (tb?.changeRate ?? 0));
default:
return 0;
}
});
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]);
// 보유 탭 "추가" 검색 결과
const holdAddResults = useMemo(() => {
if (!holdSearch) return [];
const q = holdSearch.toUpperCase();
return allKrwMarkets
.filter(m => {
const code = m.replace(/^KRW-/, '');
const name = tickers.get(m)?.koreanName ?? '';
return code.includes(q) || name.includes(holdSearch);
})
.slice(0, 10);
}, [holdSearch, allKrwMarkets, tickers]);
const tabLabels: Record<Tab, string> = {
krw: '원화', btc: 'BTC', hold: '보유', fav: '관심',
};
return (
<div className={`market-panel ${open ? 'market-panel--open' : ''}`}>
{/* ── 패널 내용 ─────────────────────────────────────────────── */}
<div className="mp-body mp-body--full">
{/* 탭 헤더 */}
<div className="mp-tabs">
{(['krw', 'btc', 'hold', 'fav'] as Tab[]).map(t => (
<button
key={t}
className={`mp-tab ${tab === t ? 'mp-tab--active' : ''}`}
onClick={() => handleTab(t)}
>
{tabLabels[t]}
</button>
))}
</div>
{/* 검색 */}
<div className="mp-search-row">
<div className="mp-search-box">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" opacity={0.5}>
<circle cx="5.5" cy="5.5" r="4"/><line x1="9" y1="9" x2="12.5" y2="12.5"/>
</svg>
<input
ref={searchRef}
className="mp-search-input"
type="text"
placeholder="코인명/심볼검색"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="mp-search-clear" onClick={() => setSearch('')}></button>
)}
</div>
</div>
{/* 컬럼 헤더 */}
<div className="mp-list-header">
{(['name', 'price', 'change'] as SortKey[]).map(key => {
const label = key === 'name' ? '한글명' : key === 'price' ? '현재가' : '전일대비';
const cls = key === 'name' ? 'mp-h-name' : key === 'price' ? 'mp-h-price' : 'mp-h-change';
const active = sortKey === key;
return (
<button
key={key}
className={`${cls} mp-h-sort-btn ${active ? 'mp-h-sort-btn--active' : ''}`}
onClick={() => handleSort(key)}
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
>
{label}
<span className="mp-sort-icon">
{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}
</span>
</button>
);
})}
</div>
{/* 보유 탭: 추가 버튼 */}
{tab === 'hold' && (
<div className="mp-hold-add-area">
{showHoldAdd ? (
<div className="mp-hold-add-form">
<input
className="mp-hold-add-input"
placeholder="종목 검색..."
value={holdSearch}
onChange={e => setHoldSrch(e.target.value)}
autoFocus
/>
<button className="mp-hold-add-cancel" onClick={() => { setShowHoldAdd(false); setHoldSrch(''); }}></button>
{holdAddResults.length > 0 && (
<div className="mp-hold-add-dropdown">
{holdAddResults.map(m => (
<div key={m} className="mp-hold-add-item" onClick={() => handleAddHolding(m)}>
<span>{tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')}</span>
<span className="mp-hold-add-code">{m.replace(/^KRW-/, '')}</span>
</div>
))}
</div>
)}
</div>
) : (
<button className="mp-hold-add-btn" onClick={() => setShowHoldAdd(true)}>
+
</button>
)}
</div>
)}
{/* 마켓 목록 */}
<div className="mp-list">
{loading && (
<div className="mp-loading">
<div className="loading-spinner" style={{ width: 18, height: 18 }} />
<span> ...</span>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="mp-empty">
{tab === 'hold' ? '보유 종목이 없습니다' :
tab === 'fav' ? '관심 종목이 없습니다' :
'검색 결과 없음'}
</div>
)}
{!loading && filtered.map(market => (
<MarketRow
key={market}
market={market}
ticker={tickers.get(market)}
isActive={market === activeSymbol}
isFav={favorites.includes(market)}
isHold={holdings.includes(market)}
showUsd={tab === 'btc'}
showHoldBtn={tab === 'krw' || tab === 'btc' || tab === 'fav'}
usdRate={usdRate}
onSelect={() => onSymbolSelect(market)}
onToggleFav={e => handleToggleFav(market, e)}
onToggleHold={e => handleToggleHold(market, e)}
onRemoveHold={tab === 'hold' ? e => handleRemoveHold(market, e) : undefined}
/>
))}
</div>
</div>
</div>
);
};
export default MarketPanel;
@@ -0,0 +1,330 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
import {
getFavorites,
toggleFavorite,
FAVORITES_CHANGED_EVENT,
} from '../utils/marketStorage';
// ─── Types ───────────────────────────────────────────────────────────────────
interface MarketInfo {
market: string; // "KRW-BTC"
korean_name: string; // "비트코인"
english_name: string; // "Bitcoin"
symbol: string; // "BTC"
}
interface TickerInfo {
trade_price: number;
signed_change_rate: number; // 전일 대비 변화율 (소수, e.g. 0.015 = +1.5%)
acc_trade_price_24h: number; // 24h 거래대금 (KRW)
}
// ─── Constants ───────────────────────────────────────────────────────────────
const UPBIT_API = import.meta.env.DEV ? '/upbit-api/v1' : 'https://api.upbit.com/v1';
const TICKER_TTL = 3000; // 3초마다 시세 갱신
function formatVol(vol: number): string {
if (vol >= 1e12) return `${(vol / 1e12).toFixed(1)}`;
if (vol >= 1e8) return `${(vol / 1e8).toFixed(0)}`;
if (vol >= 1e4) return `${(vol / 1e4).toFixed(0)}`;
return vol.toLocaleString();
}
// ─── Component ───────────────────────────────────────────────────────────────
interface MarketSearchPanelProps {
currentMarket: string;
onSelect: (market: string) => void;
onClose: () => void;
/**
* 슬롯 내 심볼 버튼 클릭 시 전달되는 슬롯 컨테이너 rect.
* 제공되면 해당 슬롯 영역 안에서 패널이 표시된다.
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
*/
anchorRect?: DOMRect | null;
/** 외부 입력과 검색어 동기화 */
initialQuery?: string;
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
}) => {
const [markets, setMarkets] = useState<MarketInfo[]>([]);
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
const [query, setQuery] = useState(initialQuery);
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [loading, setLoading] = useState(true);
const refreshFavs = useCallback(() => {
setFavs(new Set(getFavorites()));
}, []);
const inputRef = useRef<HTMLInputElement>(null);
const tickerTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const marketCodesRef = useRef<string>('');
// ── 1. 마켓 목록 로드 ─────────────────────────────────────────────────────
useEffect(() => {
setLoading(true);
fetch(`${UPBIT_API}/market/all?isDetails=false`)
.then(r => r.json())
.then((data: Array<{ market: string; korean_name: string; english_name: string }>) => {
const krw = data
.filter(m => m.market.startsWith('KRW-'))
.map(m => ({ ...m, symbol: m.market.replace('KRW-', '') }));
setMarkets(krw);
marketCodesRef.current = krw.map(m => m.market).join(',');
// 한글명 캐시 저장 (Toolbar 등에서 사용)
const nameMap: Record<string, string> = {};
for (const m of krw) nameMap[m.market] = m.korean_name;
setMarketNames(nameMap);
setLoading(false);
})
.catch(() => setLoading(false));
setTimeout(() => inputRef.current?.focus(), 50);
refreshFavs();
}, [refreshFavs]);
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
useEffect(() => {
const onChanged = () => refreshFavs();
window.addEventListener(FAVORITES_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChanged);
}, [refreshFavs]);
// ── 2. 시세 데이터 폴링 (100개씩 배치 요청) ─────────────────────────────
const fetchTickers = useCallback(async () => {
const codes = marketCodesRef.current;
if (!codes) return;
const allCodes = codes.split(',');
const BATCH = 100;
const results: Array<{ market: string } & TickerInfo> = [];
for (let i = 0; i < allCodes.length; i += BATCH) {
const batch = allCodes.slice(i, i + BATCH).join(',');
try {
const data: Array<{ market: string } & TickerInfo> =
await fetch(`${UPBIT_API}/ticker?markets=${batch}`).then(r => r.json());
results.push(...data);
} catch { /* ignore partial failures */ }
}
setTickers(prev => {
const map: Record<string, TickerInfo> = { ...prev };
for (const t of results) map[t.market] = t;
return map;
});
}, []);
useEffect(() => {
if (markets.length === 0) return;
fetchTickers();
tickerTimer.current = setInterval(fetchTickers, TICKER_TTL);
return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); };
}, [markets, fetchTickers]);
// ── 3. 즐겨찾기 토글 ─────────────────────────────────────────────────────
const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
e.stopPropagation();
const row = markets.find(m => m.market === market);
try {
const next = await toggleFavorite(market, {
koreanName: row?.korean_name,
englishName: row?.english_name,
});
setFavs(new Set(next));
} catch (err) {
console.warn('[MarketSearchPanel] watchlist toggle failed', err);
}
}, [markets]);
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
const q = query.trim();
const filtered = markets.filter(m => {
if (!q) return true;
return (
m.korean_name.includes(q) ||
m.symbol.toUpperCase().includes(q.toUpperCase()) ||
m.english_name.toLowerCase().includes(q.toLowerCase()) ||
m.market.toUpperCase().includes(q.toUpperCase())
);
});
const sorted = [...filtered].sort((a, b) => {
const fa = favs.has(a.market) ? 1 : 0;
const fb = favs.has(b.market) ? 1 : 0;
if (fb !== fa) return fb - fa;
// 즐겨찾기 동순위: 24h 거래대금 내림차순
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
return vb - va;
});
// ── Render ────────────────────────────────────────────────────────────────
// anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다.
const HEADER_H = 30; // 슬롯 헤더 높이 (약 30px)
const MIN_W = 260;
// LWC v5 canvas 레이어가 내부적으로 높은 z-index를 사용하므로
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
const PANEL_Z = 99999;
const panelStyle: React.CSSProperties | undefined = anchorRect
? {
position: 'fixed',
top: anchorRect.top + HEADER_H,
left: anchorRect.left,
width: Math.max(anchorRect.width, MIN_W),
height: anchorRect.height - HEADER_H,
maxWidth: anchorRect.width,
zIndex: PANEL_Z,
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
background: 'var(--bg2, #24283b)',
color: 'var(--text, #c0caf5)',
border: '1px solid var(--border, rgba(122,162,247,0.15))',
}
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
const backdropStyle: React.CSSProperties | undefined = anchorRect
? {
position: 'fixed',
top: anchorRect.top,
left: anchorRect.left,
width: anchorRect.width,
height: anchorRect.height,
zIndex: PANEL_Z - 1,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'none',
}
: undefined;
return (
<>
{/* 배경 오버레이 (클릭 시 닫기) */}
<div
className="msp-backdrop"
style={backdropStyle}
onClick={onClose}
/>
{/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
<div
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}`}
style={panelStyle}
>
{/* 검색 입력 */}
<div className="msp-search-row">
<span className="msp-search-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</span>
<input
ref={inputRef}
className="msp-input"
placeholder="종목명 또는 심볼 검색"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter' && sorted.length > 0) {
onSelect(sorted[0].market);
onClose();
}
}}
/>
{query && (
<button className="msp-btn-icon" title="초기화" onClick={() => setQuery('')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
<button className="msp-btn-icon" title="닫기" onClick={onClose}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
</div>
{/* 컬럼 헤더 */}
<div className="msp-header">
<span className="msp-col msp-col-fav" />
<span className="msp-col msp-col-name"></span>
<span className="msp-col msp-col-price"></span>
<span className="msp-col msp-col-change"></span>
<span className="msp-col msp-col-vol"></span>
</div>
{/* 목록 */}
<div className="msp-list">
{loading && (
<div className="msp-empty"> ...</div>
)}
{!loading && sorted.length === 0 && (
<div className="msp-empty"> </div>
)}
{sorted.map(m => {
const tk = tickers[m.market];
const isFav = favs.has(m.market);
const isActive = m.market === currentMarket;
const rate = tk ? tk.signed_change_rate * 100 : null;
const isUp = rate !== null && rate >= 0;
return (
<div
key={m.market}
className={`msp-item${isActive ? ' active' : ''}`}
onClick={() => { onSelect(m.market); onClose(); }}
>
{/* 즐겨찾기 */}
<span
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
onClick={e => toggleFav(m.market, e)}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
{isFav ? '★' : '☆'}
</span>
{/* 이름 */}
<span className="msp-col msp-col-name">
<span className="msp-kor">{m.korean_name}</span>
<span className="msp-sym">{m.symbol}</span>
</span>
{/* 현재가 */}
<span className="msp-col msp-col-price">
{tk ? tk.trade_price.toLocaleString() : '-'}
</span>
{/* 전일대비 */}
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
</span>
{/* 거래대금 */}
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
</div>
);
})}
</div>
</div>
</>
);
};
export default MarketSearchPanel;
@@ -0,0 +1,92 @@
/**
* 모바일 차트 화면 하단 고정 도크 — 종목·매매·호가·그리기·차트 도구
*/
import React from 'react';
export type MobileDockPanel = 'none' | 'market' | 'trade' | 'orderbook';
interface Props {
marketOpen: boolean;
rightOpen: boolean;
rightTab: 'trade' | 'orderbook';
drawingTool: string;
onToggleMarket: () => void;
onOpenTrade: () => void;
onOpenOrderbook: () => void;
onClosePanels: () => void;
onTool: (tool: string) => void;
}
const MobileChartDock: React.FC<Props> = ({
marketOpen,
rightOpen,
rightTab,
drawingTool,
onToggleMarket,
onOpenTrade,
onOpenOrderbook,
onClosePanels,
onTool,
}) => {
const tradeActive = rightOpen && rightTab === 'trade';
const obActive = rightOpen && rightTab === 'orderbook';
return (
<nav className="mobile-chart-dock" aria-label="차트 모바일 메뉴">
<button
type="button"
className={`mcd-btn${marketOpen ? ' active' : ''}`}
onClick={onToggleMarket}
aria-pressed={marketOpen}
>
<span className="mcd-icon" aria-hidden>📋</span>
<span className="mcd-label"></span>
</button>
<button
type="button"
className={`mcd-btn${tradeActive ? ' active' : ''}`}
onClick={onOpenTrade}
aria-pressed={tradeActive}
>
<span className="mcd-icon" aria-hidden>💰</span>
<span className="mcd-label"></span>
</button>
<button
type="button"
className={`mcd-btn${obActive ? ' active' : ''}`}
onClick={onOpenOrderbook}
aria-pressed={obActive}
>
<span className="mcd-icon" aria-hidden>📊</span>
<span className="mcd-label"></span>
</button>
<button
type="button"
className={`mcd-btn${drawingTool === 'zoom' ? ' active' : ''}`}
onClick={() => {
onClosePanels();
onTool(drawingTool === 'zoom' ? 'cursor' : 'zoom');
}}
aria-pressed={drawingTool === 'zoom'}
title="영역 드래그로 차트 확대"
>
<span className="mcd-icon" aria-hidden>🔍</span>
<span className="mcd-label"></span>
</button>
<button
type="button"
className={`mcd-btn${drawingTool === 'cursor' ? ' active' : ''}`}
onClick={() => {
onClosePanels();
onTool('cursor');
}}
aria-pressed={drawingTool === 'cursor'}
>
<span className="mcd-icon" aria-hidden>👆</span>
<span className="mcd-label"></span>
</button>
</nav>
);
};
export default MobileChartDock;
@@ -0,0 +1,62 @@
/**
* 알림 Context 연동 UI (Provider 하위에서만 사용)
*/
import React from 'react';
import TopMenuBar, { type MenuPage } from './TopMenuBar';
import type { Theme } from '../types';
import type { AuthSession } from '../utils/auth';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { useTradeNotification } from '../contexts/TradeNotificationContext';
interface NotifyTopMenuBarProps {
activePage: MenuPage;
theme: Theme;
onPage: (page: MenuPage) => void;
onTheme: () => void;
authUser?: AuthSession | null;
onLoginClick?: () => void;
onLogout?: () => void;
menuPermissions?: Record<string, boolean>;
guestMode?: boolean;
tradeAlertPopupPosition?: string;
tradeAlertPopupLayout?: string;
tradeAlertPopupGridCols?: number;
onTradeAlertPopupPosition?: (v: TradeAlertPopupPosition) => void;
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
onTradeAlertPopupGridCols?: (v: number) => void;
}
export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
const { unreadCount, toastNotifications, dismissAllToasts } = useTradeNotification();
return (
<TopMenuBar
{...props}
tradeNotifyUnread={unreadCount}
tradeNotifyToastCount={toastNotifications.length}
onOpenNotifications={() => props.onPage('notifications')}
onDismissAllNotifyPopups={dismissAllToasts}
tradeAlertPopupPosition={props.tradeAlertPopupPosition}
tradeAlertPopupLayout={props.tradeAlertPopupLayout}
tradeAlertPopupGridCols={props.tradeAlertPopupGridCols}
onTradeAlertPopupPosition={props.onTradeAlertPopupPosition}
onTradeAlertPopupLayout={props.onTradeAlertPopupLayout}
onTradeAlertPopupGridCols={props.onTradeAlertPopupGridCols}
/>
);
};
import Toolbar, { type ToolbarProps } from './Toolbar';
export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: () => void }> = ({
onOpenNotifyList,
...toolbarProps
}) => {
const { unreadCount } = useTradeNotification();
return (
<Toolbar
{...toolbarProps}
tradeNotifyUnread={unreadCount}
onOpenTradeNotifications={onOpenNotifyList}
/>
);
};
@@ -0,0 +1,207 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import type { NumericParamSpec } from '../utils/indicatorParamSpec';
interface NumericParamInputProps {
value: number;
spec: NumericParamSpec;
disabled?: boolean;
className?: string;
onChange: (v: number) => void;
}
function formatOption(v: number, decimal: boolean): string {
return decimal
? (Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.?0+$/, ''))
: String(Math.round(v));
}
/** 입력 문자열을 숫자만(선택적 -, 소수점) 허용하도록 필터 */
function filterTyping(raw: string, allowNegative: boolean, decimal: boolean): string {
let s = raw.replace(/[^\d.\-]/g, '');
if (!allowNegative) {
s = s.replace(/-/g, '');
} else {
const first = s.startsWith('-') ? '-' : '';
s = first + s.slice(first ? 1 : 0).replace(/-/g, '');
}
if (!decimal) {
s = s.replace(/\./g, '');
} else {
const parts = s.replace('-', '').split('.');
if (parts.length > 2) {
s = (s.startsWith('-') ? '-' : '') + parts[0] + '.' + parts.slice(1).join('');
}
}
return s;
}
function isPartialNumber(s: string, allowNegative: boolean, decimal: boolean): boolean {
if (s === '' || s === '-') return true;
if (decimal && (s === '.' || s === '-.')) return true;
if (decimal) {
return allowNegative ? /^-?\d*\.?\d*$/.test(s) : /^\d*\.?\d*$/.test(s);
}
return allowNegative ? /^-?\d*$/.test(s) : /^\d*$/.test(s);
}
const NumericParamInput: React.FC<NumericParamInputProps> = ({
value,
spec,
disabled = false,
className = 'ism-input',
onChange,
}) => {
const wrapRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [str, setStr] = useState(() => formatOption(value, spec.decimal));
const [open, setOpen] = useState(false);
const prevValueRef = useRef(value);
const editingRef = useRef(false);
useEffect(() => {
if (editingRef.current) return;
if (value !== prevValueRef.current) {
prevValueRef.current = value;
setStr(formatOption(value, spec.decimal));
}
}, [value, spec.decimal]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
const tid = window.setTimeout(() => {
document.addEventListener('mousedown', onDoc, true);
}, 0);
return () => {
window.clearTimeout(tid);
document.removeEventListener('mousedown', onDoc, true);
};
}, [open]);
const commit = useCallback((raw: string) => {
const trimmed = raw.trim();
if (trimmed === '' || trimmed === '-' || trimmed === '-.') {
setStr(formatOption(prevValueRef.current, spec.decimal));
return;
}
const v = parseFloat(trimmed);
if (isNaN(v)) {
setStr(formatOption(prevValueRef.current, spec.decimal));
return;
}
let clamped = Math.max(spec.min, Math.min(spec.max, v));
if (!spec.decimal) clamped = Math.round(clamped);
else clamped = roundTo(clamped, spec.step < 1 ? 2 : 1);
prevValueRef.current = clamped;
onChange(clamped);
setStr(formatOption(clamped, spec.decimal));
}, [spec, onChange]);
const handleChange = (raw: string) => {
const filtered = filterTyping(raw, spec.allowNegative, spec.decimal);
if (!isPartialNumber(filtered, spec.allowNegative, spec.decimal)) return;
setStr(filtered);
};
const selectOption = (opt: number) => {
prevValueRef.current = opt;
onChange(opt);
setStr(formatOption(opt, spec.decimal));
setOpen(false);
inputRef.current?.focus();
};
const toggleList = () => {
if (disabled) return;
setOpen(prev => !prev);
inputRef.current?.focus();
};
return (
<div
className="ism-num-combo"
ref={wrapRef}
onMouseDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<input
ref={inputRef}
type="text"
inputMode={spec.decimal ? 'decimal' : 'numeric'}
className={className}
value={str}
disabled={disabled}
autoComplete="off"
spellCheck={false}
onChange={e => handleChange(e.target.value)}
onFocus={() => {
editingRef.current = true;
}}
onBlur={e => {
const related = e.relatedTarget as Node | null;
window.setTimeout(() => {
if (document.activeElement === inputRef.current) return;
if (related && wrapRef.current?.contains(related)) return;
editingRef.current = false;
commit(e.target.value);
setOpen(false);
}, 0);
}}
onKeyDown={e => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault();
if (!disabled) setOpen(true);
return;
}
if (e.key === 'Enter') {
commit((e.target as HTMLInputElement).value);
setOpen(false);
}
if (e.key === 'Escape') setOpen(false);
}}
/>
<button
type="button"
className="ism-num-combo-toggle"
tabIndex={-1}
disabled={disabled}
aria-label="기간 목록"
aria-expanded={open}
onMouseDown={e => {
e.preventDefault();
toggleList();
}}
>
</button>
{open && !disabled && (
<ul className="ism-num-combo-list" role="listbox">
{spec.options.map(opt => (
<li key={opt}>
<button
type="button"
className={`ism-num-combo-opt${opt === value ? ' selected' : ''}`}
onMouseDown={e => {
e.preventDefault();
selectOption(opt);
}}
>
{formatOption(opt, spec.decimal)}
</button>
</li>
))}
</ul>
)}
</div>
);
};
function roundTo(v: number, decimals: number): number {
const f = 10 ** decimals;
return Math.round(v * f) / f;
}
export default NumericParamInput;
+229
View File
@@ -0,0 +1,229 @@
import React from 'react';
import type { Drawing, DrawingToolId } from '../types';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
interface ObjectTreeProps {
drawings: Drawing[];
onToggleVisible: (id: string) => void;
onRemove: (id: string) => void;
onClose: () => void;
}
// 드로잉 타입 → 한글 이름
const TOOL_LABEL: Partial<Record<DrawingToolId, string>> = {
cursor: '커서',
hline: '수평선',
hline_ray: '수평 레이',
vline: '수직선',
cross_line: '십자선',
info_line: '정보선',
trendline: '추세선',
extended_line: '연장 추세선',
ray: '레이',
arrow: '화살표 추세선',
fib: '피보나치 되돌림',
fib_ext: '피보나치 확장',
fib_channel: '피보나치 채널',
fibtz: '피보나치 타임존',
fib_speed_fan: '스피드 리지스턴스 팬',
fib_trend_time: '추세기반 피보나치 시간',
fib_circles: '피보나치 서클',
fib_spiral: '피보나치 스파이럴',
fib_speed_arc: '스피드 리지스턴스 아크',
fib_wedge: '피보나치 웻지',
pitchfork: '앤드류스 피치포크',
pitchfork_schiff: '쉬프 피치포크',
pitchfork_inside: '인사이드 피치포크',
gann_box: '갠 박스',
gann_square: '갠 스퀘어',
gann_fan: '갠 팬',
xabcd: 'XABCD 패턴',
cypher: '사이퍼 패턴',
abcd: 'ABCD 패턴',
head_shoulders: '헤드 앤 숄더',
channel: '추세 채널',
parallel_channel: '평행 채널',
disjoint_channel: '분리 채널',
flat_top_bottom: '플랫 탑/바텀 채널',
rect: '사각형',
rotated_rect: '회전 사각형',
circle: '원',
ellipse: '타원',
triangle: '삼각형',
parallelogram: '평행사변형',
polyline: '다각형',
arc: '호',
text: '텍스트',
callout: '콜아웃',
price_note: '가격 주석',
arrow_mark_up: '위쪽 화살표',
arrow_mark_down: '아래쪽 화살표',
price_label: '가격 레이블',
pencil: '자유곡선',
highlight: '형광펜',
trend_angle: '추세각',
long_position: '매수 포지션',
short_position: '숏 포지션',
forecast: '예측',
bar_pattern: '봉패턴',
ghost_feed: '고스트피드',
projection_tool: '프로젝션',
anchored_vwap: '앵커드 VWAP',
fixed_volume_profile: '볼륨 프로파일',
price_range: '가격범위',
wedge_pattern: '세모 패턴',
three_drives: '쓰리 드라이브 패턴',
elliott_impulse: '엘리엇 임펄스 파동',
elliott_correction: '엘리엇 코렉션 파동',
elliott_triangle_wave: '엘리엇 트라이앵글 웨이브',
elliott_double_combo: '엘리엇 다블콤보 파동',
arrow_mark: '화살표',
anchored_arrow: '앵커 화살표',
arrow_mark_left: '왼화살표',
arrow_mark_right: '오른화살표',
fixed_text: '고정위치문자',
note_text: '노트',
pin_mark: '핀',
table_tool: '테이블',
comment_mark: '코멘트',
guide_line: '길잡이',
flag_mark: '플래그',
measure: '가격 측정',
date_range: '날짜 범위',
date_price_range: '날짜&가격 범위',
data_window: '데이터 창',
zoom: '확대/축소',
magnifier: '돋보기',
};
// 아이콘 기본값
const DefaultIcon = () => <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><circle cx="6" cy="6" r="4"/></svg>;
// 타입별 아이콘
const ToolIcon: Partial<Record<DrawingToolId, React.ReactNode>> = {
cursor: <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M2 1 L2 9 L4 7 L6 10 L7 9.5 L5 6.5 L8 6.5 Z"/></svg>,
hline: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="1" y1="6" x2="11" y2="6"/></svg>,
vline: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="6" y1="1" x2="6" y2="11"/></svg>,
trendline: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="2" y1="10" x2="10" y2="2"/></svg>,
ray: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="2" y1="10" x2="11" y2="2"/><circle cx="2" cy="10" r="1.2" fill="currentColor"/></svg>,
fib: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1"><line x1="1" y1="2" x2="11" y2="2"/><line x1="1" y1="5" x2="11" y2="5" strokeOpacity="0.6"/><line x1="1" y1="7" x2="11" y2="7"/><line x1="1" y1="10" x2="11" y2="10"/></svg>,
fibtz: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1"><line x1="1" y1="1" x2="1" y2="11"/><line x1="3" y1="1" x2="3" y2="11" strokeOpacity="0.7"/><line x1="6" y1="1" x2="6" y2="11" strokeOpacity="0.5"/><line x1="9" y1="1" x2="9" y2="11" strokeOpacity="0.6"/><line x1="11" y1="1" x2="11" y2="11" strokeOpacity="0.4"/></svg>,
rect: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="2" y="3" width="8" height="6"/></svg>,
circle: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><circle cx="6" cy="6" r="4.5"/></svg>,
triangle: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><path d="M6 2 L11 10 L1 10 Z"/></svg>,
channel: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="1" y1="8" x2="11" y2="3"/><line x1="1" y1="11" x2="11" y2="6" strokeDasharray="2 2"/></svg>,
measure: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="1" y1="6" x2="11" y2="6"/><line x1="1" y1="4" x2="1" y2="8"/><line x1="11" y1="4" x2="11" y2="8"/></svg>,
text: <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><text x="2" y="10" fontSize="10" fontWeight="700" fontFamily="sans-serif">T</text></svg>,
pencil: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><path d="M2 10 L2.5 7 L9 1.5 Q10 1 11 2 Q12 3 11 4 L4.5 10 Z"/></svg>,
pitchfork: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="2" y1="6" x2="7" y2="5"/><line x1="7" y1="5" x2="11" y2="3"/><line x1="7" y1="5" x2="11" y2="7"/></svg>,
};
const IcEye = ({ visible }: { visible: boolean }) => visible ? (
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M1 6.5 Q4 2 6.5 2 Q9 2 12 6.5 Q9 11 6.5 11 Q4 11 1 6.5 Z"/>
<circle cx="6.5" cy="6.5" r="1.8"/>
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M1 6.5 Q4 2 6.5 2 Q9 2 12 6.5 Q9 11 6.5 11 Q4 11 1 6.5 Z" strokeOpacity="0.3"/>
<line x1="2" y1="2" x2="11" y2="11"/>
</svg>
);
const IcTrash = () => (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
<line x1="2" y1="4" x2="10" y2="4"/>
<path d="M4.5 4 V2.5 Q4.5 2 6 2 Q7.5 2 7.5 2.5 V4"/>
<path d="M3 4 L3.5 10.5 Q3.5 11 4 11 L8 11 Q8.5 11 8.5 10.5 L9 4"/>
</svg>
);
const ObjectTree: React.FC<ObjectTreeProps> = ({ drawings, onToggleVisible, onRemove, onClose }) => {
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div className="obj-tree-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div
ref={panelRef}
className="obj-tree-panel"
style={{
...panelStyle,
zIndex: 5001,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header obj-tree-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title obj-tree-title"> </span>
<button type="button" className="gc-popup-close obj-tree-close" onClick={onClose}></button>
</div>
{drawings.length === 0 ? (
<div className="obj-tree-empty"> </div>
) : (
<div className="obj-tree-list">
{[...drawings].reverse().map((d, idx) => {
const visible = d.visible !== false;
return (
<div
key={d.id}
className={`obj-tree-item${!visible ? ' hidden' : ''}`}
>
{/* 색상 스와치 */}
<span
className="obj-tree-swatch"
style={{ background: d.color }}
/>
{/* 타입 아이콘 */}
<span className="obj-tree-icon" style={{ color: d.color }}>
{ToolIcon[d.type as DrawingToolId] ?? <DefaultIcon />}
</span>
{/* 이름 */}
<span className="obj-tree-name">
{TOOL_LABEL[d.type as DrawingToolId] ?? d.type}
{d.text && <span className="obj-tree-text-preview"> "{d.text}"</span>}
</span>
{/* 번호 (역순) */}
<span className="obj-tree-num">#{drawings.length - idx}</span>
{/* 가시성 토글 */}
<button
className="obj-tree-btn"
title={visible ? '숨기기' : '표시'}
onClick={() => onToggleVisible(d.id)}
>
<IcEye visible={visible} />
</button>
{/* 삭제 */}
<button
className="obj-tree-btn obj-tree-del"
title="삭제"
onClick={() => onRemove(d.id)}
>
<IcTrash />
</button>
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default ObjectTree;
+362
View File
@@ -0,0 +1,362 @@
/**
* 실시간 호가 — 증권앱형 통합 레이아웃
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
*/
import React, { memo, useState, useEffect } from 'react';
import { formatNowClock, useDisplayTimezone } from '../utils/timezone';
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
import type { RecentTrade } from '../hooks/useUpbitRecentTrades';
import { getKoreanName } from '../utils/marketNameCache';
function fmtPrice(p: number): string {
if (p == null || !isFinite(p)) return '-';
if (p >= 1_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (p >= 1) return p.toFixed(2);
return p.toFixed(6);
}
function fmtSize(s: number): string {
if (s == null || !isFinite(s)) return '-';
if (s >= 1_000_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
if (s >= 1) return s.toFixed(4);
return s.toFixed(6);
}
function fmtTradeVol(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1_000) return Math.round(v).toLocaleString('ko-KR');
return v.toFixed(4);
}
function fmtPct(price: number, prevClose: number): string {
if (!prevClose) return '';
const rate = ((price - prevClose) / prevClose) * 100;
return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`;
}
function fmtTotalSize(s: number): string {
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (s >= 1) return s.toFixed(2);
return s.toFixed(4);
}
function coinCode(market: string): string {
return market.replace(/^KRW-/, '');
}
function pctClass(pct: string): string {
if (pct.startsWith('+')) return 'ob-td-pct--up';
if (pct.startsWith('-')) return 'ob-td-pct--dn';
return '';
}
function useClock(): string {
const tz = useDisplayTimezone();
const [t, setT] = useState(() => formatNowClock(tz, true));
useEffect(() => {
const tick = () => setT(formatNowClock(tz, true));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [tz]);
return t;
}
const COLGROUP = (
<colgroup>
<col className="ob-col-side" />
<col className="ob-col-price" />
<col className="ob-col-side" />
</colgroup>
);
const AskTableRow = memo(function AskTableRow({
unit, prevClose, onClick,
}: {
unit: OrderbookDisplayUnit;
prevClose: number;
onClick?: (price: number, type: 'ask' | 'bid') => void;
}) {
const pct = fmtPct(unit.price, prevClose);
return (
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'ask') : undefined}>
<td className="ob-td ob-td-qty ob-td-qty--ask">
<div className="ob-td-bar-wrap">
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
</div>
</td>
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
<td className={`ob-td ob-td-pct ${pctClass(pct)}`}>{pct}</td>
</tr>
);
});
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
const BidTableRow = memo(function BidTableRow({
unit, prevClose, onClick,
}: {
unit: OrderbookDisplayUnit;
prevClose: number;
onClick?: (price: number, type: 'ask' | 'bid') => void;
}) {
const pct = fmtPct(unit.price, prevClose);
return (
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'bid') : undefined}>
<td className={`ob-td ob-td-pct ob-td-pct--left ${pctClass(pct)}`}>{pct}</td>
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
<td className="ob-td ob-td-qty ob-td-qty--bid">
<div className="ob-td-bar-wrap ob-td-bar-wrap--right">
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
</div>
</td>
</tr>
);
});
function QuoteHeader({
market, tickerInfo, prevClose, totalAskSize, totalBidSize, wsStatus,
}: {
market: string;
tickerInfo?: OrderbookTickerInfo;
prevClose: number;
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
}) {
const price = tickerInfo?.tradePrice ?? 0;
const change = price > 0 && prevClose > 0 ? price - prevClose : (tickerInfo?.changePrice ?? 0);
const rate = tickerInfo?.changeRate != null
? tickerInfo.changeRate * 100
: (prevClose > 0 && price > 0 ? (change / prevClose) * 100 : 0);
const isUp = change >= 0;
const vol = tickerInfo?.accTradeVolume24 ?? 0;
const bidRatio = totalAskSize + totalBidSize > 0
? (totalBidSize / (totalAskSize + totalBidSize)) * 100
: 50;
const code = coinCode(market);
return (
<div className="ob-quote-header">
<div className="ob-quote-left">
<span className="ob-quote-name">{getKoreanName(market)}</span>
<div className="ob-quote-price-row">
<span className={`ob-quote-price ${isUp ? 'ob-quote-price--up' : 'ob-quote-price--dn'}`}>
{price > 0 ? fmtPrice(price) : '-'}
</span>
<span className={`ob-quote-change ${isUp ? 'ob-quote-change--up' : 'ob-quote-change--dn'}`}>
{change >= 0 ? '+' : ''}{Math.abs(change) >= 1000
? Math.round(change).toLocaleString('ko-KR')
: change.toFixed(change >= 1 ? 0 : 2)}
</span>
<span className={`ob-quote-rate ${isUp ? 'ob-quote-rate--up' : 'ob-quote-rate--dn'}`}>
{rate >= 0 ? '+' : ''}{rate.toFixed(2)}%
</span>
</div>
</div>
<div className="ob-quote-right">
<div className="ob-quote-vol">
<span className="ob-quote-vol-label"></span>
<span className="ob-quote-vol-val">
{vol > 0 ? `${Math.round(vol).toLocaleString('ko-KR')} ${code}` : '-'}
</span>
</div>
<div className="ob-quote-ratio">
<span className="ob-quote-ratio-label"></span>
<span className="ob-quote-ratio-val ob-quote-ratio-val--up">{bidRatio.toFixed(2)}%</span>
</div>
<WsDot status={wsStatus} />
</div>
</div>
);
}
function WsDot({ status }: { status: WsStatus }) {
const cls = status === 'connected' ? 'ob-dot--ok'
: status === 'connecting' ? 'ob-dot--wait' : 'ob-dot--err';
return <span className={`ob-dot ${cls}`}><span className="ob-dot-circle" /></span>;
}
function StatsRail({ tickerInfo, prevClose }: {
tickerInfo: OrderbookTickerInfo;
prevClose: number;
}) {
return (
<aside className="ob-stats-rail">
<div className="ob-stat-row"><span className="ob-stat-k"></span><span className="ob-stat-v">{prevClose > 0 ? fmtPrice(prevClose) : '-'}</span></div>
<div className="ob-stat-row"><span className="ob-stat-k"></span><span className="ob-stat-v">{tickerInfo.openingPrice != null ? fmtPrice(tickerInfo.openingPrice) : '-'}</span></div>
<div className="ob-stat-row"><span className="ob-stat-k"></span><span className="ob-stat-v ob-stat-v--up">{tickerInfo.highPrice != null ? fmtPrice(tickerInfo.highPrice) : '-'}</span></div>
<div className="ob-stat-row"><span className="ob-stat-k"></span><span className="ob-stat-v ob-stat-v--dn">{tickerInfo.lowPrice != null ? fmtPrice(tickerInfo.lowPrice) : '-'}</span></div>
</aside>
);
}
function TradesPanel({ trades, strength }: { trades: RecentTrade[]; strength: number | null }) {
return (
<aside className="ob-trades-panel">
<div className="ob-trades-strength">
<span className="ob-trades-strength-k"></span>
<span className={`ob-trades-strength-v${strength != null && strength >= 100 ? ' ob-trades-strength-v--up' : ''}`}>
{strength != null ? `${strength >= 100 ? '+' : ''}${strength.toFixed(2)}%` : '-'}
</span>
</div>
<table className="ob-table ob-table--trades">
<thead><tr><th></th><th></th></tr></thead>
<tbody>
{trades.length === 0 ? (
<tr><td colSpan={2} className="ob-td-empty"></td></tr>
) : trades.map((t, i) => (
<tr key={`${t.time}-${i}`}>
<td className="ob-td ob-td-price-sm">{fmtPrice(t.price)}</td>
<td className={t.side === 'bid' ? 'ob-td ob-trades-vol--buy' : 'ob-td ob-trades-vol--sell'}>
{fmtTradeVol(t.volume)}
</td>
</tr>
))}
</tbody>
</table>
</aside>
);
}
export interface OrderbookTickerInfo {
tradePrice: number | null;
changeRate: number | null;
changePrice?: number | null;
accTradePrice24: number;
accTradeVolume24?: number | null;
highPrice: number | null;
lowPrice: number | null;
openingPrice: number | null;
}
export interface OrderbookPanelProps {
market: string;
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
bestAsk: number;
bestBid: number;
spread: number;
spreadPct: number;
prevClose: number;
tickerInfo?: OrderbookTickerInfo;
recentTrades?: RecentTrade[];
tradeStrength?: number | null;
onRowClick?: (price: number, rowType: 'ask' | 'bid') => void;
}
const SUB_TABS = ['호가', '체결', '일별', '차트'] as const;
export const OrderbookPanel = memo(function OrderbookPanel({
market, asks, bids, totalAskSize, totalBidSize, wsStatus,
prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick,
}: OrderbookPanelProps) {
const isEmpty = asks.length === 0 && bids.length === 0;
const midPrice = tickerInfo?.tradePrice ?? 0;
const midPct = midPrice > 0 ? fmtPct(midPrice, prevClose) : '';
const clock = useClock();
const isUp = (tickerInfo?.changeRate ?? 0) >= 0;
return (
<div className="ob-panel ob-panel--classic">
<QuoteHeader
market={market}
tickerInfo={tickerInfo}
prevClose={prevClose}
totalAskSize={totalAskSize}
totalBidSize={totalBidSize}
wsStatus={wsStatus}
/>
<nav className="ob-subtabs" aria-label="호가 하위 메뉴">
{SUB_TABS.map((label, i) => (
<button
key={label}
type="button"
className={`ob-subtab${i === 0 ? ' ob-subtab--active' : ''}`}
disabled={i !== 0}
>
{label}
</button>
))}
</nav>
{isEmpty ? (
<div className="ob-empty">
<div className="loading-spinner" style={{ width: 16, height: 16 }} />
<span> ...</span>
</div>
) : (
<div className="ob-body">
{/* 매도 구간 + 우측 시세 */}
<div className="ob-block ob-block--ask">
<div className="ob-block-table">
<div className="ob-table-scroll ob-table-scroll--ask">
<table className="ob-table ob-table--hoga">
{COLGROUP}
<tbody>
{asks.map((unit, i) => (
<AskTableRow key={`ask-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
))}
</tbody>
</table>
</div>
</div>
{tickerInfo && <StatsRail tickerInfo={tickerInfo} prevClose={prevClose} />}
</div>
{/* 현재가 (가운데 열 정렬) */}
<table className="ob-table ob-table--hoga ob-table--mid">
{COLGROUP}
<tbody>
<tr className="ob-tr-mid">
<td className="ob-td ob-td--pad" />
<td className={`ob-td ob-td-price ob-td-price--current ${isUp ? 'ob-td-price--up' : 'ob-td-price--dn'}`}>
<span className="ob-mid-price">{midPrice > 0 ? fmtPrice(midPrice) : '-'}</span>
{midPct && <span className={`ob-mid-pct ${pctClass(midPct)}`}>{midPct}</span>}
</td>
<td className="ob-td ob-td--pad" />
</tr>
</tbody>
</table>
{/* 매수 구간 + 좌측 체결 */}
<div className="ob-block ob-block--bid">
<TradesPanel trades={recentTrades} strength={tradeStrength} />
<div className="ob-block-table">
<div className="ob-table-scroll ob-table-scroll--bid">
<table className="ob-table ob-table--hoga">
{COLGROUP}
<tbody>
{bids.map((unit, i) => (
<BidTableRow key={`bid-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{!isEmpty && (
<footer className="ob-summary-bar">
<div className="ob-summary ob-summary--ask">
<span className="ob-summary-num">{fmtTotalSize(totalAskSize)}</span>
</div>
<div className="ob-summary ob-summary--time">{clock}</div>
<div className="ob-summary ob-summary--bid">
<span className="ob-summary-num">{fmtTotalSize(totalBidSize)}</span>
</div>
</footer>
)}
</div>
);
});
export default OrderbookPanel;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,317 @@
/**
* 모의투자 화면 — 계좌 요약, 보유종목, 체결내역
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
loadPaperSummary,
loadPaperTrades,
resetPaperAccount,
type PaperPositionDto,
type PaperSummaryDto,
type PaperTradeDto,
} from '../utils/backendApi';
import { getKoreanName } from '../utils/marketNameCache';
import { fmtKrw } from './TradeOrderPanel';
type TabId = 'overview' | 'positions' | 'trades';
interface Props {
tickers?: Map<string, { tradePrice: number | null }>;
onGoChart?: (market: string) => void;
/** 외부 체결(알림 모달 등) 후 목록 갱신 트리거 */
refreshKey?: number;
}
function buildMarkPrices(
summary: PaperSummaryDto | null,
tickers?: Map<string, { tradePrice: number | null }>,
): Record<string, number> {
const m: Record<string, number> = {};
if (summary?.positions) {
for (const p of summary.positions) {
const t = tickers?.get(p.symbol)?.tradePrice;
if (t != null && t > 0) m[p.symbol] = t;
}
}
return m;
}
function positionPriceKey(
positions: PaperPositionDto[] | undefined,
tickers?: Map<string, { tradePrice: number | null }>,
): string {
if (!positions?.length) return '';
return positions
.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`)
.join('|');
}
const PaperTradingPage: React.FC<Props> = ({ tickers, onGoChart, refreshKey = 0 }) => {
const [tab, setTab] = useState<TabId>('overview');
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const tickersRef = useRef(tickers);
tickersRef.current = tickers;
const summaryRef = useRef(summary);
summaryRef.current = summary;
const loadData = useCallback(async (opts?: { showSpinner?: boolean }) => {
const showSpinner = opts?.showSpinner ?? false;
if (showSpinner) setRefreshing(true);
setError(null);
try {
const base = await loadPaperSummary();
const marks = buildMarkPrices(base, tickersRef.current);
const full = Object.keys(marks).length > 0
? await loadPaperSummary(marks)
: base;
setSummary(full);
setTrades(await loadPaperTrades());
lastPriceKeyRef.current = positionPriceKey(full?.positions, tickersRef.current);
} catch {
if (showSpinner) setError('모의투자 데이터를 불러오지 못했습니다.');
} finally {
if (showSpinner) setRefreshing(false);
setInitialLoading(false);
}
}, []);
/** 최초 진입·수동 새로고침 */
const refresh = useCallback(() => {
void loadData({ showSpinner: true });
}, [loadData]);
useEffect(() => {
void loadData({ showSpinner: true });
}, [loadData]);
useEffect(() => {
if (refreshKey <= 0) return;
void loadData();
}, [refreshKey, loadData]);
/** 보유 종목 시세만 조용히 갱신 (tickers Map 참조 변경마다 전체 로딩 방지) */
const priceKey = positionPriceKey(summary?.positions, tickers);
const lastPriceKeyRef = useRef('');
useEffect(() => {
if (!priceKey || initialLoading) return;
if (priceKey === lastPriceKeyRef.current) return;
lastPriceKeyRef.current = priceKey;
let cancelled = false;
(async () => {
const base = summaryRef.current;
if (!base?.positions?.length) return;
const marks = buildMarkPrices(base, tickersRef.current);
if (Object.keys(marks).length === 0) return;
try {
const full = await loadPaperSummary(marks);
if (!cancelled) setSummary(full);
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [priceKey, initialLoading]);
const handleReset = useCallback(async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 보유 종목과 체결 이력이 모두 삭제됩니다. 계속할까요?')) return;
const res = await resetPaperAccount();
if (res) {
setSummary(res);
setTrades([]);
lastPriceKeyRef.current = '';
alert('모의투자 계좌가 초기화되었습니다.');
}
}, []);
const s = summary;
const retUp = (s?.totalReturnPct ?? 0) >= 0;
const showPanelLoading = initialLoading;
return (
<div className="paper-page">
<header className="paper-header">
<div>
<h1 className="paper-title"></h1>
<p className="paper-subtitle">
· . .
</p>
</div>
<div className="paper-header-actions">
<button type="button" className="paper-btn" onClick={refresh} disabled={refreshing}>
{refreshing ? '새로고침 중…' : '새로고침'}
</button>
<button type="button" className="paper-btn paper-btn--danger" onClick={handleReset}>
</button>
</div>
</header>
{!s?.enabled && !showPanelLoading && (
<div className="paper-banner paper-banner--warn">
. .
</div>
)}
{s?.enabled && s.autoTradeEnabled && !showPanelLoading && (
<div className="paper-banner paper-banner--info">
ON (BUY/SELL) .
(매수: 가용현금 {s.autoTradeBudgetPct}% · 매도: 보유 )
</div>
)}
{s?.enabled && !s.autoTradeEnabled && !showPanelLoading && (
<div className="paper-banner paper-banner--info">
OFF , · .
</div>
)}
{error && <div className="paper-banner paper-banner--error">{error}</div>}
<div className="paper-summary-grid">
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className="paper-card-value">{s ? fmtKrw(s.totalAsset) : '—'} KRW</span>
</div>
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className="paper-card-value">{s ? fmtKrw(s.cashBalance) : '—'} KRW</span>
</div>
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className="paper-card-value">{s ? fmtKrw(s.stockEvalAmount) : '—'} KRW</span>
</div>
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className={`paper-card-value ${retUp ? 'up' : 'down'}`}>
{s ? `${s.totalReturnPct >= 0 ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
</span>
</div>
<div className="paper-card">
<span className="paper-card-label"></span>
<span className={`paper-card-value ${(s?.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
{s ? fmtKrw(s.unrealizedPnl) : '—'} KRW
</span>
</div>
<div className="paper-card">
<span className="paper-card-label"></span>
<span className={`paper-card-value ${(s?.realizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
{s ? fmtKrw(s.realizedPnl) : '—'} KRW
</span>
</div>
</div>
<nav className="paper-tabs">
{(['overview', 'positions', 'trades'] as TabId[]).map(id => (
<button
key={id}
type="button"
className={`paper-tab${tab === id ? ' active' : ''}`}
onClick={() => setTab(id)}
>
{id === 'overview' ? '계좌 개요' : id === 'positions' ? '보유종목' : '체결내역'}
</button>
))}
</nav>
<div className="paper-panel">
{showPanelLoading && <p className="paper-muted"> ...</p>}
{!showPanelLoading && tab === 'overview' && s && (
<dl className="paper-dl">
<dt> </dt><dd>{fmtKrw(s.initialCapital)} KRW</dd>
<dt></dt><dd>{s.feeRatePct}%</dd>
<dt></dt><dd>{s.slippagePct}%</dd>
<dt> </dt><dd>{fmtKrw(s.minOrderKrw)} KRW</dd>
<dt></dt>
<dd>{s.autoTradeEnabled ? `ON (매수 시 가용현금 ${s.autoTradeBudgetPct}%)` : 'OFF (수동 매매)'}</dd>
<dt> </dt><dd>{s.positions.length}</dd>
</dl>
)}
{!showPanelLoading && tab === 'positions' && (
<table className="paper-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{(s?.positions ?? []).length === 0 ? (
<tr><td colSpan={7} className="paper-muted"> .</td></tr>
) : s!.positions.map(p => (
<tr key={p.symbol}>
<td>
<strong>{getKoreanName(p.symbol)}</strong>
<span className="paper-sym">{p.symbol}</span>
</td>
<td>{p.quantity.toFixed(8).replace(/\.?0+$/, '')}</td>
<td>{fmtKrw(p.avgPrice)}</td>
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
<td>{p.evalAmount != null ? fmtKrw(p.evalAmount) : '—'}</td>
<td className={(p.profitLoss ?? 0) >= 0 ? 'up' : 'down'}>
{p.profitLoss != null ? `${fmtKrw(p.profitLoss)} (${(p.profitLossPct ?? 0).toFixed(2)}%)` : '—'}
</td>
<td>
{onGoChart && (
<button type="button" className="paper-link" onClick={() => onGoChart(p.symbol)}>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{!showPanelLoading && tab === 'trades' && (
<table className="paper-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th> </th>
</tr>
</thead>
<tbody>
{trades.length === 0 ? (
<tr><td colSpan={8} className="paper-muted"> .</td></tr>
) : trades.map(t => (
<tr key={t.id}>
<td className="paper-time">{t.createdAt?.replace('T', ' ').slice(0, 19) ?? '—'}</td>
<td>{getKoreanName(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side}</td>
<td>{t.source === 'STRATEGY' ? '전략' : '수동'}</td>
<td>{fmtKrw(t.price)}</td>
<td>{t.quantity.toFixed(6).replace(/\.?0+$/, '')}</td>
<td>{fmtKrw(t.feeAmount)}</td>
<td>{fmtKrw(t.cashAfter)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default PaperTradingPage;
@@ -0,0 +1,270 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import type { HLineStyle } from '../utils/indicatorRegistry';
import {
parsePlotColor,
formatPlotColor,
plotColorCss,
LINE_WIDTH_OPTIONS,
LINE_STYLE_OPTIONS,
LINE_STYLE_LABELS,
} from '../utils/plotColorUtils';
import ColorPickerPanel from './ColorPickerPanel';
export interface PlotLineStyleValue {
color: string;
lineWidth?: number;
lineStyle?: HLineStyle;
}
interface PlotLineStylePickerProps {
value: PlotLineStyleValue;
disabled?: boolean;
/** 팝업 제목 (예: MA3) */
title?: string;
/** false면 색상만 (히스토그램 등) */
showLineControls?: boolean;
onChange: (patch: Partial<PlotLineStyleValue>) => void;
}
interface PlotLineStylePopupProps {
title?: string;
showLineControls: boolean;
hex6: string;
alpha: number;
lineWidth: number;
lineStyle: HLineStyle;
onChange: (patch: Partial<PlotLineStyleValue>) => void;
onClose: () => void;
patchColor: (hex: string, alphaPct: number) => void;
}
const PlotLineStylePopup: React.FC<PlotLineStylePopupProps> = ({
title,
showLineControls,
hex6,
alpha,
lineWidth,
lineStyle,
onChange,
onClose,
patchColor,
}) => {
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div className="plsp-portal-root">
<div
className="plsp-overlay"
role="presentation"
onMouseDown={onClose}
/>
<div
ref={panelRef}
className="plsp-popup"
role="dialog"
aria-modal="true"
aria-label={title ? `${title} 선 스타일` : '선 스타일'}
style={{
...panelStyle,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header plsp-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title plsp-popup-title">
{title ? `${title} · ` : ''} ·
</span>
<button type="button" className="gc-popup-close plsp-popup-close" onClick={onClose} aria-label="닫기">
</button>
</div>
<ColorPickerPanel
hex6={hex6}
onHexChange={h => patchColor(h, alpha)}
/>
<div className="plsp-section">
<span className="plsp-section-label"></span>
<div className="plsp-opacity-row">
<input
type="range"
className="plsp-opacity-slider"
min={0}
max={100}
value={alpha}
onChange={e => patchColor(hex6, Number(e.target.value))}
/>
<span className="plsp-opacity-pct">{alpha}%</span>
</div>
</div>
{showLineControls && (
<>
<div className="plsp-section">
<span className="plsp-section-label"></span>
<div className="plsp-btn-group">
{LINE_WIDTH_OPTIONS.map(w => (
<button
key={w}
type="button"
className={`plsp-icon-btn${lineWidth === w ? ' active' : ''}`}
title={`${w}px`}
onClick={() => onChange({ lineWidth: w })}
>
<svg width="24" height="14" viewBox="0 0 24 14" aria-hidden>
<line x1="2" y1="7" x2="22" y2="7" stroke="currentColor" strokeWidth={w} strokeLinecap="round" />
</svg>
</button>
))}
</div>
</div>
<div className="plsp-section">
<span className="plsp-section-label"> </span>
<div className="plsp-btn-group">
{LINE_STYLE_OPTIONS.map(s => (
<button
key={s}
type="button"
className={`plsp-icon-btn${lineStyle === s ? ' active' : ''}`}
title={LINE_STYLE_LABELS[s]}
onClick={() => onChange({ lineStyle: s })}
>
<LineStyleIcon style={s} />
</button>
))}
</div>
</div>
</>
)}
<div className="plsp-popup-footer">
<button type="button" className="plsp-popup-ok" onClick={onClose}>
</button>
</div>
</div>
</div>
);
};
const LineStyleIcon: React.FC<{ style: HLineStyle }> = ({ style }) => (
<svg width="28" height="14" viewBox="0 0 28 14" aria-hidden>
<line
x1="2" y1="7" x2="26" y2="7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={
style === 'dashed' ? '6 4' : style === 'dotted' ? '2 3' : undefined
}
/>
</svg>
);
const PlotLineStylePicker: React.FC<PlotLineStylePickerProps> = ({
value,
disabled = false,
title,
showLineControls = true,
onChange,
}) => {
const [open, setOpen] = useState(false);
const swatchRef = useRef<HTMLButtonElement>(null);
const { hex6, alpha } = parsePlotColor(value.color);
const lineWidth = value.lineWidth ?? 1;
const lineStyle = value.lineStyle ?? 'solid';
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
const patchColor = useCallback((hex: string, alphaPct: number) => {
onChange({ color: formatPlotColor(hex, alphaPct) });
}, [onChange]);
const popup = open ? (
<PlotLineStylePopup
title={title}
showLineControls={showLineControls}
hex6={hex6}
alpha={alpha}
lineWidth={lineWidth}
lineStyle={lineStyle}
onChange={onChange}
onClose={close}
patchColor={patchColor}
/>
) : null;
return (
<div className={`plsp-inline${disabled ? ' plsp-inline--disabled' : ''}`}>
<button
ref={swatchRef}
type="button"
className="plsp-swatch"
disabled={disabled}
title="색상·선 스타일"
onClick={() => !disabled && setOpen(true)}
>
<span
className="plsp-swatch-inner"
style={{ background: plotColorCss(value.color) }}
/>
</button>
{showLineControls && (
<>
<select
className="plsp-inline-select"
value={lineWidth}
disabled={disabled}
title="선 굵기"
aria-label="선 굵기"
onChange={e => onChange({ lineWidth: Number(e.target.value) })}
>
{LINE_WIDTH_OPTIONS.map(w => (
<option key={w} value={w}>{w}</option>
))}
</select>
<select
className="plsp-inline-select plsp-inline-select--style"
value={lineStyle}
disabled={disabled}
title="선 유형"
aria-label="선 유형"
onChange={e => onChange({ lineStyle: e.target.value as HLineStyle })}
>
{LINE_STYLE_OPTIONS.map(s => (
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
))}
</select>
</>
)}
{typeof document !== 'undefined' && popup
? createPortal(popup, document.body)
: null}
</div>
);
};
export default PlotLineStylePicker;
+81
View File
@@ -0,0 +1,81 @@
/**
* 공통 팝업 셸 — 그라데이션 타이틀바 + 드래그 이동
*/
import React from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
export interface PopupShellProps {
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
/** 패널 너비 (px 또는 CSS 값) */
width?: number | string;
className?: string;
dialogClassName?: string;
centered?: boolean;
zIndex?: number;
closeOnBackdrop?: boolean;
initialPosition?: { x: number; y: number };
}
export const PopupShell: React.FC<PopupShellProps> = ({
onClose,
title,
children,
width,
className,
dialogClassName,
centered = true,
zIndex = 5000,
closeOnBackdrop = true,
initialPosition,
}) => {
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: centered, initialPosition });
return (
<div
className={`gc-popup-overlay${className ? ` ${className}` : ''}`}
style={{ zIndex }}
onMouseDown={e => {
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className={`gc-popup-panel${dialogClassName ? ` ${dialogClassName}` : ''}`}
style={{
...panelStyle,
width,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title">{title}</span>
<button
type="button"
className="gc-popup-close"
onMouseDown={e => e.stopPropagation()}
onClick={onClose}
aria-label="닫기"
>
</button>
</div>
{children}
</div>
</div>
);
};
export default PopupShell;
+222
View File
@@ -0,0 +1,222 @@
/**
* 실시간 차트 우측 패널
* - 매매 탭: 상단 매수 / 하단 매도
* - 호가 탭: 실시간 호가창
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { OrderbookPanel } from './OrderbookPanel';
import TradeOrderPanel from './TradeOrderPanel';
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import type { OrderbookTickerInfo } from './OrderbookPanel';
import type { TradeOrderFillRequest } from '../types';
type PanelTab = 'trade' | 'orderbook';
export interface RightSidePanelProps {
/** 모바일 등 외부 제어 시 열림 상태 */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 모바일 도크에서 호가/매매 탭 지정 */
activeTab?: PanelTab;
onTabChange?: (tab: PanelTab) => void;
market: string;
tradePrice: number | null;
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
bestAsk: number;
bestBid: number;
spread: number;
spreadPct: number;
prevClose: number;
tickerInfo?: OrderbookTickerInfo;
fillRequest?: TradeOrderFillRequest | null;
onOrderbookPick?: (price: number, rowType: 'ask' | 'bid') => void;
onMarketSelect?: (market: string) => void;
availableKrw?: number;
availableCoinQty?: number;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
}
const PANEL_TABS: { id: PanelTab; label: string }[] = [
{ id: 'trade', label: '매매' },
{ id: 'orderbook', label: '호가' },
];
const RightSidePanel: React.FC<RightSidePanelProps> = ({
open: openControlled,
onOpenChange,
activeTab: activeTabControlled,
onTabChange,
market,
tradePrice,
asks,
bids,
totalAskSize,
totalBidSize,
wsStatus,
bestAsk,
bestBid,
spread,
spreadPct,
prevClose,
tickerInfo,
fillRequest,
onOrderbookPick,
onMarketSelect,
availableKrw = 0,
availableCoinQty = 0,
paperTradingEnabled = false,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
}) => {
const [openInternal, setOpenInternal] = useState(false);
const [tabInternal, setTabInternal] = useState<PanelTab>('trade');
const tradeSectionRef = useRef<HTMLDivElement>(null);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
const isOpenControlled = openControlled !== undefined && onOpenChange !== undefined;
const open = isOpenControlled ? !!openControlled : openInternal;
const setOpen = (next: boolean | ((p: boolean) => boolean)) => {
const cur = isOpenControlled ? !!openControlled : openInternal;
const value = typeof next === 'function' ? next(cur) : next;
if (isOpenControlled && onOpenChange) onOpenChange(value);
else setOpenInternal(value);
};
const activeTab = activeTabControlled ?? tabInternal;
const setActiveTab = useCallback((tab: PanelTab) => {
if (onTabChange) onTabChange(tab);
else setTabInternal(tab);
}, [onTabChange]);
useEffect(() => {
if (!fillRequest) return;
setOpen(true);
setActiveTab('trade');
}, [fillRequest?.seq, fillRequest?.side, setOpen, setActiveTab]);
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setActiveTab('trade');
onOrderbookPick?.(price, rowType);
}, [onOrderbookPick]);
return (
<div className={`rsp-wrap ${open ? 'rsp-wrap--open' : ''}`}>
<button
type="button"
className="rsp-handle"
onClick={() => setOpen(v => !v)}
title={open ? '패널 닫기' : '패널 열기'}
>
<svg
width="8" height="14"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
{open
? <polyline points="2,1 6,7 2,13" />
: <polyline points="6,1 2,7 6,13" />}
</svg>
</button>
<div className="rsp-panel">
<div className="rsp-tabs">
{PANEL_TABS.map(tab => (
<button
key={tab.id}
type="button"
className={`rsp-tab ${activeTab === tab.id ? 'rsp-tab--active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="rsp-body">
{activeTab === 'trade' && (
<div className="rsp-trade-stack" ref={tradeSectionRef}>
<div className="rsp-trade-card rsp-trade-card--buy">
<div className="rsp-trade-card-title"></div>
<div className="rsp-trade-card-body">
<TradeOrderPanel
side="buy"
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
</div>
<div className="rsp-trade-card rsp-trade-card--sell">
<div className="rsp-trade-card-title"></div>
<div className="rsp-trade-card-body">
<TradeOrderPanel
side="sell"
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableCoinQty={availableCoinQty}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
</div>
</div>
)}
{activeTab === 'orderbook' && (
<div className="rsp-ob-stack">
<div className="rsp-trade-card rsp-ob-card">
<div className="rsp-trade-card-title rsp-ob-card-title"> </div>
<div className="rsp-trade-card-body rsp-ob-card-body">
<OrderbookPanel
market={market}
asks={asks}
bids={bids}
totalAskSize={totalAskSize}
totalBidSize={totalBidSize}
wsStatus={wsStatus}
bestAsk={bestAsk}
bestBid={bestBid}
spread={spread}
spreadPct={spreadPct}
prevClose={prevClose}
tickerInfo={tickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default RightSidePanel;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
/**
* SMA MA1~11 기간 입력 — 프리셋 드롭다운 없이 직접 숫자 편집 (포커스 유지)
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
const MIN = 1;
const MAX = 500;
interface SmaPeriodInputProps {
value: number;
disabled?: boolean;
onCommit: (v: number) => void;
}
const SmaPeriodInput: React.FC<SmaPeriodInputProps> = ({
value,
disabled = false,
onCommit,
}) => {
const [text, setText] = useState(() => String(value));
const editingRef = useRef(false);
const committedRef = useRef(value);
useEffect(() => {
if (editingRef.current) return;
if (value !== committedRef.current) {
committedRef.current = value;
setText(String(value));
}
}, [value]);
const commit = useCallback((raw: string) => {
const trimmed = raw.trim();
if (trimmed === '') {
setText(String(committedRef.current));
return;
}
const n = parseInt(trimmed, 10);
if (isNaN(n)) {
setText(String(committedRef.current));
return;
}
const clamped = Math.max(MIN, Math.min(MAX, n));
committedRef.current = clamped;
setText(String(clamped));
onCommit(clamped);
}, [onCommit]);
return (
<input
type="text"
inputMode="numeric"
className="ism-input ism-narrow ism-sma-period-input"
value={text}
disabled={disabled}
autoComplete="off"
spellCheck={false}
onMouseDown={e => {
e.stopPropagation();
}}
onPointerDown={e => {
e.stopPropagation();
}}
onClick={e => e.stopPropagation()}
onFocus={e => {
e.stopPropagation();
editingRef.current = true;
}}
onChange={e => {
const s = e.target.value.replace(/[^\d]/g, '');
setText(s);
}}
onBlur={e => {
editingRef.current = false;
commit(e.target.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
}}
/>
);
};
export default SmaPeriodInput;
+154
View File
@@ -0,0 +1,154 @@
/**
* 서비스 진입 스플래시 — 로그인 또는 게스트 모드로 메인 화면 진입
*/
import React, { useState } from 'react';
import { loginUser, type LoginResponse } from '../utils/backendApi';
const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin';
interface Props {
onLoginSuccess: (res: LoginResponse) => void;
onGuest: () => void;
}
const SplashScreen: React.FC<Props> = ({ onLoginSuccess, onGuest }) => {
const [username, setUsername] = useState(DEFAULT_USERNAME);
const [password, setPassword] = useState(DEFAULT_PASSWORD);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const submitLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await loginUser(username.trim(), password);
onLoginSuccess(res);
} catch (err) {
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="splash">
<div className="splash-bg" aria-hidden />
<div className="splash-chart-deco" aria-hidden>
<svg className="splash-chart-svg" viewBox="0 0 400 120" preserveAspectRatio="none">
<defs>
<linearGradient id="splashLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#2196f3" stopOpacity="0.2" />
<stop offset="50%" stopColor="#42a5f5" stopOpacity="1" />
<stop offset="100%" stopColor="#ffd54f" stopOpacity="0.8" />
</linearGradient>
</defs>
<path
className="splash-line"
d="M0,90 L40,70 L80,75 L120,45 L160,55 L200,25 L240,40 L280,15 L320,30 L360,10 L400,20"
fill="none"
stroke="url(#splashLineGrad)"
strokeWidth="2.5"
/>
<path
className="splash-area"
d="M0,90 L40,70 L80,75 L120,45 L160,55 L200,25 L240,40 L280,15 L320,30 L360,10 L400,20 L400,120 L0,120 Z"
fill="url(#splashLineGrad)"
opacity="0.12"
/>
</svg>
</div>
<div className="splash-inner">
<header className="splash-brand">
<div className="splash-logo">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="10" fill="#2196f3" />
<polyline
points="8,32 16,22 24,26 32,14 40,18"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
</div>
<h1 className="splash-title">GoldenChart</h1>
<p className="splash-tagline">
· · ·
</p>
</header>
<ul className="splash-features">
<li><span className="splash-feat-icon">📈</span> ·</li>
<li><span className="splash-feat-icon"></span> &amp; </li>
<li><span className="splash-feat-icon">🔔</span> </li>
<li><span className="splash-feat-icon">🛡</span> · ( )</li>
</ul>
<div className="splash-card">
<h2 className="splash-card-title"></h2>
<p className="splash-card-desc">
. .
</p>
<form className="splash-form" onSubmit={submitLogin}>
<div className="splash-form-row">
<label className="splash-field">
<span></span>
<input
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loading}
placeholder="admin"
/>
</label>
<label className="splash-field">
<span></span>
<input
type="text"
autoComplete="off"
className="splash-field--visible"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
placeholder="••••••"
/>
</label>
</div>
{error && <p className="splash-error">{error}</p>}
<div className="splash-actions">
<button
type="submit"
className="splash-btn splash-btn--primary"
disabled={loading}
>
{loading ? '로그인 중…' : '로그인'}
</button>
<button
type="button"
className="splash-btn splash-btn--guest"
onClick={onGuest}
disabled={loading}
>
</button>
</div>
</form>
<p className="splash-footnote">
: <code>admin</code> / <code>admin</code> ·
</p>
</div>
</div>
<footer className="splash-footer">
<span>GoldenChart Trading Platform</span>
</footer>
</div>
);
};
export default SplashScreen;
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import type { PriceStats } from '../utils/calculations';
interface StatsPanelProps {
stats: PriceStats | null;
symbol: string;
onClose: () => void;
}
function fmt(n: number, digits = 2): string {
if (!isFinite(n)) return '—';
return n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function fmtVol(n: number): string {
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K';
return n.toFixed(0);
}
const StatRow: React.FC<{ label: string; value: string; highlight?: 'up' | 'down' | 'neutral' }> = ({ label, value, highlight }) => (
<div className="stat-row">
<span className="stat-label">{label}</span>
<span className={`stat-value ${highlight ?? ''}`}>{value}</span>
</div>
);
const StatsPanel: React.FC<StatsPanelProps> = ({ stats, symbol, onClose }) => {
if (!stats) return null;
const trendColor = stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'neutral';
const changeColor = stats.change >= 0 ? 'up' : 'down';
return (
<div className="stats-panel">
<div className="stats-header">
<span className="stats-title">📊 {symbol} </span>
<button className="stats-close" onClick={onClose}></button>
</div>
<div className="stats-body">
<div className="stats-section">
<div className="stats-section-title"></div>
<StatRow label="현재가" value={fmt(stats.current)} />
<StatRow label="변동" value={`${stats.change >= 0 ? '+' : ''}${fmt(stats.change)} (${fmt(stats.changePct)}%)`} highlight={changeColor} />
<StatRow label="52주 고점" value={fmt(stats.high52w)} highlight="up" />
<StatRow label="52주 저점" value={fmt(stats.low52w)} highlight="down" />
<StatRow label="평균 일일 범위" value={fmt(stats.avgRange)} />
</div>
<div className="stats-section">
<div className="stats-section-title"> & </div>
<StatRow label="추세" value={stats.trend === 'up' ? '상승 ▲' : stats.trend === 'down' ? '하락 ▼' : '횡보 ▶'} highlight={trendColor} />
<StatRow label="RSI(14)" value={fmt(stats.rrsi14, 1)} highlight={stats.rrsi14 > 70 ? 'down' : stats.rrsi14 < 30 ? 'up' : 'neutral'} />
<StatRow label="변동성(연)" value={`${fmt(stats.volatility, 1)}%`} />
</div>
<div className="stats-section">
<div className="stats-section-title"> </div>
<StatRow label="저항2" value={fmt(stats.resistance2)} highlight="down" />
<StatRow label="저항1" value={fmt(stats.resistance1)} highlight="down" />
<StatRow label="피벗(P)" value={fmt(stats.pivotP)} highlight="neutral" />
<StatRow label="지지1" value={fmt(stats.support1)} highlight="up" />
<StatRow label="지지2" value={fmt(stats.support2)} highlight="up" />
</div>
<div className="stats-section">
<div className="stats-section-title"></div>
<StatRow label="평균 거래량" value={fmtVol(stats.avgVol)} />
</div>
</div>
</div>
);
};
export default StatsPanel;
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
/**
* 시간대 선택 — 하단 바 클릭·설정 화면 공용
*/
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TIMEZONE_OPTIONS, formatUnixClock, getTimezoneAbbr, normalizeTimezone } from '../utils/timezone';
interface Props {
value: string;
onChange: (tz: string) => void;
/** compact: 하단 바용 버튼 스타일 */
variant?: 'bar' | 'select';
/** 하단 바: 마지막 봉 시각 표시 (unix 초) */
displayUnix?: number;
className?: string;
}
const TimezonePicker: React.FC<Props> = ({ value, onChange, variant = 'bar', displayUnix, className = '' }) => {
const [open, setOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ bottom: number; right: number } | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null);
const tz = normalizeTimezone(value);
const updateMenuPos = useCallback(() => {
const btn = triggerRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
setMenuPos({
bottom: window.innerHeight - r.top + 4,
right: Math.max(8, window.innerWidth - r.right),
});
}, []);
useLayoutEffect(() => {
if (!open || variant !== 'bar') return;
updateMenuPos();
const onResize = () => updateMenuPos();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [open, variant, updateMenuPos]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (rootRef.current?.contains(t) || menuRef.current?.contains(t)) return;
setOpen(false);
};
const id = window.setTimeout(() => {
document.addEventListener('mousedown', onDoc);
}, 0);
return () => {
window.clearTimeout(id);
document.removeEventListener('mousedown', onDoc);
};
}, [open]);
if (variant === 'select') {
return (
<select
className={`stg-select ${className}`}
value={tz}
onChange={e => onChange(e.target.value)}
>
{TIMEZONE_OPTIONS.map(o => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
);
}
const menu = open && variant === 'bar' && menuPos && (
<ul
ref={menuRef}
className="tz-picker-menu tz-picker-menu--portal"
role="listbox"
style={{ bottom: menuPos.bottom, right: menuPos.right }}
>
{TIMEZONE_OPTIONS.map(o => (
<li key={o.id}>
<button
type="button"
role="option"
aria-selected={o.id === tz}
className={`tz-picker-item${o.id === tz ? ' active' : ''}`}
onClick={() => {
onChange(o.id);
setOpen(false);
}}
>
<span className="tz-picker-item-label">{o.label}</span>
<span className="tz-picker-item-abbr">{o.abbr}</span>
</button>
</li>
))}
</ul>
);
return (
<div className={`tz-picker ${className}`} ref={rootRef}>
<button
ref={triggerRef}
type="button"
className="tv-time-display tz-picker-trigger"
onClick={() => {
if (open) {
setOpen(false);
return;
}
updateMenuPos();
setOpen(true);
}}
title="시간대 변경"
aria-expanded={open}
aria-haspopup="listbox"
>
{displayUnix != null && displayUnix > 0 && (
<span className="tz-picker-clock">{formatUnixClock(displayUnix, tz, true)}</span>
)}
<span className="tz-picker-abbr">{getTimezoneAbbr(tz)}</span>
<span className="tz-picker-caret"></span>
</button>
{menu && createPortal(menu, document.body)}
</div>
);
};
export default TimezonePicker;
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
/**
* 최상단 글로벌 메뉴바
*
* 레이아웃:
* [로고] | [대시보드] [실시간차트] [투자전략] | [테마토글] [로그인]
*/
import React, { memo } from 'react';
import type { Theme } from '../types';
import type { AuthSession } from '../utils/auth';
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'strategy' | 'backtest' | 'notifications' | 'settings';
interface TopMenuBarProps {
activePage: MenuPage;
theme: Theme;
onPage: (page: MenuPage) => void;
onTheme: () => void;
/** 미확인 매매 시그널 알림 수 */
tradeNotifyUnread?: number;
onOpenNotifications?: () => void;
/** 우측에 떠 있는 알림 팝업(토스트) 개수 */
tradeNotifyToastCount?: number;
/** 모든 알림 팝업 닫기 */
onDismissAllNotifyPopups?: () => void;
/** 알림 팝업 표시 위치·방식 */
tradeAlertPopupPosition?: string;
tradeAlertPopupLayout?: string;
tradeAlertPopupGridCols?: number;
onTradeAlertPopupPosition?: (v: TradeAlertPopupPosition) => void;
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
onTradeAlertPopupGridCols?: (v: number) => void;
/** 로그인 사용자 (null = 비로그인·기기별 설정) */
authUser?: AuthSession | null;
/** 게스트 모드(스플래시에서 게스트 진입) */
guestMode?: boolean;
onLoginClick?: () => void;
onLogout?: () => void;
/** 메뉴별 접근 허용 (없으면 전체 표시) */
menuPermissions?: Record<string, boolean>;
}
// ── SVG 아이콘 ────────────────────────────────────────────────────────────
const IcDashboard = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="1" width="5.5" height="5.5" rx="1"/>
<rect x="9.5" y="1" width="5.5" height="5.5" rx="1"/>
<rect x="1" y="9.5" width="5.5" height="5.5" rx="1"/>
<rect x="9.5" y="9.5" width="5.5" height="5.5" rx="1"/>
</svg>
);
const IcChart = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1,12 5,7 8,9 11,4 15,4"/>
<line x1="3" y1="14" x2="3" y2="10"/>
<line x1="6.5" y1="14" x2="6.5" y2="11.5"/>
<line x1="10" y1="14" x2="10" y2="7"/>
<line x1="13.5" y1="14" x2="13.5" y2="4"/>
</svg>
);
const IcPaper = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<line x1="5" y1="6" x2="11" y2="6"/>
<line x1="5" y1="9" x2="9" y2="9"/>
</svg>
);
const IcStrategy = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="6.5"/>
<circle cx="8" cy="8" r="2.5"/>
<line x1="8" y1="1.5" x2="8" y2="5.5"/>
<line x1="8" y1="10.5" x2="8" y2="14.5"/>
<line x1="1.5" y1="8" x2="5.5" y2="8"/>
<line x1="10.5" y1="8" x2="14.5" y2="8"/>
</svg>
);
const IcBacktest = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="9" width="2.5" height="5" rx="0.5"/>
<rect x="4.5" y="6" width="2.5" height="8" rx="0.5"/>
<rect x="8" y="3.5" width="2.5" height="10.5" rx="0.5"/>
<rect x="11.5" y="1" width="2.5" height="13" rx="0.5"/>
<polyline points="2.25,8 5.75,5 9.25,3 12.75,0.5" strokeDasharray="2 1.5"/>
</svg>
);
const IcSettings = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="2.5"/>
<path d="M8 1.5v1.3M8 13.2v1.3M1.5 8h1.3M13.2 8h1.3M3.4 3.4l.9.9M11.7 11.7l.9.9M3.4 12.6l.9-.9M11.7 4.3l.9-.9"/>
</svg>
);
const IcNotify = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
<path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/>
</svg>
);
/** 알림 팝업 전체 닫기 */
const IcDismissPopups = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="9" height="10" rx="1"/>
<path d="M11 5.5 14 2.5M14 5.5 11 2.5"/>
<line x1="4.5" y1="6.5" x2="8.5" y2="10.5"/>
<line x1="8.5" y1="6.5" x2="4.5" y2="10.5"/>
</svg>
);
const IcLogin = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 2h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H9"/>
<polyline points="6,10.5 9,7.5 6,4.5"/>
<line x1="1.5" y1="7.5" x2="9" y2="7.5"/>
</svg>
);
// ── 테마 아이콘 ───────────────────────────────────────────────────────────
const THEME_CONFIG: Record<Theme, { icon: string; label: string; next: Theme }> = {
dark: { icon: '🌙', label: '다크', next: 'blue' },
blue: { icon: '🌊', label: '블루', next: 'light' },
light: { icon: '☀️', label: '라이트', next: 'dark' },
};
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'strategy', label: '투자전략', icon: <IcStrategy /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> },
];
export const TopMenuBar = memo(function TopMenuBar({
activePage, theme, onPage, onTheme,
tradeNotifyUnread = 0,
onOpenNotifications,
tradeNotifyToastCount = 0,
onDismissAllNotifyPopups,
tradeAlertPopupPosition = 'right',
tradeAlertPopupLayout = 'stack',
tradeAlertPopupGridCols = 2,
onTradeAlertPopupPosition,
onTradeAlertPopupLayout,
onTradeAlertPopupGridCols,
authUser = null,
guestMode = false,
onLoginClick,
onLogout,
menuPermissions,
}: TopMenuBarProps) {
const tc = THEME_CONFIG[theme];
const visibleItems = menuPermissions
? MENU_ITEMS.filter(({ page }) => menuPermissions[page] === true)
: MENU_ITEMS;
const showNotifications = menuPermissions == null || menuPermissions.notifications === true;
return (
<header className="top-menubar">
{/* 로고 */}
<div className="tmb-logo">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect width="20" height="20" rx="4" fill="#2196f3"/>
<polyline points="3,14 7,9 10,12 14,6 17,6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>
<span className="tmb-logo-text">GoldenChart</span>
</div>
<div className="tmb-divider" />
{/* 내비게이션 메뉴 */}
<nav className="tmb-nav">
{visibleItems.map(({ page, label, icon }) => (
<button
key={page}
className={`tmb-nav-btn ${activePage === page ? 'tmb-nav-btn--active' : ''}`}
onClick={() => onPage(page)}
>
<span className="tmb-nav-icon">{icon}</span>
<span className="tmb-nav-label">{label}</span>
</button>
))}
</nav>
{/* 우측 영역 */}
<div className="tmb-right">
{onOpenNotifications && showNotifications && (
<div className="tmb-notify-group">
<button
type="button"
className={`tmb-notify-btn ${activePage === 'notifications' ? 'tmb-nav-btn--active' : ''}`}
onClick={onOpenNotifications}
title="매매 시그널 알림"
aria-label="매매 시그널 알림"
>
<IcNotify />
{tradeNotifyUnread > 0 && (
<span className="tmb-notify-badge">
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
</span>
)}
</button>
<TradeAlertPopupMenubarSelect
position={tradeAlertPopupPosition}
layout={tradeAlertPopupLayout}
gridCols={tradeAlertPopupGridCols}
onPositionChange={onTradeAlertPopupPosition}
onLayoutChange={onTradeAlertPopupLayout}
onGridColsChange={onTradeAlertPopupGridCols}
/>
{onDismissAllNotifyPopups && (
<button
type="button"
className="tmb-notify-dismiss-btn"
onClick={onDismissAllNotifyPopups}
disabled={tradeNotifyToastCount <= 0}
title={
tradeNotifyToastCount > 0
? `알림 팝업 ${tradeNotifyToastCount}건 전체 닫기`
: '닫을 알림 팝업 없음'
}
aria-label="알림 팝업 전체 닫기"
>
<IcDismissPopups />
</button>
)}
</div>
)}
{/* 테마 토글 버튼 */}
<button
className={`tmb-theme-btn tmb-theme-btn--${theme}`}
onClick={onTheme}
title={`테마 전환 (현재: ${tc.label}) → ${THEME_CONFIG[tc.next].label}`}
>
<span className="tmb-theme-icon">{tc.icon}</span>
<span className="tmb-theme-label">{tc.label}</span>
</button>
<div className="tmb-sep" />
{authUser ? (
<>
<span className="tmb-user-badge" title={`${authUser.displayName} (${authUser.username}) · ${authUser.role}`}>
{authUser.displayName}
<span className="tmb-user-role">{authUser.role}</span>
</span>
<button type="button" className="tmb-login-btn tmb-login-btn--out" onClick={onLogout}>
</button>
</>
) : guestMode ? (
<>
<span className="tmb-guest-badge" title="게스트 권한으로 실행 중"></span>
<button type="button" className="tmb-login-btn" onClick={onLoginClick}>
<IcLogin />
<span></span>
</button>
</>
) : (
<button type="button" className="tmb-login-btn" onClick={onLoginClick}>
<IcLogin />
<span></span>
</button>
)}
</div>
</header>
);
});
export default TopMenuBar;
+294
View File
@@ -0,0 +1,294 @@
import React, { useState } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi';
import { formatUnixDateTime } from '../utils/timezone';
export interface TradeSignalInfo {
market: string;
signalType: 'BUY' | 'SELL';
price: number;
candleTime: number;
strategyName?: string | null;
strategyId?: number | null;
executionType?: string;
candleType?: string;
}
interface Props {
signal: TradeSignalInfo;
onClose: () => void;
centered?: boolean;
tradingMode?: string;
hasUpbitKeys?: boolean;
paperTradingEnabled?: boolean;
liveAutoTradeBudgetPct?: number;
paperAutoTradeBudgetPct?: number;
onOrderDone?: () => void;
}
const fmt = (n: number): string =>
n > 0 ? n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : '0';
function fmtTime(unix: number): string {
return formatUnixDateTime(unix);
}
function execLabel(type?: string, candleType?: string): string {
const base = type === 'REALTIME_TICK' ? '실시간 틱' : '봉 마감';
return candleType ? `${base}(${candleType})` : base;
}
const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="9" width="5" height="12" rx="1" fill={color} opacity="0.9"/>
<line x1="8.5" y1="5" x2="8.5" y2="9" stroke={color} strokeWidth="1.5"/>
<line x1="8.5" y1="21" x2="8.5" y2="25" stroke={color} strokeWidth="1.5"/>
<rect x="17" y="7" width="5" height="9" rx="1" fill="var(--down,#4dabf7)" opacity="0.8"/>
<line x1="19.5" y1="4" x2="19.5" y2="7" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
<line x1="19.5" y1="16" x2="19.5" y2="20" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
</svg>
);
export const TradeAlertModal: React.FC<Props> = ({
signal,
onClose,
centered = false,
tradingMode = 'PAPER',
hasUpbitKeys = false,
paperTradingEnabled = true,
liveAutoTradeBudgetPct = 95,
paperAutoTradeBudgetPct = 95,
onOrderDone,
}) => {
const isBuy = signal.signalType === 'BUY';
const accentColor = isBuy ? '#3f7ef5' : '#ef5350';
const accentGlow = isBuy ? 'rgba(63,126,245,0.22)' : 'rgba(239,83,80,0.22)';
const signalLabel = isBuy ? 'BUY' : 'SELL';
const signalKo = isBuy ? '매수 알림' : '매도 알림';
const coinSymbol = signal.market.replace(/^[A-Z]+-/, '');
const fiatSymbol = signal.market.split('-')[0] ?? 'KRW';
const useLive = (tradingMode === 'LIVE' || tradingMode === 'BOTH') && hasUpbitKeys;
const usePaper = (tradingMode === 'PAPER' || tradingMode === 'BOTH') && paperTradingEnabled;
const [limitPrice, setLimitPrice] = useState(fmt(signal.price));
const [memo, setMemo] = useState('');
const [activePct, setActivePct] = useState<string | null>(null);
const [ordering, setOrdering] = useState(false);
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({
centerOnMount: centered,
initialPosition: { x: 40, y: 40 },
});
const handlePct = (pct: string) => {
setActivePct(pct);
};
const resolveBudgetPct = (): number => {
if (activePct === '25%') return 25;
if (activePct === '50%') return 50;
if (activePct === '100%') return 100;
return paperAutoTradeBudgetPct;
};
const handleOrder = async (type: 'limit' | 'market') => {
if (ordering) return;
setOrdering(true);
try {
const side = signal.signalType;
const budgetPct = resolveBudgetPct();
const orderPrice = type === 'limit'
? Number(limitPrice.replace(/,/g, ''))
: signal.price;
let executed = false;
if (usePaper) {
await placePaperOrder({
market: signal.market,
side,
orderKind: type === 'market' ? 'market' : 'limit',
price: orderPrice,
quantity: 0,
budgetPct,
source: 'MANUAL',
strategyId: signal.strategyId ?? undefined,
});
executed = true;
}
if (useLive && type === 'market') {
const livePct = activePct === '25%' ? 25 : activePct === '50%' ? 50 : activePct === '100%' ? 100 : liveAutoTradeBudgetPct;
const summary = isBuy ? await loadLiveSummary() : null;
const krwAmount = isBuy && summary
? summary.krwBalance * livePct / 100
: undefined;
await placeLiveOrder({
market: signal.market,
side,
orderKind: 'market',
krwAmount,
strategyId: signal.strategyId ?? undefined,
});
executed = true;
}
if (!executed) {
window.alert('주문 가능한 채널이 없습니다. 설정에서 모의투자 또는 실거래 API를 확인하세요.');
return;
}
onOrderDone?.();
onClose();
} catch (e) {
window.alert((e as Error).message);
} finally {
setOrdering(false);
}
};
return (
<div className="tam-overlay">
<div
ref={panelRef}
className="tam-modal"
style={{
...panelStyle,
zIndex: 10000,
cursor: dragging ? 'grabbing' : undefined,
'--tam-accent': accentColor,
'--tam-accent-glow': accentGlow,
} as React.CSSProperties}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header tam-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<div className="tam-header-left">
<span className="tam-badge" style={{ background: accentColor }}>
{signalLabel}
</span>
<span className="tam-header-title">
ALERT
<span className="tam-header-ko"> ({signalKo})</span>
</span>
</div>
<button className="tam-close" onClick={onClose} title="닫기"></button>
</div>
<div className="tam-price-section">
<div className="tam-price-row">
<span className="tam-market-name">{signal.market}</span>
<span className="tam-price-arrow" style={{ color: accentColor }}>
{isBuy ? '↑' : '↓'}
</span>
<span className="tam-price-value" style={{ color: accentColor }}>
{fmt(signal.price)}
</span>
</div>
<div className="tam-price-time">
: {fmtTime(signal.candleTime)}
</div>
</div>
<div className="tam-strategy-card">
<div className="tam-strategy-icon">
<CandleIcon color={accentColor} />
</div>
<div className="tam-strategy-info">
<div className="tam-strategy-name">
<span className="tam-strategy-label"></span>
<span className="tam-strategy-val">
{signal.strategyName ?? '(전략 미지정)'}
</span>
</div>
<div className="tam-strategy-exec">
<span className="tam-strategy-label"> </span>
<span className="tam-strategy-val tam-strategy-exec-val">
{execLabel(signal.executionType, signal.candleType)}
</span>
</div>
</div>
</div>
<div className="tam-order-section">
<div className="tam-field-row">
<span className="tam-field-label"> </span>
<div className="tam-pct-group">
{['25%', '50%', '100%'].map(p => (
<button
key={p}
type="button"
className={`tam-pct-btn2 ${activePct === p ? 'tam-pct-btn2--active' : ''}`}
style={activePct === p ? { borderColor: accentColor, color: accentColor } : undefined}
onClick={() => handlePct(p)}
>
{p}
</button>
))}
</div>
</div>
<div className="tam-field-row">
<span className="tam-field-label"></span>
<div className="tam-price-input-wrap">
<input
className="tam-price-input"
value={limitPrice}
onChange={e => setLimitPrice(e.target.value)}
inputMode="numeric"
/>
</div>
<button
type="button"
className="tam-market-btn"
disabled={ordering}
onClick={() => handleOrder('market')}
>
</button>
</div>
<div className="tam-field-row tam-field-row--memo">
<span className="tam-field-label"></span>
<input
className="tam-memo-input"
value={memo}
onChange={e => setMemo(e.target.value)}
placeholder="메모 입력 (선택)"
/>
</div>
<button
type="button"
className="tam-cta-btn"
style={{ background: accentColor }}
disabled={ordering}
onClick={() => handleOrder('limit')}
>
{ordering ? '주문 중…' : `${fiatSymbol} ${coinSymbol} ${isBuy ? '매수' : '매도'} (${usePaper ? '모의' : '실거래'})`}
</button>
<p className="tam-disclaimer">
{useLive && usePaper && '※ 모의·실거래 병행: 시장가는 모의·실거래 모두 체결, 지정가는 모의 계좌 체결'}
{useLive && !usePaper && '※ 업비트 실거래 시장가 주문'}
{!useLive && usePaper && '※ 모의투자 계좌 체결 (자동매매와 동일 내역 반영)'}
{!useLive && !usePaper && '※ 설정에서 모의투자 또는 API 키를 활성화하세요'}
</p>
</div>
</div>
</div>
);
};
export default TradeAlertModal;
@@ -0,0 +1,131 @@
/**
* 상단 메뉴바 — 알림 팝업 표시 위치·방식 (아이콘 드롭다운)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import TradeAlertPopupModeIcon from './TradeAlertPopupModeIcon';
import {
TRADE_ALERT_GRID_COL_OPTIONS,
buildTradeAlertPopupModeOptions,
formatTradeAlertPopupMode,
normalizeTradeAlertGridCols,
normalizeTradeAlertPopupLayout,
normalizeTradeAlertPopupPosition,
tradeAlertPopupModeKey,
type TradeAlertPopupLayout,
type TradeAlertPopupPosition,
} from '../utils/tradeAlertPopupLayout';
interface Props {
position?: string;
layout?: string;
gridCols?: number;
onPositionChange?: (v: TradeAlertPopupPosition) => void;
onLayoutChange?: (v: TradeAlertPopupLayout) => void;
onGridColsChange?: (v: number) => void;
}
export const TradeAlertPopupMenubarSelect: React.FC<Props> = ({
position: positionRaw = 'right',
layout: layoutRaw = 'stack',
gridCols: gridColsRaw = 2,
onPositionChange,
onLayoutChange,
onGridColsChange,
}) => {
const position = normalizeTradeAlertPopupPosition(positionRaw);
const layout = normalizeTradeAlertPopupLayout(layoutRaw);
const gridCols = normalizeTradeAlertGridCols(gridColsRaw);
const modeKey = tradeAlertPopupModeKey(position, layout);
const modeOptions = useMemo(() => buildTradeAlertPopupModeOptions(), []);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const selectMode = useCallback((
nextPos: TradeAlertPopupPosition,
nextLay: TradeAlertPopupLayout,
) => {
if (nextPos !== position) onPositionChange?.(nextPos);
if (nextLay !== layout) onLayoutChange?.(nextLay);
setOpen(false);
}, [position, layout, onPositionChange, onLayoutChange]);
const currentLabel = formatTradeAlertPopupMode(position, layout);
return (
<div className="tmb-notify-layout" ref={rootRef}>
<button
type="button"
className={`tmb-notify-layout-trigger${open ? ' tmb-notify-layout-trigger--open' : ''}`}
onClick={() => setOpen(v => !v)}
aria-label={`알림 표시 방식: ${currentLabel}`}
aria-expanded={open}
aria-haspopup="listbox"
title={`알림 표시: ${currentLabel}`}
>
<TradeAlertPopupModeIcon position={position} layout={layout} size={20} />
</button>
{open && (
<div className="tmb-notify-layout-menu" role="listbox" aria-label="알림 표시 방식">
{modeOptions.map(opt => {
const active = opt.key === modeKey;
return (
<button
key={opt.key}
type="button"
role="option"
aria-selected={active}
className={`tmb-notify-layout-option${active ? ' tmb-notify-layout-option--active' : ''}`}
onClick={() => selectMode(opt.position, opt.layout)}
>
<span className="tmb-notify-layout-option-icon">
<TradeAlertPopupModeIcon position={opt.position} layout={opt.layout} size={22} />
</span>
<span className="tmb-notify-layout-option-label">{opt.label}</span>
</button>
);
})}
{layout === 'grid' && (
<div className="tmb-notify-layout-cols-row">
<span className="tmb-notify-layout-cols-label"> </span>
<div className="tmb-notify-layout-cols-btns">
{TRADE_ALERT_GRID_COL_OPTIONS.map(n => (
<button
key={n}
type="button"
className={`tmb-notify-layout-col-btn${gridCols === n ? ' tmb-notify-layout-col-btn--active' : ''}`}
onClick={() => onGridColsChange?.(n)}
>
{n}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
export default TradeAlertPopupMenubarSelect;
@@ -0,0 +1,106 @@
/**
* 알림 팝업 표시 방식 미니 아이콘 (위치 + 배치)
*/
import React from 'react';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
interface Props {
position: TradeAlertPopupPosition;
layout: TradeAlertPopupLayout;
size?: number;
className?: string;
}
/** 화면 프레임 안 카드 배치를 그린 24×18 미니 프리뷰 */
const ICON_H = 18;
function slotOrigin(
position: TradeAlertPopupPosition,
cols: number,
rows: number,
): { x: number; y: number } {
const blockW = cols === 1 ? 5 : cols === 3 ? 9.4 : 6.2;
const blockH = rows === 1 ? 3.2 : rows === 3 ? 10.2 : 6.2;
switch (position) {
case 'left':
return { x: 2, y: (ICON_H - blockH) / 2 };
case 'bottom':
return { x: (24 - blockW) / 2, y: ICON_H - blockH - 1.5 };
case 'right':
default:
return { x: 24 - blockW - 2, y: (ICON_H - blockH) / 2 };
}
}
export const TradeAlertPopupModeIcon: React.FC<Props> = ({
position,
layout,
size = 22,
className,
}) => {
const w = 24;
const h = ICON_H;
const cards: React.ReactNode[] = [];
const card = (x: number, y: number, cw: number, ch: number, key: string) => (
<rect
key={key}
x={x}
y={y}
width={cw}
height={ch}
rx={1}
fill="currentColor"
opacity={0.85}
/>
);
if (layout === 'single') {
const slots = slotOrigin(position, 1, 1);
cards.push(card(slots.x, slots.y, 5, 4, 's'));
} else if (layout === 'strip') {
const o = slotOrigin(position, 3, 1);
for (let i = 0; i < 3; i++) {
cards.push(card(o.x + i * 3.2, o.y, 2.6, 3.2, `h${i}`));
}
} else if (layout === 'grid') {
const o = slotOrigin(position, 2, 2);
for (let r = 0; r < 2; r++) {
for (let c = 0; c < 2; c++) {
cards.push(card(o.x + c * 3.2, o.y + r * 3.4, 2.6, 2.8, `g${r}${c}`));
}
}
} else {
// stack
const o = slotOrigin(position, 1, 3);
for (let i = 0; i < 3; i++) {
cards.push(card(o.x, o.y + i * 3.4, 5, 2.8, `v${i}`));
}
}
return (
<svg
width={size}
height={Math.round(size * (h / w))}
viewBox={`0 0 ${w} ${h}`}
className={className}
aria-hidden
>
<rect
x={0.5}
y={0.5}
width={w - 1}
height={h - 1}
rx={2}
fill="none"
stroke="currentColor"
strokeWidth={1}
opacity={0.45}
/>
{cards}
</svg>
);
};
export default TradeAlertPopupModeIcon;
@@ -0,0 +1,84 @@
/**
* 설정 화면 — 매매 알림 팝업 배치 미리보기
*/
import React from 'react';
import {
type TradeAlertPopupLayout,
type TradeAlertPopupPosition,
tradeAlertPopupClassNames,
} from '../utils/tradeAlertPopupLayout';
interface Props {
position: TradeAlertPopupPosition;
layout: TradeAlertPopupLayout;
gridCols: number;
enabled?: boolean;
}
const MOCK_ITEMS = [
{ id: '1', side: 'BUY' as const, title: 'KRW-BTC 매수', price: '98,500,000' },
{ id: '2', side: 'SELL' as const, title: 'KRW-ETH 매도', price: '4,620,000' },
{ id: '3', side: 'BUY' as const, title: 'KRW-SOL 매수', price: '245,000' },
{ id: '4', side: 'SELL' as const, title: 'KRW-XRP 매도', price: '890' },
];
const PreviewCard: React.FC<{ side: 'BUY' | 'SELL'; title: string; price: string; compact?: boolean }> = ({
side, title, price, compact,
}) => (
<div className={`tsn-card tsn-card--preview ${side === 'BUY' ? 'tsn-card--buy' : 'tsn-card--sell'}${compact ? ' tsn-card--compact' : ''}`}>
<div className="tsn-card-header tsn-card-header--preview">
<span className={`tsn-badge ${side === 'BUY' ? 'tsn-badge--buy' : 'tsn-badge--sell'}`}>{side}</span>
<span className="tsn-card-title">{title}</span>
</div>
{!compact && (
<div className="tsn-preview-body">
<span>{price}</span>
</div>
)}
</div>
);
export const TradeAlertPopupPreview: React.FC<Props> = ({
position,
layout,
gridCols,
enabled = true,
}) => {
const { stack, cards } = tradeAlertPopupClassNames(position, layout);
const visible = layout === 'single' ? MOCK_ITEMS.slice(0, 1) : MOCK_ITEMS.slice(0, layout === 'grid' ? gridCols * 2 : 3);
const compact = layout === 'grid' || layout === 'strip';
return (
<div className={`stg-alert-preview${enabled ? '' : ' stg-alert-preview--off'}`}>
<div className="stg-alert-preview-frame">
<div className="stg-alert-preview-screen">
<span className="stg-alert-preview-label"></span>
<div
className={`${stack} tsn-stack--preview`}
style={layout === 'grid' ? { ['--tsn-grid-cols' as string]: gridCols } : undefined}
>
<div
className={cards}
style={layout === 'grid' ? { ['--tsn-grid-cols' as string]: gridCols } : undefined}
>
{visible.map(item => (
<PreviewCard
key={item.id}
side={item.side}
title={item.title}
price={item.price}
compact={compact}
/>
))}
</div>
{layout === 'single' && MOCK_ITEMS.length > 1 && (
<span className="tsn-single-more">+{MOCK_ITEMS.length - 1}</span>
)}
</div>
</div>
</div>
</div>
);
};
export default TradeAlertPopupPreview;
@@ -0,0 +1,128 @@
/**
* 매매 시그널 알림 목록 화면
*/
import React, { useCallback } from 'react';
import type { Theme } from '../types';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import { buildSignalDetailRows, formatSignalPrice, getSignalHeadline } from '../utils/tradeSignalDisplay';
interface Props {
theme: Theme;
onGoToChart: (market: string) => void;
}
export const TradeNotificationListPage: React.FC<Props> = ({ theme, onGoToChart }) => {
const {
allNotifications,
unreadCount,
markAsRead,
markAllAsRead,
openDetail,
refreshHistory,
} = useTradeNotification();
const [filter, setFilter] = React.useState<'all' | 'unread'>('all');
const filtered = filter === 'unread'
? allNotifications.filter(n => !n.isRead)
: allNotifications;
const handleRow = useCallback((item: TradeNotificationItem) => {
if (!item.isRead) markAsRead(item.id);
openDetail(item, { centered: true });
}, [markAsRead, openDetail]);
return (
<div className={`tnl-page app ${theme}`}>
<div className="tnl-header">
<h1 className="tnl-title"> </h1>
<p className="tnl-sub">
BUY/SELL . · .
</p>
<div className="tnl-toolbar">
<span className="tnl-chip tnl-chip--warn"> {unreadCount}</span>
<div className="tnl-filter">
<button
type="button"
className={filter === 'all' ? 'tnl-filter-btn--active' : ''}
onClick={() => setFilter('all')}
>
</button>
<button
type="button"
className={filter === 'unread' ? 'tnl-filter-btn--active' : ''}
onClick={() => setFilter('unread')}
>
</button>
</div>
<button type="button" className="tnl-btn" onClick={() => void refreshHistory()}>
</button>
{unreadCount > 0 && (
<button type="button" className="tnl-btn tnl-btn--primary" onClick={markAllAsRead}>
</button>
)}
</div>
</div>
<div className="tnl-list-wrap">
{filtered.length === 0 ? (
<div className="tnl-empty"> .</div>
) : (
<ul className="tnl-list">
{filtered.map(item => {
const isBuy = item.signalType === 'BUY';
const detailRows = buildSignalDetailRows(item);
return (
<li
key={item.id}
className={`tnl-row ${!item.isRead ? 'tnl-row--unread' : ''}`}
>
<button
type="button"
className="tnl-row-main"
onClick={() => handleRow(item)}
>
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}>
{item.signalType}
</span>
<span className="tnl-row-body">
<span className="tnl-row-title">{getSignalHeadline(item)}</span>
<span className="tnl-row-price">{formatSignalPrice(item.price)}</span>
<span className="tnl-row-detail-grid">
{detailRows.map(row => (
<span key={row.label} className="tnl-row-detail-item">
<span className="tnl-row-detail-lbl">{row.label}</span>
<span className="tnl-row-detail-val">{row.value}</span>
</span>
))}
</span>
</span>
{!item.isRead && <span className="tnl-dot" aria-hidden />}
</button>
<button
type="button"
className="tnl-row-chart"
title="차트로 이동"
onClick={() => {
markAsRead(item.id);
onGoToChart(item.market);
}}
>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
};
export default TradeNotificationListPage;
+415
View File
@@ -0,0 +1,415 @@
/**
* 매수 / 매도 주문 폼 (업비트 스타일)
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import { getKoreanName } from '../utils/marketNameCache';
import { MarketSearchPanel } from './MarketSearchPanel';
import type { TradeOrderFillRequest } from '../types';
import { placePaperOrder } from '../utils/backendApi';
export type OrderKind = 'limit' | 'market' | 'stop_limit';
export interface TradeOrderPanelProps {
side: 'buy' | 'sell';
market: string;
/** 현재가 (KRW) — 종목 변경 시 초기값 */
tradePrice: number | null;
availableKrw?: number;
/** 매도 시 보유 수량 (모의투자) */
availableCoinQty?: number;
/** 호가·차트에서 자동 입력 */
fillRequest?: TradeOrderFillRequest | null;
/** 종목 검색 패널 앵커 (우측 매수·매도 영역) */
searchAnchorRef?: React.RefObject<HTMLElement | null>;
/** 종목 변경 시 차트·호가 연동 */
onMarketSelect?: (market: string) => void;
/** 매매 통합 탭에서 매도 블록은 종목 필드 숨김 (매수 블록에만 표시) */
showSymbolField?: boolean;
/** 모의투자 활성화 시 주문이 가상 계좌로 체결됨 */
paperTradingEnabled?: boolean;
/** 모의투자 자동매매 (OFF면 수동 주문만) */
paperAutoTradeEnabled?: boolean;
/** 모의 체결 후 잔고 갱신 */
onPaperOrderFilled?: () => void;
}
export function fmtKrw(n: number): string {
if (!Number.isFinite(n)) return '0';
return Math.round(n).toLocaleString('ko-KR');
}
function parseNum(s: string): number {
const v = Number(String(s).replace(/,/g, '').trim());
return Number.isFinite(v) ? v : 0;
}
/** KRW 가격: 숫자만 허용, 천 단위 쉼표 표시 */
function sanitizePriceInput(raw: string): string {
const digits = raw.replace(/\D/g, '');
if (!digits) return '';
return Number(digits).toLocaleString('ko-KR');
}
/** 주문 수량: 숫자와 소수점(1개)만 허용 */
function sanitizeQtyInput(raw: string): string {
let s = raw.replace(/[^\d.]/g, '');
const dot = s.indexOf('.');
if (dot !== -1) {
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '');
}
return s;
}
function coinCode(market: string): string {
return market.replace(/^KRW-/, '');
}
function priceStep(price: number): number {
if (price >= 2_000_000) return 1000;
if (price >= 200_000) return 100;
if (price >= 20_000) return 10;
if (price >= 2_000) return 1;
if (price >= 200) return 0.1;
if (price >= 20) return 0.01;
return 0.0001;
}
const ORDER_KINDS: { id: OrderKind; label: string }[] = [
{ id: 'limit', label: '지정가' },
{ id: 'market', label: '시장가' },
{ id: 'stop_limit', label: '예약-지정가' },
];
const PCT_BTNS = [10, 25, 50, 100] as const;
const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
side,
market,
tradePrice,
availableKrw = 0,
availableCoinQty = 0,
fillRequest,
searchAnchorRef,
onMarketSelect,
showSymbolField = true,
paperTradingEnabled = false,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
}) => {
const isBuy = side === 'buy';
const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market;
const code = coinCode(displayMarket);
const koreanName = getKoreanName(displayMarket);
const [orderKind, setOrderKind] = useState<OrderKind>('limit');
const [priceStr, setPriceStr] = useState('');
const [qtyStr, setQtyStr] = useState('');
const [totalStr, setTotalStr] = useState('0');
const [pctMode, setPctMode] = useState<number | null>(null);
const [showSymbolSearch, setShowSymbolSearch] = useState(false);
const [symbolQuery, setSymbolQuery] = useState('');
const symbolInputRef = useRef<HTMLInputElement>(null);
const openSymbolSearch = useCallback((q = '') => {
setSymbolQuery(q);
setShowSymbolSearch(true);
}, []);
const handleSymbolSelect = useCallback((m: string) => {
onMarketSelect?.(m);
setShowSymbolSearch(false);
setSymbolQuery('');
symbolInputRef.current?.blur();
}, [onMarketSelect]);
const symbolDisplay = koreanName && koreanName !== displayMarket
? `${koreanName} (${code})`
: (code || displayMarket);
useEffect(() => {
if (fillRequest && fillRequest.side === side) {
setPriceStr(fmtKrw(fillRequest.price));
setPctMode(null);
return;
}
if (tradePrice != null && tradePrice > 0) {
setPriceStr(fmtKrw(tradePrice));
}
}, [market, tradePrice, fillRequest?.seq, fillRequest?.side, fillRequest?.market, side]);
const price = parseNum(priceStr);
const qty = parseNum(qtyStr);
const step = priceStep(price > 0 ? price : (tradePrice ?? 0));
useEffect(() => {
if (orderKind === 'market') {
setTotalStr(availableKrw > 0 ? fmtKrw(availableKrw) : '0');
return;
}
const total = price * qty;
setTotalStr(total > 0 ? fmtKrw(total) : '0');
}, [price, qty, orderKind, availableKrw]);
const applyPct = useCallback((pct: number) => {
setPctMode(pct);
if (isBuy) {
if (orderKind === 'market' || availableKrw <= 0) return;
const budget = (availableKrw * pct) / 100;
const p = orderKind === 'limit' && price > 0 ? price : (tradePrice ?? 0);
if (p > 0) {
const q = budget / p;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
}
return;
}
if (availableCoinQty <= 0) return;
const q = (availableCoinQty * pct) / 100;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
}, [isBuy, availableKrw, availableCoinQty, orderKind, price, tradePrice]);
const bumpPrice = useCallback((delta: number) => {
const base = price > 0 ? price : (tradePrice ?? 0);
const next = Math.max(0, base + delta);
setPriceStr(fmtKrw(next));
}, [price, tradePrice]);
const handleReset = useCallback(() => {
setOrderKind('limit');
setQtyStr('');
setTotalStr('0');
setPctMode(null);
if (tradePrice != null && tradePrice > 0) {
setPriceStr(fmtKrw(tradePrice));
} else {
setPriceStr('');
}
}, [tradePrice]);
const handleSubmit = useCallback(async () => {
const label = isBuy ? '매수' : '매도';
const execPrice = orderKind === 'market' && tradePrice != null && tradePrice > 0
? tradePrice
: price;
if (!paperTradingEnabled) {
window.alert('모의투자가 비활성화되어 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.');
return;
}
if (execPrice <= 0 || qty <= 0) {
window.alert('가격과 수량을 입력해 주세요.');
return;
}
try {
const trade = await placePaperOrder({
market: displayMarket,
side: isBuy ? 'BUY' : 'SELL',
orderKind: orderKind === 'market' ? 'market' : 'limit',
price: execPrice,
quantity: qty,
source: 'MANUAL',
});
if (!trade) {
window.alert('모의 주문에 실패했습니다.');
return;
}
onPaperOrderFilled?.();
window.alert(
`[모의투자] ${koreanName || code} ${label} 체결\n` +
`가격: ${fmtKrw(trade.price)} KRW\n` +
`수량: ${trade.quantity} ${code}\n` +
`수수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
);
setQtyStr('');
setPctMode(null);
} catch (e) {
window.alert(e instanceof Error ? e.message : '모의 주문 처리 중 오류가 발생했습니다.');
}
}, [
isBuy, koreanName, code, orderKind, priceStr, qtyStr, totalStr, paperTradingEnabled,
displayMarket, orderKind, price, qty, tradePrice, onPaperOrderFilled,
]);
const handlePriceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setPriceStr(sanitizePriceInput(e.target.value));
}, []);
const handleQtyChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setPctMode(null);
setQtyStr(sanitizeQtyInput(e.target.value));
}, []);
const priceLabel = isBuy ? '매수가격' : '매도가격';
const minOrder = 5000;
const feeRate = 0.05;
return (
<div className={`top-panel top-panel--${side}`}>
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
<p className="top-paper-hint"> OFF · .</p>
)}
{showSymbolField && (
<div className="top-field">
<label className="top-label"></label>
<div className="top-symbol-field">
<input
ref={symbolInputRef}
type="text"
className="top-symbol-input"
value={showSymbolSearch ? symbolQuery : symbolDisplay}
placeholder="종목명/심볼 검색"
onFocus={() => openSymbolSearch('')}
onChange={e => openSymbolSearch(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') {
setShowSymbolSearch(false);
setSymbolQuery('');
}
}}
/>
<button
type="button"
className="top-symbol-search-btn"
title="종목 검색"
onClick={() => {
openSymbolSearch(symbolQuery);
symbolInputRef.current?.focus();
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</button>
</div>
{showSymbolSearch && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={displayMarket}
initialQuery={symbolQuery}
onSelect={handleSymbolSelect}
onClose={() => { setShowSymbolSearch(false); setSymbolQuery(''); }}
anchorRect={searchAnchorRef?.current?.getBoundingClientRect() ?? null}
/>,
document.body,
)}
</div>
)}
<div className="top-field">
<label className="top-label">
<span className="top-label-help" title="주문 유형 안내">?</span>
</label>
<div className="top-order-kind">
{ORDER_KINDS.map(k => (
<button
key={k.id}
type="button"
className={`top-kind-btn${orderKind === k.id ? ' top-kind-btn--active' : ''}${k.id === 'stop_limit' ? ' top-kind-btn--wide' : ''}`}
onClick={() => setOrderKind(k.id)}
>
{k.label}
{k.id === 'stop_limit' && <span className="top-kind-arrow"></span>}
</button>
))}
</div>
</div>
<div className="top-row top-row--balance">
<span className="top-label">{isBuy ? '주문가능' : '보유수량'}</span>
<span className="top-balance">
{isBuy
? `${fmtKrw(availableKrw)} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
</span>
</div>
<div className="top-field">
<label className="top-label">{priceLabel} (KRW)</label>
<div className="top-input-group">
<input
type="text"
className="top-input"
value={priceStr}
disabled={orderKind === 'market'}
onChange={handlePriceChange}
inputMode="numeric"
pattern="[0-9,]*"
autoComplete="off"
/>
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}></button>
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
</div>
</div>
<div className="top-field">
<label className="top-label"> ({code})</label>
<input
type="text"
className="top-input top-input--full"
value={qtyStr}
disabled={orderKind === 'market'}
onChange={handleQtyChange}
inputMode="decimal"
pattern="[0-9.]*"
autoComplete="off"
placeholder="0"
/>
<div className="top-pct-row">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
onClick={() => applyPct(p)}
>
{p === 100 ? '최대' : `${p}%`}
</button>
))}
<button
type="button"
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
onClick={() => setPctMode(-1)}
>
</button>
</div>
</div>
<div className="top-field">
<label className="top-label"> (KRW)</label>
<input
type="text"
className="top-input top-input--full top-input--readonly"
value={totalStr}
readOnly
/>
</div>
<div className="top-meta">
<span className="top-meta-line">
<span>조건: 보통 </span>
<span className="top-meta-sep">·</span>
<span> {fmtKrw(minOrder)}</span>
<span className="top-meta-sep">·</span>
<span> {feeRate}%</span>
</span>
</div>
<div className="top-actions">
<button type="button" className="top-reset" onClick={handleReset}>
<span className="top-reset-icon"></span>
</button>
<button
type="button"
className={`top-submit top-submit--${side}`}
onClick={handleSubmit}
>
{isBuy ? '매수' : '매도'}
</button>
</div>
</div>
);
};
export default TradeOrderPanel;
@@ -0,0 +1,51 @@
/**
* 매매 시그널 알림 상세 본문 — 종목·시간·매수/매도·금액 등
*/
import React from 'react';
import {
buildSignalDetailRows,
formatSignalPrice,
getSignalHeadline,
getSignalTypeKo,
type TradeSignalDisplaySource,
} from '../utils/tradeSignalDisplay';
interface Props {
item: TradeSignalDisplaySource;
/** compact: 스택 팝업, full: 목록/모달 요약 */
variant?: 'compact' | 'full';
showHeadline?: boolean;
}
export const TradeSignalDetailBody: React.FC<Props> = ({
item,
variant = 'compact',
showHeadline = true,
}) => {
const isBuy = item.signalType === 'BUY';
const rows = buildSignalDetailRows(item);
return (
<div className={`tsd-body tsd-body--${variant}`}>
{showHeadline && (
<div className="tsd-headline">
<span className={`tsd-direction ${isBuy ? 'tsd-direction--buy' : 'tsd-direction--sell'}`}>
{isBuy ? '↑' : '↓'} {getSignalTypeKo(item.signalType)}
</span>
<span className="tsd-headline-price">{formatSignalPrice(item.price)}</span>
</div>
)}
<p className="tsd-summary">{getSignalHeadline(item)}</p>
<dl className="tsd-detail-grid">
{rows.map(row => (
<React.Fragment key={row.label}>
<dt className="tsd-dt">{row.label}</dt>
<dd className={`tsd-dd${row.highlight ? ' tsd-dd--highlight' : ''}`}>{row.value}</dd>
</React.Fragment>
))}
</dl>
</div>
);
};
export default TradeSignalDetailBody;
@@ -0,0 +1,349 @@
/**
* 매매 시그널 알림 팝업 — 화면 크기 기반 페이지·좌우 슬라이드
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import TradeSignalDetailBody from './TradeSignalDetailBody';
import { getSignalHeadline } from '../utils/tradeSignalDisplay';
import {
type TradeAlertPopupLayout,
type TradeAlertPopupPosition,
normalizeTradeAlertGridCols,
tradeAlertPopupClassNames,
} from '../utils/tradeAlertPopupLayout';
import { useToastPageCapacity } from '../hooks/useToastPageCapacity';
interface Props {
onGoToChart: (market: string) => void;
onOpenList: () => void;
popupPosition?: TradeAlertPopupPosition;
popupLayout?: TradeAlertPopupLayout;
gridCols?: number;
}
const IcSlidePrev = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="15 6 9 12 15 18" />
</svg>
);
const IcSlideNext = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="9 6 15 12 9 18" />
</svg>
);
export const TradeSignalSnackbar: React.FC<Props> = ({
onGoToChart,
onOpenList,
popupPosition = 'right',
popupLayout = 'stack',
gridCols = 2,
}) => {
const {
toastNotifications,
dismissToast,
dismissToasts,
dismissAllToasts,
openDetail,
} = useTradeNotification();
const cols = normalizeTradeAlertGridCols(gridCols);
const { pageSize, effectiveCols } = useToastPageCapacity(popupLayout, popupPosition, cols);
const { stack, cards } = tradeAlertPopupClassNames(popupPosition, popupLayout);
const layoutKey = `${popupPosition}-${popupLayout}-${cols}`;
const [pageIndex, setPageIndex] = useState(0);
const swipeRef = useRef<{ startX: number; startY: number } | null>(null);
const positionsRef = useRef<Record<string, { x: number; y: number }>>({});
const dragRef = useRef<{
key: string;
startX: number;
startY: number;
startLeft: number;
startTop: number;
} | null>(null);
const [, forceDragRender] = React.useReducer((x: number) => x + 1, 0);
const unbindDragRef = useRef<(() => void) | null>(null);
const totalPages = Math.max(1, Math.ceil(toastNotifications.length / pageSize));
const safePage = Math.min(pageIndex, totalPages - 1);
const pageItems = useMemo(() => {
const start = safePage * pageSize;
return toastNotifications.slice(start, start + pageSize);
}, [toastNotifications, safePage, pageSize]);
useEffect(() => {
positionsRef.current = {};
setPageIndex(0);
}, [layoutKey]);
useEffect(() => {
setPageIndex(p => Math.min(p, Math.max(0, totalPages - 1)));
}, [totalPages, pageSize]);
const goPrev = useCallback(() => {
setPageIndex(p => Math.max(0, p - 1));
}, []);
const goNext = useCallback(() => {
setPageIndex(p => Math.min(totalPages - 1, p + 1));
}, [totalPages]);
const dismissCurrentPage = useCallback(() => {
dismissToasts(pageItems.map(i => i.id));
}, [dismissToasts, pageItems]);
const handleDragStart = useCallback((e: React.PointerEvent, key: string) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
const el = (e.currentTarget as HTMLElement).closest('[data-tsn-card]') as HTMLElement;
if (!el) return;
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
const rect = el.getBoundingClientRect();
const pos = positionsRef.current[key];
const { x, y } = clientXYFromReact(e);
dragRef.current = {
key,
startX: x,
startY: y,
startLeft: pos?.x ?? rect.left,
startTop: pos?.y ?? rect.top,
};
unbindDragRef.current?.();
unbindDragRef.current = bindWindowDrag(
({ x: cx, y: cy }) => {
const s = dragRef.current;
if (!s) return;
positionsRef.current = {
...positionsRef.current,
[s.key]: {
x: s.startLeft + (cx - s.startX),
y: s.startTop + (cy - s.startY),
},
};
forceDragRender();
},
() => {
dragRef.current = null;
unbindDragRef.current = null;
},
);
}, []);
const handleSwipeStart = useCallback((e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
swipeRef.current = { startX: e.clientX, startY: e.clientY };
}, []);
const handleSwipeEnd = useCallback((e: React.PointerEvent) => {
const s = swipeRef.current;
swipeRef.current = null;
if (!s || totalPages <= 1) return;
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy)) return;
if (dx < 0) goNext();
else goPrev();
}, [goNext, goPrev, totalPages]);
if (toastNotifications.length === 0) return null;
const compactCard = popupLayout === 'grid' || popupLayout === 'strip';
const gridColsEffective = popupLayout === 'grid' ? effectiveCols : cols;
const containerStyle = popupLayout === 'grid'
? ({ ['--tsn-grid-cols' as string]: gridColsEffective } as React.CSSProperties)
: undefined;
const showSlideNav = totalPages > 1;
const cardList = (
<>
{pageItems.map((item, index) => (
<ToastCard
key={item.id}
item={item}
stackIndex={index}
compact={compactCard}
position={positionsRef.current[item.id]}
onDragStart={handleDragStart}
onClose={() => {
delete positionsRef.current[item.id];
dismissToast(item.id);
}}
onDetail={() => openDetail(item)}
onChart={() => {
delete positionsRef.current[item.id];
dismissToast(item.id);
onGoToChart(item.market);
}}
onList={onOpenList}
/>
))}
</>
);
const cardsProps = {
className: `${cards} tsn-cards--paged`,
style: containerStyle,
onPointerDown: handleSwipeStart,
onPointerUp: handleSwipeEnd,
onPointerCancel: () => { swipeRef.current = null; },
};
return (
<div
key={layoutKey}
className={`${stack}${showSlideNav ? ' tsn-stack--has-nav' : ''}`}
style={containerStyle}
aria-live="polite"
aria-label="매매 시그널 알림"
>
<div className="tsn-stack-toolbar">
<div className="tsn-toolbar-actions">
<button
type="button"
className="tsn-dismiss-page"
onClick={dismissCurrentPage}
title={`현재 화면 알림 ${pageItems.length}건 닫기`}
>
({pageItems.length})
</button>
<button
type="button"
className="tsn-dismiss-all"
onClick={dismissAllToasts}
title={`대기 중인 알림 ${toastNotifications.length}건 전체 닫기`}
>
({toastNotifications.length})
</button>
</div>
{showSlideNav && (
<span className="tsn-pager-label">
{safePage + 1} / {totalPages}
<span className="tsn-pager-sub"> · {pageSize}</span>
</span>
)}
</div>
<div className={`tsn-slide-row${showSlideNav ? ' tsn-slide-row--nav' : ''}`}>
{showSlideNav && (
<button
type="button"
className="tsn-slide-nav tsn-slide-nav--prev"
onClick={goPrev}
disabled={safePage <= 0}
aria-label="이전 알림 목록"
title="이전 알림"
>
<IcSlidePrev />
</button>
)}
{showSlideNav ? (
<div className="tsn-slide-body">
<div {...cardsProps}>{cardList}</div>
</div>
) : (
<div {...cardsProps}>{cardList}</div>
)}
{showSlideNav && (
<button
type="button"
className="tsn-slide-nav tsn-slide-nav--next"
onClick={goNext}
disabled={safePage >= totalPages - 1}
aria-label="다음 알림 목록"
title="다음 알림"
>
<IcSlideNext />
</button>
)}
</div>
</div>
);
};
const ToastCard: React.FC<{
item: TradeNotificationItem;
stackIndex: number;
compact?: boolean;
position?: { x: number; y: number };
onDragStart: (e: React.PointerEvent, key: string) => void;
onClose: () => void;
onDetail: () => void;
onChart: () => void;
onList: () => void;
}> = ({ item, stackIndex, compact, position, onDragStart, onClose, onDetail, onChart, onList }) => {
const isBuy = item.signalType === 'BUY';
const isDragged = Boolean(position);
const headline = getSignalHeadline(item);
return (
<div
data-tsn-card
className={`tsn-card ${isBuy ? 'tsn-card--buy' : 'tsn-card--sell'}${stackIndex === 0 ? ' tsn-card--latest' : ''}${compact ? ' tsn-card--compact' : ''}${isDragged ? ' tsn-card--dragging' : ''}`}
style={
isDragged
? {
position: 'fixed',
left: position!.x,
top: position!.y,
right: 'auto',
width: compact ? 260 : 380,
zIndex: 10050 + stackIndex,
}
: undefined
}
>
<div
className="gc-popup-header tsn-card-header"
onPointerDown={e => onDragStart(e, item.id)}
style={{ touchAction: 'none' }}
>
<span className={`tsn-badge ${isBuy ? 'tsn-badge--buy' : 'tsn-badge--sell'}`}>
{item.signalType}
</span>
<span className="gc-popup-title tsn-card-title" title={headline}>
{headline}
</span>
<button type="button" className="gc-popup-close tsn-icon-btn" onClick={onClose} title="닫기" aria-label="닫기">
</button>
</div>
{!compact && <TradeSignalDetailBody item={item} variant="compact" />}
<div className={`tsn-card-actions${compact ? ' tsn-card-actions--compact' : ''}`}>
<button type="button" className="tsn-action-btn tsn-action-btn--primary" onClick={onChart}>
</button>
{!compact && (
<>
<button type="button" className="tsn-action-btn" onClick={onDetail}>
</button>
<button type="button" className="tsn-action-btn" onClick={onList}>
</button>
</>
)}
{compact && (
<button type="button" className="tsn-action-btn" onClick={onDetail}>
</button>
)}
</div>
</div>
);
};
export default TradeSignalSnackbar;
+951
View File
@@ -0,0 +1,951 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
import ChartHoverToolbar from './ChartHoverToolbar';
import ChartMagnifier from './ChartMagnifier';
import ChartContextMenu from './ChartContextMenu';
import CandlePaneControls from './CandlePaneControls';
import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
interface TradingChartProps {
bars: OHLCVBar[];
/** BB 등 다른 심볼 계산용 */
market?: string;
timeframe?: Timeframe;
chartType: ChartType;
theme: Theme;
mode: ChartMode;
indicators: IndicatorConfig[];
drawingTool: string;
drawings: Drawing[];
logScale: boolean;
drawingsLocked?: boolean;
drawingsVisible?: boolean;
onCrosshair: (data: LegendData | null) => void;
onManagerReady: (mgr: ChartManager) => void;
onAddDrawing: (d: Drawing) => void;
/** 시리즈 단일 클릭 → (indicatorId | '__main__' | null, 패인 좌측상단 screen 좌표) */
onSeriesClick?: (entryId: string | null, point: { x: number; y: number }) => void;
/** 시리즈 더블 클릭 → 설정 모달 직접 오픈용 (패인 좌측상단 screen 좌표 포함) */
onSeriesDoubleClick?: (entryId: string | null, point: { x: number; y: number }) => void;
/** pane 레전드 레이블 호버 */
onHoverPaneLegend?: (id: string, sx: number, sy: number) => void;
/** pane 레전드 레이블 이탈 */
onLeavePaneLegend?: () => void;
/** 멀티차트 전체 보기: 이 차트를 단일 모드로 확장 (있으면 hover toolbar 버튼에 연결) */
onFullView?: () => void;
/** 현재 선택된 드로잉 ID (핸들 렌더용) */
selectedDrawingId?: string | null;
/** cursor 모드에서 드로잉 단일 클릭 */
onDrawingClick?: (id: string | null, screenX: number, screenY: number) => void;
/** cursor 모드에서 드로잉 더블 클릭 */
onDrawingDoubleClick?: (id: string, screenX: number, screenY: number) => void;
/** 돋보기 활성 여부 */
magnifierEnabled?: boolean;
/** 돋보기 닫기 콜백 */
onMagnifierClose?: () => void;
/**
* 데이터 로드 완료 콜백 — reloadAll 이 setInitialVisibleRange 까지 마친 뒤 호출.
* ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용.
*/
onDataLoaded?: () => void;
/** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */
onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void;
/** 보조지표 pane 병합 */
onMergeIndicators?: (fromId: string, intoId: string) => void;
/** 병합 pane 분리 */
onSplitIndicatorPane?: (hostId: string) => void;
/** 지표 단독 전체화면 확장 */
onExpandIndicator?: (id: string) => void;
/** 지표 제거 (X 버튼) */
onRemoveIndicator?: (id: string) => void;
/** 보조지표 pane 복사 */
onDuplicateIndicator?: (id: string) => void;
/** 현재 단독 전체화면 중인 지표 id */
focusedIndicatorId?: string | null;
/** 전체화면 → 전체 보기 복원 */
onRestoreIndicators?: () => void;
/** 우클릭 메뉴에서 매수·매도 선택 시 */
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void;
/** 표시 시간대 (IANA) */
displayTimezone?: string;
}
const TradingChart: React.FC<TradingChartProps> = ({
bars, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings,
logScale, drawingsLocked = false, drawingsVisible = true,
onCrosshair, onManagerReady, onAddDrawing,
onSeriesClick, onSeriesDoubleClick,
onHoverPaneLegend, onLeavePaneLegend,
onFullView,
selectedDrawingId,
onDrawingClick,
onDrawingDoubleClick,
magnifierEnabled = false,
onMagnifierClose,
onDataLoaded,
onReorderIndicators,
onMergeIndicators,
onSplitIndicatorPane,
onExpandIndicator,
onRemoveIndicator,
onDuplicateIndicator,
focusedIndicatorId,
onRestoreIndicators,
onTradeOrderRequest,
displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
const managerRef = useRef<ChartManager | null>(null);
const barsRef = useRef<OHLCVBar[]>([]);
// 드로잉 최신 참조 (캡처 리스너에서 closure 없이 접근)
const drawingsRef = useRef<Drawing[]>(drawings);
const drawingToolRef = useRef<string>(drawingTool);
const drawingsVisibleRef = useRef<boolean>(drawingsVisible);
const drawingsLockedRef = useRef<boolean>(drawingsLocked);
const modeRef = useRef<ChartMode>(mode);
const onDrawingClickRef = useRef(onDrawingClick);
const onDrawingDoubleClickRef = useRef(onDrawingDoubleClick);
const canPanRef = useRef(false);
const panStateRef = useRef({
active: false,
lastX: 0,
lastY: 0,
moved: false,
/** 0=캔들 pane — 세로 패닝 허용 */
originPaneIndex: 0,
});
const suppressClickRef = useRef(false);
const seriesDblSuppressRef = useRef(false);
const toggleCandleOnlyRef = useRef<() => void>(() => {});
const candleOnlyModeRef = useRef(false);
const lastCrosshairPriceRef = useRef<number | null>(null);
const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
onTradeOrderRequestRef.current = onTradeOrderRequest;
const onSeriesDoubleClickRef = useRef(onSeriesDoubleClick);
onSeriesDoubleClickRef.current = onSeriesDoubleClick;
const marketRef = useRef(market);
marketRef.current = market;
const [ctxMenu, setCtxMenu] = useState<{
x: number; y: number; price: number;
} | null>(null);
// 드로잉 더블클릭 감지용
const lastDrawingClickRef = useRef({ id: '', time: 0 });
const drawingSingleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 직전 값 추적 (불필요한 재실행 방지)
const prevBarsKey = useRef<string>('');
const prevIndKey = useRef<string>('');
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevChartType = useRef<ChartType>(chartType);
const prevTheme = useRef<Theme>(theme);
const prevLogScale = useRef<boolean>(logScale);
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
useEffect(() => {
barsRef.current = bars;
}, [bars]);
const toggleCandleOnly = useCallback(() => {
setCandleOnlyMode(v => !v);
}, []);
useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]);
useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]);
const prevCandleOnlyRef = useRef(false);
// 최신 값을 ref에 동기화 (캡처 리스너 closure 탈출)
useEffect(() => {
setIndicatorChartContext(market, timeframe);
}, [market, timeframe]);
useEffect(() => { drawingsRef.current = drawings; }, [drawings]);
useEffect(() => { drawingToolRef.current = drawingTool; }, [drawingTool]);
useEffect(() => { drawingsVisibleRef.current = drawingsVisible; }, [drawingsVisible]);
useEffect(() => { drawingsLockedRef.current = drawingsLocked; }, [drawingsLocked]);
useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { onDrawingClickRef.current = onDrawingClick; }, [onDrawingClick]);
useEffect(() => { onDrawingDoubleClickRef.current = onDrawingDoubleClick; }, [onDrawingDoubleClick]);
useEffect(() => {
canPanRef.current = drawingTool === 'cursor';
const el = containerRef.current;
if (el && !panStateRef.current.active) {
el.style.cursor = canPanRef.current ? 'grab' : '';
el.style.touchAction = canPanRef.current ? 'none' : '';
}
}, [mode, drawingTool]);
/**
* pane 높이 재배분 + 스크롤 컨테이너 확장
*
* wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다.
* CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로,
* retryCount 를 통해 최대 6회 자동 재시도합니다 (100→200→300ms…)
*/
const applyPaneLayout = useCallback((mgr: ChartManager, retryCount = 0) => {
const wrapper = wrapperRef.current;
const container = containerRef.current;
if (!wrapper || !container) return;
const wrapperH = wrapper.clientHeight;
if (wrapperH <= 0) {
// CSS Grid 높이가 아직 계산되지 않은 경우: 지연 후 재시도 (최대 6회)
if (retryCount < 6) {
setTimeout(() => applyPaneLayout(mgr, retryCount + 1), 100 * (retryCount + 1));
}
return;
}
let required: number;
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperH);
required = wrapperH;
} else {
if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperH);
}
required = mgr.resetPaneHeights(wrapperH);
}
if (required > wrapperH + 4) {
container.style.height = `${required}px`;
} else {
container.style.height = '';
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
useEffect(() => {
const mgr = managerRef.current;
if (!mgr || !chartMgr) return;
const wasOnly = prevCandleOnlyRef.current;
prevCandleOnlyRef.current = candleOnlyMode;
if (!wasOnly && candleOnlyMode) {
applyPaneLayout(mgr);
} else if (wasOnly && !candleOnlyMode && mgr.hasMainSeries()) {
const runRestore = () => {
const m = managerRef.current;
if (!m?.hasMainSeries()) return;
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
m.restoreFromCandleFullscreen(wrapperH);
applyPaneLayout(m);
const futureBars = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
};
requestAnimationFrame(() => requestAnimationFrame(runRestore));
[200, 500, 1000].forEach(delay => setTimeout(runRestore, delay));
}
}, [candleOnlyMode, chartMgr, applyPaneLayout]);
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
const reloadAll = useCallback(async (
mgr: ChartManager,
newBars: OHLCVBar[],
ct: ChartType,
th: Theme,
ls: boolean,
inds: IndicatorConfig[],
) => {
if (newBars.length === 0) return;
barsRef.current = newBars;
prevChartType.current = ct;
prevTheme.current = th;
prevLogScale.current = ls;
prevBarsKey.current = barsKey(newBars);
prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(inds);
mgr.setData(newBars, ct, th);
mgr.setTheme(th);
mgr.setLogScale(ls);
mgr.setDisplayTimezone(displayTimezone, timeframe);
// 인디케이터를 순차적으로 등록 (async/await 보장)
for (const ind of inds) {
await mgr.addIndicator(ind);
}
// 인디케이터 추가 완료 후 RAF × 2 대기
// → LWC 가 모든 pane 을 DOM 에 확정한 뒤 높이·X축 재설정
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
applyPaneLayout(mgr); // ① pane 높이 재배분 (wrapper 높이 기준, 0이면 자동 재시도)
requestAnimationFrame(() => {
const futureBars = mgr.hasIchimoku() ? 28 : 0;
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
resolve();
});
})));
// 데이터 로드 완료 알림: 멀티차트 sync range 재적용 등 외부 콜백 처리용
onDataLoaded?.();
// ── 안전망: 멀티레이아웃에서 CSS Grid 높이 확정이 늦어진 경우를 위한 지연 재적용
// applyPaneLayout 이 이미 재시도 중이지만, setInitialVisibleRange 도 재실행 필요할 수 있음
const safetyTimers = [300, 700, 1400].map(delay =>
setTimeout(() => {
const m = managerRef.current;
if (!m || !m.hasMainSeries()) return;
const w = wrapperRef.current;
if (!w || w.clientHeight <= 0) return;
m.resetPaneHeights(w.clientHeight);
const fb = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
}, delay)
);
// cleanup 시 타이머 해제 (컴포넌트 언마운트 대응)
// 반환값이 없으므로 managerRef 체크로 충분
void safetyTimers; // 타이머는 managerRef null 체크로 자동 무효화됨
}, [applyPaneLayout, onDataLoaded]);
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
useEffect(() => {
if (!containerRef.current) return;
const mgr = new ChartManager(containerRef.current, theme);
managerRef.current = mgr;
setChartMgr(mgr);
onManagerReady(mgr);
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
if (p.point?.y != null) {
const yPrice = mgr.yToPrice(p.point.y);
if (yPrice != null && yPrice > 0) lastCrosshairPriceRef.current = yPrice;
}
if (!p.time) { onCrosshair(null); return; }
const bar = mgr.getBarAtTime(p.time as number)
?? barsRef.current.find(b => b.time === p.time);
if (bar) {
const crosshairPrice = p.point?.y != null ? mgr.yToPrice(p.point.y) ?? undefined : undefined;
const indicatorValues = mgr.getIndicatorValuesFromParams(p);
onCrosshair({
time: p.time as number,
open: bar.open, high: bar.high, low: bar.low,
close: bar.close, volume: bar.volume,
crosshairPrice: crosshairPrice ?? undefined,
indicatorValues,
});
}
});
const container = containerRef.current!;
// ── 드로잉 클릭/더블클릭 감지 (capture phase → 인디케이터 클릭보다 우선) ──────
// cursor 모드 + trading + visible + !locked 일 때만 처리
const handleDrawingCapture = (e: MouseEvent) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
e.stopImmediatePropagation();
return;
}
if (
modeRef.current !== 'trading' ||
drawingToolRef.current !== 'cursor'||
!drawingsVisibleRef.current ||
drawingsLockedRef.current
) return;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const cssW = container.clientWidth;
const cssH = container.clientHeight;
const m = managerRef.current;
if (!m) return;
// 역순(나중에 그린 것 우선)으로 hit-test
const ds = drawingsRef.current;
for (let i = ds.length - 1; i >= 0; i--) {
if (hitTestDrawing(cx, cy, ds[i], m, cssW, cssH)) {
const hitId = ds[i].id;
// 인디케이터 컨텍스트 툴바가 열리지 않도록 이벤트 전파 차단
e.stopImmediatePropagation();
const now = Date.now();
const isDouble =
now - lastDrawingClickRef.current.time < 400 &&
lastDrawingClickRef.current.id === hitId;
lastDrawingClickRef.current = { id: hitId, time: now };
if (isDouble) {
if (drawingSingleTimerRef.current) {
clearTimeout(drawingSingleTimerRef.current);
drawingSingleTimerRef.current = null;
}
onDrawingDoubleClickRef.current?.(hitId, e.clientX, e.clientY);
} else {
if (drawingSingleTimerRef.current) clearTimeout(drawingSingleTimerRef.current);
drawingSingleTimerRef.current = setTimeout(() => {
drawingSingleTimerRef.current = null;
onDrawingClickRef.current?.(hitId, e.clientX, e.clientY);
}, 220);
}
return;
}
}
// 빈 공간 클릭 → 선택 해제
if (
modeRef.current === 'trading' &&
drawingToolRef.current === 'cursor'
) {
onDrawingClickRef.current?.(null, e.clientX, e.clientY);
}
};
// capture:true → subscribeSeriesClick(bubble) 보다 먼저 실행
container.addEventListener('click', handleDrawingCapture, { capture: true });
const onContextMenuCapture = (e: MouseEvent) => {
if (!onTradeOrderRequestRef.current || !marketRef.current) return;
const m = managerRef.current;
if (!m) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
const price = m.getTradePriceAtChartPoint(chartX, chartY);
if (price == null) return;
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY, price });
};
container.addEventListener('contextmenu', onContextMenuCapture, { capture: true });
const onPanPointerDown = (e: PointerEvent) => {
if (!canPanRef.current || e.button !== 0) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
if (mgr.isOnPriceAxis(chartX, chartY) || mgr.isOnTimeAxis(chartY)) return;
const originPaneIndex = mgr.getPaneIndexAtChartY(chartY);
try {
container.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
panStateRef.current = {
active: true,
lastX: e.clientX,
lastY: e.clientY,
moved: false,
originPaneIndex,
};
container.style.cursor = 'grabbing';
};
const onPanPointerMove = (e: PointerEvent) => {
if (!panStateRef.current.active) return;
const dx = e.clientX - panStateRef.current.lastX;
const dy = e.clientY - panStateRef.current.lastY;
panStateRef.current.lastX = e.clientX;
panStateRef.current.lastY = e.clientY;
const allowVertical = panStateRef.current.originPaneIndex === 0;
if (!panStateRef.current.moved) {
const hitY = allowVertical && Math.abs(dy) > 2;
if (Math.abs(dx) > 2 || hitY) panStateRef.current.moved = true;
}
if (!panStateRef.current.moved) return;
if (e.cancelable) e.preventDefault();
managerRef.current?.panByPixelDelta(dx, dy, {
allowVerticalPan: allowVertical,
});
};
const onPanPointerUp = () => {
if (!panStateRef.current.active) return;
if (panStateRef.current.moved) suppressClickRef.current = true;
panStateRef.current.active = false;
container.style.cursor = canPanRef.current ? 'grab' : '';
};
container.addEventListener('pointerdown', onPanPointerDown);
window.addEventListener('pointermove', onPanPointerMove);
window.addEventListener('pointerup', onPanPointerUp);
window.addEventListener('pointercancel', onPanPointerUp);
const onWheelCapture = (e: WheelEvent) => {
const m = managerRef.current;
if (!m) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
if (!m.isCandlePanePriceAxis(chartX, chartY)) return;
e.preventDefault();
e.stopPropagation();
m.zoomMainPriceByWheel(e.deltaY, chartY);
};
container.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });
// subscribeSeriesClick 은 이제 native DOM 이벤트 기반.
// point 는 ChartManager 내부에서 chart-container 좌표로 계산된 screen 좌표.
/** 캔들 pane: 시리즈 위 → 설정 팝업, 빈 공간 → 전체보기/원복 토글 */
const onCandlePaneDblClick = (e: MouseEvent) => {
const m = managerRef.current;
if (!m) return;
const rect = container.getBoundingClientRect();
const chartX = e.clientX - rect.left;
const chartY = e.clientY - rect.top;
const entryId = m.resolveEntryAtChartPoint(chartX, chartY);
if (entryId && entryId !== '__main__') {
e.preventDefault();
e.stopImmediatePropagation();
suppressClickRef.current = true;
seriesDblSuppressRef.current = true;
onSeriesDoubleClickRef.current?.(entryId, { x: e.clientX, y: e.clientY });
return;
}
if (!m.isInCandlePlotArea(chartX, chartY)) return;
e.preventDefault();
e.stopImmediatePropagation();
suppressClickRef.current = true;
seriesDblSuppressRef.current = true;
if (m.isClickOnMainSeries(chartX, chartY)) {
onSeriesDoubleClickRef.current?.('__main__', { x: e.clientX, y: e.clientY });
} else {
toggleCandleOnlyRef.current();
}
};
container.addEventListener('dblclick', onCandlePaneDblClick, { capture: true });
const unsubClick = mgr.subscribeSeriesClick(
(entryId, point) => { onSeriesClick?.(entryId, point); },
(entryId, point) => {
if (seriesDblSuppressRef.current) {
seriesDblSuppressRef.current = false;
return;
}
// 캔들 pane 더블클릭은 dblclick 캡처에서 처리 (시리즈/빈공간 구분)
if (entryId === '__main__') return;
onSeriesDoubleClick?.(entryId, point);
},
);
// autoSize:true 를 쓰면 LWC 가 내부 ResizeObserver 로 자동 크기 조절.
// 이 외부 ResizeObserver 는 applyPaneLayout 재트리거 + 데이터 재로드 보장용.
let prevW = 0, prevH = 0;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (width === prevW && height === prevH) return;
// 이전 크기가 0이었다가 유효해진 경우 (= display:none → visible 전환)
const wasHidden = prevW <= 0 || prevH <= 0;
prevW = width; prevH = height;
if (width <= 0 || height <= 0) return;
const m = managerRef.current;
if (!m) return;
// 컨테이너가 처음으로 유효한 크기를 가질 때:
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
reloadAll(
m,
barsRef.current,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
[] // indicators는 별도 useEffect에서 처리
);
} else {
applyPaneLayout(m);
// 멀티→단일 레이아웃 전환처럼 숨겨졌다가 다시 표시될 때
// setInitialVisibleRange 를 재실행해 가시 범위(캔들 분포)를 정상화
if (wasHidden && m.hasMainSeries()) {
requestAnimationFrame(() => {
const fb = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
});
}
}
});
ro.observe(containerRef.current);
return () => {
unsub();
unsubClick();
container.removeEventListener('click', handleDrawingCapture, { capture: true });
container.removeEventListener('contextmenu', onContextMenuCapture, { capture: true });
container.removeEventListener('dblclick', onCandlePaneDblClick, { capture: true });
container.removeEventListener('pointerdown', onPanPointerDown);
container.removeEventListener('wheel', onWheelCapture, { capture: true });
window.removeEventListener('pointermove', onPanPointerMove);
window.removeEventListener('pointerup', onPanPointerUp);
window.removeEventListener('pointercancel', onPanPointerUp);
if (drawingSingleTimerRef.current) {
clearTimeout(drawingSingleTimerRef.current);
drawingSingleTimerRef.current = null;
}
ro.disconnect();
managerRef.current?.destroy();
managerRef.current = null;
setChartMgr(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
}, [displayTimezone, timeframe]);
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
useEffect(() => {
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length === 0) return;
// manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지
if (!mgr.hasMainSeries()) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
return;
}
const bk = barsKey(bars);
const ik = indKey(indicators);
const barsChanged = bk !== prevBarsKey.current;
const indChanged = ik !== prevIndKey.current;
const ctChanged = chartType !== prevChartType.current;
const thChanged = theme !== prevTheme.current;
const lsChanged = logScale !== prevLogScale.current;
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged) return;
if (barsChanged || ctChanged || thChanged) {
// 데이터 또는 차트 형식 변경 → 전체 재로드
reloadAll(mgr, bars, chartType, theme, logScale, indicators);
} else if (lsChanged) {
prevLogScale.current = logScale;
mgr.setLogScale(logScale);
} else if (indChanged) {
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
const currPK = paramKey(indicators);
const prevSK = prevIndKey.current.split('@@')[1] ?? '';
const currSK = styleKey(indicators);
const needsRecalc = prevPK !== currPK;
// 같은 지표 집합인데 순서만 바뀐 경우 (reorder-only)
const isReorderOnly = needsRecalc
&& sortedParamKey(indicators) === prevSortedPKRef.current
&& currSK === prevSK;
if (isReorderOnly) {
// ── pane 순서 변경만: 메인 차트를 건드리지 않고 지표만 재배치 ──────────
// 1) 지표 영역만 커버 (순간 숨김) → 2) 지표만 재추가 → 3) 서서히 공개
prevSortedPKRef.current = sortedParamKey(indicators);
prevIndKey.current = ik;
const containerEl = containerRef.current;
// 지표 영역 위에 불투명 커버 div 삽입
let cover: HTMLDivElement | null = null;
if (containerEl) {
cover = document.createElement('div');
cover.style.cssText = [
'position:absolute', 'inset:0', 'z-index:900',
'pointer-events:none', 'opacity:1',
'background:var(--bg,#131722)',
].join(';');
containerEl.parentElement?.appendChild(cover);
}
mgr.reloadIndicatorsOnly(indicators).then(() => {
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
}
applyPaneLayout(mgr);
requestAnimationFrame(() => requestAnimationFrame(() => {
if (cover) {
cover.style.transition = 'opacity 0.25s ease';
cover.style.opacity = '0';
setTimeout(() => { cover?.remove(); }, 300);
}
}));
});
} else if (needsRecalc) {
// ── 지표 추가·제거·파라미터 변경 → 지표만 재로드 (메인 캔들 유지) ──
// reloadIndicatorsOnly 는 메인/볼륨 시리즈를 건드리지 않으므로
// 차트 위치 초기화·깜빡임 없이 지표만 갱신된다.
prevSortedPKRef.current = sortedParamKey(indicators);
prevIndKey.current = ik;
// 현재 scroll/zoom 위치를 저장해 reload 후 복원
const savedLR = mgr.getVisibleLogicalRange();
mgr.reloadIndicatorsOnly(indicators).then(() => {
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
} else if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
}
applyPaneLayout(mgr);
requestAnimationFrame(() => requestAnimationFrame(() => {
if (savedLR) mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
}));
});
} else if (prevSK !== currSK) {
// ── 스타일만 변경 (색상·선폭·plotVisibility) → in-place 업데이트 ────
// 시리즈를 제거·재생성하지 않으므로 pane 재번호 없음
for (const ind of indicators) {
mgr.applyIndicatorStyle(ind);
}
prevIndKey.current = ik;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartMgr, bars, chartType, theme, logScale, indicators]);
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
if (panStateRef.current.active) return;
if (canPanRef.current) {
const container = containerRef.current;
if (!container) return;
if (
modeRef.current === 'trading' &&
drawingToolRef.current === 'cursor' &&
drawingsVisibleRef.current &&
!drawingsLockedRef.current
) {
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const m = managerRef.current;
if (m) {
const isHit = drawingsRef.current.some(d =>
hitTestDrawing(cx, cy, d, m, container.clientWidth, container.clientHeight),
);
container.style.cursor = isHit ? 'pointer' : 'grab';
return;
}
}
container.style.cursor = 'grab';
return;
}
if (
modeRef.current !== 'trading' ||
drawingToolRef.current !== 'cursor' ||
!drawingsVisibleRef.current ||
drawingsLockedRef.current
) {
if (containerRef.current) containerRef.current.style.cursor = '';
return;
}
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const cssW = container.clientWidth;
const cssH = container.clientHeight;
const m = managerRef.current;
if (!m) return;
const ds = drawingsRef.current;
const isHit = ds.some(d => hitTestDrawing(cx, cy, d, m, cssW, cssH));
container.style.cursor = isHit ? 'pointer' : '';
}, []);
const fmtCtxPrice = (p: number) =>
p >= 1000 ? Math.round(p).toLocaleString('ko-KR') : p.toFixed(p >= 1 ? 2 : 6);
const closeCtxAndRequest = useCallback((side: 'buy' | 'sell') => {
if (!ctxMenu || !market) return;
onTradeOrderRequestRef.current?.({ market, price: ctxMenu.price, side });
setCtxMenu(null);
}, [ctxMenu, market]);
return (
// wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap)
<div
ref={wrapperRef}
className="tv-chart-wrap"
style={{ flex: 1, position: 'relative', minHeight: 0 }}
onPointerMove={handleWrapperMouseMove}
>
<div
ref={containerRef}
className="chart-container"
/>
{ctxMenu && (
<ChartContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
marketLabel={getKoreanName(market) || market.replace(/^KRW-/, '')}
priceLabel={`${fmtCtxPrice(ctxMenu.price)} KRW`}
onBuy={() => closeCtxAndRequest('buy')}
onSell={() => closeCtxAndRequest('sell')}
onClose={() => setCtxMenu(null)}
/>
)}
{chartMgr && (
<DrawingCanvas
manager={chartMgr}
activeTool={drawingTool}
drawings={drawings}
onAddDrawing={onAddDrawing}
onZoom={(x0, x1) => chartMgr.zoomToXRange(x0, x1)}
theme={theme}
enabled={mode === 'trading' || drawingTool === 'zoom' || drawingTool === 'magnifier'}
visible={drawingsVisible}
locked={drawingsLocked}
selectedDrawingId={selectedDrawingId}
/>
)}
{/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
{chartMgr && (
<CandlePaneControlsPortal
manager={chartMgr}
getContainer={() => containerRef.current}
candleOnly={candleOnlyMode}
onExpand={() => setCandleOnlyMode(true)}
onRestore={() => setCandleOnlyMode(false)}
/>
)}
{chartMgr && !candleOnlyMode && (
<PaneLegendPortal
manager={chartMgr}
indicators={indicators}
getContainer={() => containerRef.current}
onHoverName={(id, sx, sy) => onHoverPaneLegend?.(id, sx, sy)}
onLeaveName={() => onLeavePaneLegend?.()}
onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
onReorder={onReorderIndicators}
onMerge={onMergeIndicators}
onSplit={onSplitIndicatorPane}
onExpand={onExpandIndicator}
onRemove={onRemoveIndicator}
onDuplicate={onDuplicateIndicator}
focusedId={focusedIndicatorId}
onRestore={onRestoreIndicators}
/>
)}
{/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */}
{chartMgr && (
<ChartHoverToolbar
onZoomOut={() => managerRef.current?.zoomOut()}
onZoomIn={() => managerRef.current?.zoomIn()}
onFit={() => managerRef.current?.fitContent()}
onScrollLeft={() => managerRef.current?.scrollLeft()}
onScrollRight={() => managerRef.current?.scrollRight()}
onFullView={onFullView}
/>
)}
{/* 돋보기 오버레이 */}
<ChartMagnifier
containerRef={containerRef}
wrapperRef={wrapperRef}
enabled={magnifierEnabled}
onClose={onMagnifierClose ?? (() => {})}
/>
</div>
);
};
const CandlePaneControlsPortal: React.FC<{
manager: ChartManager;
getContainer: () => HTMLElement | null;
candleOnly: boolean;
onExpand: () => void;
onRestore: () => void;
}> = ({ manager, getContainer, candleOnly, onExpand, onRestore }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
const c = getContainer();
if (c) { setEl(c); return; }
const tid = setTimeout(() => setEl(getContainer()), 100);
return () => clearTimeout(tid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!el) return null;
return (
<CandlePaneControls
manager={manager}
containerEl={el}
candleOnly={candleOnly}
onExpand={onExpand}
onRestore={onRestore}
/>
);
};
// ── containerEl 을 안전하게 주입하는 래퍼 ───────────────────────────────────
const PaneLegendPortal: React.FC<
Omit<PaneLegendProps, 'containerEl'> & { getContainer: () => HTMLElement | null }
> = ({ getContainer, ...rest }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
// 마운트 직후 containerEl 확보
const container = getContainer();
if (container) { setEl(container); return; }
// 혹시 null 이면 짧게 재시도
const tid = setTimeout(() => setEl(getContainer()), 100);
return () => clearTimeout(tid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!el) return null;
return <PaneLegend {...rest} containerEl={el} />;
};
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
function barsKey(bars: OHLCVBar[]): string {
if (bars.length === 0) return '';
const last = bars[bars.length - 1];
// 종목이 달라도 시간 범위·개수가 같을 수 있으므로 마지막 close 가격도 포함
return `${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
}
/**
* 재계산이 필요한 변경 키 (params·플롯 구성·추가·제거) — 배열 순서 기준
* 이 키가 바뀌면 시리즈를 재생성해야 한다.
*/
function paramKey(inds: IndicatorConfig[]): string {
return inds.map(i =>
`${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`
).join(';');
}
/**
* 순서 무관 파라미터 키 — id 기준으로 정렬
* paramKey 값은 같지만 배열 순서만 다른 경우를 감지하는 데 사용.
*/
function sortedParamKey(inds: IndicatorConfig[]): string {
return [...inds]
.sort((a, b) => a.id.localeCompare(b.id))
.map(i => `${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`)
.join(';');
}
/**
* 스타일만 포함하는 키 (색상·선폭·plotVisibility)
* 이 키만 바뀌면 시리즈를 재생성하지 않고 in-place 업데이트.
*/
function styleKey(inds: IndicatorConfig[]): string {
return inds.map(i =>
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}`
).join(';');
}
/** 전체 변경 감지 키 (두 키를 합침) */
function indKey(inds: IndicatorConfig[]): string {
return paramKey(inds) + '@@' + styleKey(inds);
}
export default TradingChart;
+76
View File
@@ -0,0 +1,76 @@
import React, { useState } from 'react';
interface WatchListProps {
watchlist: string[];
currentSymbol: string;
onSelect: (symbol: string) => void;
onAdd: (symbol: string) => void;
onRemove: (symbol: string) => void;
onClose: () => void;
priceMap: Record<string, { price: number; changePct: number }>;
}
const WatchList: React.FC<WatchListProps> = ({
watchlist, currentSymbol, onSelect, onAdd, onRemove, onClose, priceMap,
}) => {
const [input, setInput] = useState('');
const handleAdd = () => {
const sym = input.trim().toUpperCase();
if (sym && !watchlist.includes(sym)) {
onAdd(sym);
setInput('');
}
};
return (
<div className="watchlist-panel">
<div className="watchlist-header">
<span className="watchlist-title"> </span>
<button className="watchlist-close" onClick={onClose}></button>
</div>
<div className="watchlist-add">
<input
className="watchlist-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="심볼 추가..."
/>
<button className="watchlist-add-btn" onClick={handleAdd}>+</button>
</div>
<div className="watchlist-body">
{watchlist.map(sym => {
const info = priceMap[sym];
const pct = info?.changePct ?? 0;
const isActive = sym === currentSymbol;
return (
<div
key={sym}
className={`watchlist-item ${isActive ? 'active' : ''}`}
onClick={() => onSelect(sym)}
>
<span className="watchlist-sym">{sym}</span>
<div className="watchlist-right">
{info && (
<>
<span className="watchlist-price">{info.price.toFixed(2)}</span>
<span className={`watchlist-change ${pct >= 0 ? 'up' : 'down'}`}>
{pct >= 0 ? '+' : ''}{pct.toFixed(2)}%
</span>
</>
)}
<button
className="watchlist-remove"
onClick={e => { e.stopPropagation(); onRemove(sym); }}
></button>
</div>
</div>
);
})}
</div>
</div>
);
};
export default WatchList;
@@ -0,0 +1,318 @@
/**
* 실시간 매매 시그널 알림 Context
*
* - STOMP 수신 시 토스트 스택(미확인)에 추가
* - 알림 목록·읽음 상태는 localStorage + 서버 trade-signals 이력 병합
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { TradeSignalInfo } from '../components/TradeAlertModal';
import { loadTradeSignals, type TradeSignalDto } from '../utils/backendApi';
import {
normalizeTradeAlertSoundId,
playTradeAlertSound,
type TradeAlertSoundId,
} from '../utils/tradeAlertSound';
const STORAGE_KEY = 'gc_trade_notify_read_v1';
const MAX_HISTORY = 300;
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
const MAX_TOAST_QUEUE = 50;
export interface TradeNotificationItem extends TradeSignalInfo {
/** 클라이언트 고유 키 (market:time:signal) */
id: string;
dbId?: number;
isRead: boolean;
receivedAt: number;
}
interface TradeNotificationContextValue {
/** 우측 스택에 표시 중인 미확인 알림 */
toastNotifications: TradeNotificationItem[];
/** 전체 이력 (목록 화면) */
allNotifications: TradeNotificationItem[];
unreadCount: number;
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
dismissToast: (id: string) => void;
/** 지정 id 알림만 닫기 (현재 페이지 등) */
dismissToasts: (ids: string[]) => void;
dismissAllToasts: () => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
refreshHistory: () => Promise<void>;
detailSignal: TradeSignalInfo | null;
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
detailCentered: boolean;
openDetail: (item: TradeNotificationItem, options?: { centered?: boolean }) => void;
closeDetail: () => void;
}
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
}
function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotificationItem {
const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
return {
id,
dbId: dto.id,
market: dto.market,
signalType: dto.signalType,
price: dto.price,
candleTime: dto.candleTime,
strategyName: dto.strategyName,
strategyId: dto.strategyId,
executionType: dto.executionType,
candleType: dto.candleType,
isRead: readIds.has(id),
receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
};
}
function loadReadIds(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
}
function saveReadIds(ids: Set<string>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
} catch { /* ignore */ }
}
export function useTradeNotification(): TradeNotificationContextValue {
const ctx = useContext(TradeNotificationContext);
if (!ctx) {
throw new Error('useTradeNotification must be used within TradeNotificationProvider');
}
return ctx;
}
interface ProviderProps {
children: React.ReactNode;
/** 매매 알림 팝업 ON (설정) */
popupEnabled: boolean;
/** 매매 알림 사운드 ON (설정) */
soundEnabled?: boolean;
/** 알림음 ID */
soundId?: string;
}
export const TradeNotificationProvider: React.FC<ProviderProps> = ({
children,
popupEnabled,
soundEnabled = true,
soundId = 'bell',
}) => {
const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId;
const [readIds, setReadIds] = useState<Set<string>>(() => loadReadIds());
const [toastNotifications, setToastNotifications] = useState<TradeNotificationItem[]>([]);
const [allNotifications, setAllNotifications] = useState<TradeNotificationItem[]>([]);
const [detailSignal, setDetailSignal] = useState<TradeSignalInfo | null>(null);
const [detailCentered, setDetailCentered] = useState(false);
const unreadCount = useMemo(
() => allNotifications.filter(n => !n.isRead).length,
[allNotifications],
);
const refreshHistory = useCallback(async () => {
try {
const dtos = await loadTradeSignals();
const items = dtos.map(d => dtoToItem(d, readIds));
setAllNotifications(prev => {
const isBoot = prev.length === 0;
const newlyArrived = items.filter(
item => !item.isRead && !prev.some(p => p.id === item.id),
);
if (!isBoot && popupEnabled && newlyArrived.length > 0) {
setToastNotifications(tprev => {
const map = new Map(tprev.map(t => [t.id, t]));
for (const n of newlyArrived) map.set(n.id, n);
return [...map.values()].slice(0, MAX_TOAST_QUEUE);
});
if (soundEnabled) {
playTradeAlertSound(alertSoundId);
}
}
const map = new Map<string, TradeNotificationItem>();
for (const n of items) map.set(n.id, n);
for (const n of prev) {
if (!map.has(n.id)) map.set(n.id, n);
}
return [...map.values()]
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, MAX_HISTORY);
});
} catch (e) {
console.warn('[TradeNotification] 이력 로드 실패:', e);
}
}, [readIds, popupEnabled, soundEnabled, alertSoundId]);
useEffect(() => {
void refreshHistory();
}, [refreshHistory]);
const markAsRead = useCallback((id: string) => {
setReadIds(prev => {
const next = new Set(prev);
next.add(id);
saveReadIds(next);
return next;
});
setToastNotifications(prev => prev.filter(n => n.id !== id));
setAllNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, isRead: true } : n)),
);
}, []);
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
const id = makeId(signal);
// 실시간 재수신 시 이전에 읽음 처리된 동일 키도 다시 미확인으로
setReadIds(prev => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
saveReadIds(next);
return next;
});
const item: TradeNotificationItem = {
...signal,
id,
dbId: signal.dbId,
candleType: signal.candleType ?? '1m',
isRead: false,
receivedAt: Date.now(),
};
// 배지(미확인 건수)는 팝업 설정과 무관하게 항상 갱신
setAllNotifications(prev => {
const existing = prev.find(n => n.id === id);
if (existing) {
return prev.map(n =>
n.id === id
? { ...n, ...item, isRead: false, receivedAt: item.receivedAt }
: n,
);
}
return [item, ...prev].slice(0, MAX_HISTORY);
});
if (soundEnabled) {
playTradeAlertSound(alertSoundId);
}
if (!popupEnabled) return;
setToastNotifications(prev => {
const rest = prev.filter(n => n.id !== id);
return [item, ...rest].slice(0, MAX_TOAST_QUEUE);
});
}, [popupEnabled, soundEnabled, alertSoundId]);
const dismissToast = useCallback((id: string) => {
markAsRead(id);
}, [markAsRead]);
const dismissToasts = useCallback((ids: string[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
setReadIds(r => {
const next = new Set(r);
ids.forEach(i => next.add(i));
saveReadIds(next);
return next;
});
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
setAllNotifications(all =>
all.map(n => (idSet.has(n.id) ? { ...n, isRead: true } : n)),
);
}, []);
const dismissAllToasts = useCallback(() => {
setToastNotifications(prev => {
const ids = prev.map(n => n.id);
setReadIds(r => {
const next = new Set(r);
ids.forEach(i => next.add(i));
saveReadIds(next);
return next;
});
setAllNotifications(all =>
all.map(n => (ids.includes(n.id) ? { ...n, isRead: true } : n)),
);
return [];
});
}, []);
const markAllAsRead = useCallback(() => {
setAllNotifications(prev => {
const nextRead = new Set(readIds);
prev.forEach(n => nextRead.add(n.id));
saveReadIds(nextRead);
setReadIds(nextRead);
return prev.map(n => ({ ...n, isRead: true }));
});
setToastNotifications([]);
}, [readIds]);
const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => {
setDetailCentered(options?.centered ?? false);
setDetailSignal({
market: item.market,
signalType: item.signalType,
price: item.price,
candleTime: item.candleTime,
strategyName: item.strategyName,
strategyId: item.strategyId,
executionType: item.executionType,
candleType: item.candleType,
});
markAsRead(item.id);
}, [markAsRead]);
const closeDetail = useCallback(() => {
setDetailSignal(null);
setDetailCentered(false);
}, []);
const value: TradeNotificationContextValue = {
toastNotifications,
allNotifications,
unreadCount,
addNotification,
dismissToast,
dismissToasts,
dismissAllToasts,
markAsRead,
markAllAsRead,
refreshHistory,
detailSignal,
detailCentered,
openDetail,
closeDetail,
};
return (
<TradeNotificationContext.Provider value={value}>
{children}
</TradeNotificationContext.Provider>
);
};
+159
View File
@@ -0,0 +1,159 @@
/**
* useAppSettings
*
* 앱 전역 차트 기본 설정을 DB 와 동기화하는 훅.
*
* 관리 항목 (하드코딩 대체):
* - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol)
* - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe)
* - defaultChartType : 기본 차트 타입
* - defaultTheme : 기본 테마
* - defaultLogScale : 기본 로그 스케일
* - defaultLayoutId : 기본 레이아웃 ID
* - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE)
* - syncOptions : 멀티차트 동기화 옵션 (DEFAULT_SYNC)
*
* 사용 예:
* ```tsx
* const { settings, isLoaded, save } = useAppSettings();
*
* // 기본 심볼 사용
* const symbol = settings.defaultSymbol ?? 'KRW-BTC';
*
* // 테마 변경 시 DB 에 저장
* save({ defaultTheme: newTheme });
* ```
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
loadAppSettings,
saveAppSettings,
type AppSettingsDto,
} from '../utils/backendApi';
import type { Theme, ChartType, Timeframe, MainChartStyle } from '../types';
import { resolveChartLegendOptions } from '../types/chartLegend';
import type { ChartLegendVisibility } from '../types/chartLegend';
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
let _cache: AppSettingsDto | null = null;
let _loadPromise: Promise<AppSettingsDto> | null = null;
function ensureLoaded(): Promise<AppSettingsDto> {
if (_cache !== null) return Promise.resolve(_cache);
if (_loadPromise) return _loadPromise;
_loadPromise = loadAppSettings().then(data => {
_cache = data ?? {};
_loadPromise = null;
return _cache;
});
return _loadPromise;
}
export function invalidateAppSettingsCache() {
_cache = null;
_loadPromise = null;
}
// ─────────────────────────────────────────────────────────────────────────────
/** DB 또는 fallback 기본값 접근 헬퍼 */
export function resolveAppDefaults(s: AppSettingsDto) {
return {
symbol: (s.defaultSymbol ?? 'KRW-BTC') as string,
timeframe: (s.defaultTimeframe ?? '1D') as Timeframe,
chartType: (s.defaultChartType ?? 'candlestick') as ChartType,
theme: (s.defaultTheme ?? 'dark') as Theme,
logScale: s.defaultLogScale ?? false,
layoutId: s.defaultLayoutId ?? '1',
mainChartStyle:(s.mainChartStyle as unknown as MainChartStyle | null) ?? DEFAULT_MAIN_CHART_STYLE,
syncOptions: (s.syncOptions as unknown as SyncOptions | null) ?? DEFAULT_SYNC,
btAutoPopup: s.btAutoPopup ?? true,
btShowPrice: s.btShowPrice ?? true,
chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true,
chartVolumeVisible: s.chartVolumeVisible ?? true,
chartLegendOptions: resolveChartLegendOptions(
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
),
tradeAlertPopup: s.tradeAlertPopup ?? true,
tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true,
tradeAlertSound: s.tradeAlertSound ?? 'bell',
tradeAlertPopupPosition: s.tradeAlertPopupPosition ?? 'right',
tradeAlertPopupLayout: s.tradeAlertPopupLayout ?? 'stack',
tradeAlertPopupGridCols: s.tradeAlertPopupGridCols ?? 2,
liveStrategyCheck: s.liveStrategyCheck ?? false,
liveStrategyId: s.liveStrategyId ?? null,
liveExecutionType: s.liveExecutionType ?? 'CANDLE_CLOSE',
livePositionMode: s.livePositionMode ?? 'LONG_ONLY',
paperTradingEnabled: s.paperTradingEnabled ?? true,
paperInitialCapital: s.paperInitialCapital ?? 10_000_000,
paperFeeRatePct: s.paperFeeRatePct ?? 0.05,
paperSlippagePct: s.paperSlippagePct ?? 0,
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95,
tradingMode: s.tradingMode ?? 'PAPER',
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
hasUpbitKeys: s.hasUpbitKeys ?? false,
upbitAccessKeyMasked: s.upbitAccessKeyMasked ?? null,
chartRealtimeSource: s.chartRealtimeSource ?? 'BACKEND_STOMP',
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
fcmPushEnabled: s.fcmPushEnabled ?? false,
displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE),
};
}
// ─────────────────────────────────────────────────────────────────────────────
/** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */
export function useAppSettings(sessionKey = 0) {
const [settings, setSettings] = useState<AppSettingsDto>(_cache ?? {});
const [isLoaded, setIsLoaded] = useState(false);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
setIsLoaded(false);
invalidateAppSettingsCache();
ensureLoaded().then(data => {
if (mountedRef.current) {
setSettings(data);
setDisplayTimezone(normalizeTimezone(data.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
setIsLoaded(true);
}
}).catch(err => {
console.error('[useAppSettings] load failed', err);
if (mountedRef.current) setIsLoaded(true);
});
return () => { mountedRef.current = false; };
}, [sessionKey]);
/**
* 특정 필드를 DB 에 저장하고 로컬 캐시를 업데이트.
* 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영.
*/
const save = useCallback((patch: AppSettingsDto) => {
const merged = { ...(_cache ?? {}), ...patch };
_cache = merged;
setSettings(merged);
if (patch.displayTimezone != null) {
setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
}
saveAppSettings(patch).then(updated => {
if (updated && mountedRef.current) {
_cache = { ...(_cache ?? {}), ...updated };
setSettings(prev => ({ ...prev, ...updated }));
}
}).catch(err =>
console.error('[useAppSettings] save failed', err)
);
}, []);
/** 로드된 설정의 해석된 기본값 */
const defaults = resolveAppDefaults(settings);
return { settings, defaults, isLoaded, save };
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 백테스팅 실행 훅.
*
* 사용법:
* const { signals, stats, analysis, running, run, clear } = useBacktest();
*
* await run({ strategyId, bars, timeframe });
*/
import { useState, useCallback } from 'react';
import type { OHLCVBar } from '../types';
import {
runBacktest,
type BacktestSignal,
type BacktestStats,
type BacktestAnalysis,
type BacktestSettingsDto,
} from '../utils/backendApi';
export interface BacktestResult {
signals: BacktestSignal[];
stats: BacktestStats | null;
analysis: BacktestAnalysis | null;
resultId?: number;
}
export function useBacktest() {
const [result, setResult] = useState<BacktestResult | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = useCallback(async (opts: {
strategyId?: number;
buyCondition?: unknown;
sellCondition?: unknown;
bars: OHLCVBar[];
timeframe: string;
indicatorParams?: Record<string, Record<string, unknown>>;
settings?: BacktestSettingsDto;
symbol?: string;
strategyName?: string;
}) => {
setRunning(true);
setError(null);
try {
const req = {
strategyId: opts.strategyId,
buyCondition: opts.buyCondition,
sellCondition: opts.sellCondition,
bars: opts.bars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe: opts.timeframe,
indicatorParams: opts.indicatorParams,
settings: opts.settings,
symbol: opts.symbol,
strategyName: opts.strategyName,
};
const res = await runBacktest(req);
if (!res) {
setError('백테스팅 요청에 실패했습니다.');
return null;
}
const r: BacktestResult = {
signals: res.signals,
stats: res.stats,
analysis: res.analysis ?? null,
resultId: res.resultId,
};
setResult(r);
return r;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '알 수 없는 오류';
setError(msg);
return null;
} finally {
setRunning(false);
}
}, []);
const clear = useCallback(() => {
setResult(null);
setError(null);
}, []);
return {
signals: result?.signals ?? [],
stats: result?.stats ?? null,
analysis: result?.analysis ?? null,
result,
running,
error,
run,
clear,
};
}
@@ -0,0 +1,35 @@
import type { Timeframe, OHLCVBar } from '../types';
import { useUpbitData, type WsStatus } from './useUpbitData';
import { useStompChartData } from './useStompChartData';
export type ChartRealtimeSource = 'BACKEND_STOMP' | 'UPBIT_DIRECT';
interface Options {
onTickUpdate: (bar: OHLCVBar) => void;
onNewCandle: (bar: OHLCVBar) => void;
enabled?: boolean;
deepObvHistory?: boolean;
source?: ChartRealtimeSource;
}
export function useChartRealtimeData(
market: string,
timeframe: Timeframe,
opts: Options,
) {
const useStomp = opts.source !== 'UPBIT_DIRECT';
const stomp = useStompChartData(market, timeframe, {
onTickUpdate: opts.onTickUpdate,
onNewCandle: opts.onNewCandle,
enabled: opts.enabled !== false && useStomp,
});
const upbit = useUpbitData(market, timeframe, {
onTickUpdate: opts.onTickUpdate,
onNewCandle: opts.onNewCandle,
enabled: opts.enabled !== false && !useStomp,
deepObvHistory: opts.deepObvHistory,
});
return useStomp ? stomp : upbit;
}
export type { WsStatus };
+111
View File
@@ -0,0 +1,111 @@
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
import { bindWindowDrag, clientXYFromReact, isPrimaryPointerButton } from '../utils/pointerDrag';
export interface DraggablePosition {
x: number;
y: number;
}
export interface UseDraggablePanelOptions {
/** 마운트 후 패널 크기 기준 화면 중앙 배치 */
centerOnMount?: boolean;
initialPosition?: DraggablePosition;
panelRef?: RefObject<HTMLElement | null>;
}
const DEFAULT_POS: DraggablePosition = { x: 80, y: 72 };
/**
* 팝업 패널 타이틀바 드래그 이동 (마우스·터치·펜)
*/
export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
const {
centerOnMount = false,
initialPosition = DEFAULT_POS,
panelRef: externalRef,
} = options;
const internalRef = useRef<HTMLDivElement>(null);
const panelRef = (externalRef ?? internalRef) as RefObject<HTMLDivElement | null>;
const [pos, setPos] = useState<DraggablePosition>(initialPosition);
const [dragging, setDragging] = useState(false);
const dragOrigin = useRef({ mx: 0, my: 0, px: 0, py: 0 });
const centeredOnce = useRef(false);
const unbindDragRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!centerOnMount || centeredOnce.current) return;
const el = panelRef.current;
if (!el) return;
const placeCenter = () => {
const w = el.offsetWidth || 400;
const h = el.offsetHeight || 320;
setPos({
x: Math.max(8, (window.innerWidth - w) / 2),
y: Math.max(8, (window.innerHeight - h) / 2),
});
centeredOnce.current = true;
};
placeCenter();
const id = requestAnimationFrame(placeCenter);
return () => cancelAnimationFrame(id);
}, [centerOnMount, panelRef]);
const endDrag = useCallback(() => {
unbindDragRef.current?.();
unbindDragRef.current = null;
setDragging(false);
}, []);
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
if (!isPrimaryPointerButton(e)) return;
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
e.stopPropagation();
const el = e.currentTarget as HTMLElement;
try {
el.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
const { x, y } = clientXYFromReact(e);
setDragging(true);
dragOrigin.current = { mx: x, my: y, px: pos.x, py: pos.y };
unbindDragRef.current?.();
unbindDragRef.current = bindWindowDrag(
({ x: cx, y: cy }) => {
const o = dragOrigin.current;
setPos({
x: o.px + (cx - o.mx),
y: o.py + (cy - o.my),
});
},
endDrag,
);
}, [pos, endDrag]);
useEffect(() => () => {
unbindDragRef.current?.();
}, []);
const panelStyle: React.CSSProperties = {
position: 'fixed',
left: pos.x,
top: pos.y,
};
return {
panelRef: internalRef,
pos,
dragging,
/** @deprecated onHeaderPointerDown 사용 */
onHeaderMouseDown: onHeaderPointerDown,
onHeaderPointerDown,
panelStyle,
headerCursor: dragging ? 'grabbing' : 'grab',
headerTouchStyle: { touchAction: 'none' as const },
};
}
+181
View File
@@ -0,0 +1,181 @@
/**
* useHistoryLoader
*
* 차트를 왼쪽으로 스크롤할 때 과거 캔들 데이터를 추가로 불러오는 훅.
*
* 데이터 소스 우선순위:
* 1. 백엔드 GET /api/candles/history (Ta4jStorage + 업비트 REST 프록시 + RSI 포함)
* 2. 백엔드 호출 실패 시 → 업비트 REST 직접 폴백
*
* 중복 방지 락(Lock):
* loadingRef = true 인 동안에는 동일 타임스탬프 중복 요청을 원천 차단.
*
* 사용 방법:
* 1. 훅에서 반환한 { isLoadingMore, loadMoreRef } 를 받는다.
* 2. ChartManager.subscribeVisibleLogicalRange 콜백 안에서
* `r.from < LOAD_MORE_TRIGGER` 조건 시 `loadMoreRef.current()` 를 호출한다.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import type { RefObject, MutableRefObject } from 'react';
import type { ChartManager } from '../utils/ChartManager';
import { fetchUpbitCandlesBeforeCached } from '../utils/requestCache';
import type { OHLCVBar, Timeframe } from '../types';
/** 이 값(바 인덱스)보다 왼쪽에 가시 범위의 시작이 오면 추가 로드 트리거 */
export const LOAD_MORE_TRIGGER = 30;
/** 한 번에 불러올 추가 캔들 수 */
const LOAD_BATCH = 200;
/** 연속 스크롤 중 불필요한 요청을 막는 디바운스 (ms) */
const DEBOUNCE_MS = 300;
/** 백엔드 API 기본 URL */
import { API_BASE } from '../utils/backendApi';
interface UseHistoryLoaderOptions {
symbol: string;
timeframe: Timeframe;
isUpbit: boolean;
managerRef: RefObject<ChartManager | null>;
}
interface UseHistoryLoaderReturn {
isLoadingMore: boolean;
/** onManagerReady 의 subscribeVisibleLogicalRange 콜백에서 직접 호출 */
loadMoreRef: MutableRefObject<() => void>;
}
// ── 백엔드 /api/candles/history 호출 ──────────────────────────────────────────
/**
* 백엔드에서 과거 캔들을 가져온다.
* 응답에 rsi 필드가 포함되어 있으며, 시간 오름차순으로 정렬된다.
*
* @param market 업비트 마켓 코드 (e.g. "KRW-BTC")
* @param type 캔들 타입 (e.g. "1m", "1d")
* @param to 기준 시간 이전 ISO-8601 (e.g. "2026-05-20T10:00:00")
* @param count 요청 캔들 수
*/
async function fetchHistoryFromBackend(
market: string,
type: string,
to: string,
count: number,
): Promise<OHLCVBar[]> {
const params = new URLSearchParams({
market,
type,
to,
count: String(count),
});
const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any[] = await res.json();
// 백엔드 CandleBarDto → OHLCVBar 변환 (rsi 는 extra 필드로 보존)
return data.map(d => ({
time: d.time as number,
open: d.open as number,
high: d.high as number,
low: d.low as number,
close: d.close as number,
volume: d.volume as number,
}));
}
// ── 훅 본체 ───────────────────────────────────────────────────────────────────
export function useHistoryLoader({
symbol,
timeframe,
isUpbit,
managerRef,
}: UseHistoryLoaderOptions): UseHistoryLoaderReturn {
const [isLoadingMore, setIsLoadingMore] = useState(false);
/**
* 중복 요청 방지 락(Lock):
* ref 를 사용하여 re-render 없이 동기적으로 상태를 업데이트한다.
* true 인 동안에는 스크롤 이벤트가 아무리 발생해도 API 를 재호출하지 않는다.
*/
const loadingRef = useRef(false);
/** false 가 되면 더 이상 API 를 호출하지 않음 */
const hasMoreRef = useRef(true);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 심볼·타임프레임이 바뀌면 상태 초기화
useEffect(() => {
hasMoreRef.current = true;
loadingRef.current = false;
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [symbol, timeframe]);
const doLoadMore = useCallback(async () => {
if (!isUpbit) return; // 시뮬레이션 모드는 건너뜀
if (loadingRef.current) return; // ★ 중복 방지 락: 이미 로딩 중이면 즉시 리턴
if (!hasMoreRef.current) return; // 더 이상 데이터 없음
const mgr = managerRef.current;
if (!mgr || mgr.getRawBarsLength() === 0) return;
const oldestTime = mgr.getOldestBarTime();
if (oldestTime === null) return;
// Unix 타임스탬프(초) → ISO 8601 UTC ('Z' 없이 전달, 백엔드에서 처리)
const beforeIso = new Date(oldestTime * 1000)
.toISOString()
.replace('.000Z', ''); // '2026-05-20T10:00:00'
// ★ 락 설정: 이 시점부터 동일 타임스탬프 중복 요청 차단
loadingRef.current = true;
setIsLoadingMore(true);
try {
let olderBars: OHLCVBar[];
// ── 1. 백엔드 /api/candles/history 우선 시도 ──────────────────────────
try {
olderBars = await fetchHistoryFromBackend(symbol, timeframe, beforeIso, LOAD_BATCH);
console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신`);
} catch (backendErr) {
// ── 2. 백엔드 실패 시 업비트 REST 직접 폴백 ───────────────────────
console.warn('[HistoryLoader] 백엔드 실패, 업비트 직접 폴백:', backendErr);
olderBars = await fetchUpbitCandlesBeforeCached(
symbol, timeframe, LOAD_BATCH, beforeIso,
);
}
if (olderBars.length === 0) {
hasMoreRef.current = false; // 더 이상 데이터 없음
} else {
// ── 3. 차트 앞부분에 부드럽게 병합 (Prepend) ──────────────────────
await mgr.prependBars(olderBars);
}
} catch (e) {
console.warn('[HistoryLoader] 과거 데이터 로드 실패:', e);
} finally {
// ★ 락 해제: 다음 스크롤 이벤트부터 재요청 허용
loadingRef.current = false;
setIsLoadingMore(false);
}
}, [symbol, timeframe, isUpbit, managerRef]);
/**
* 디바운스 래퍼 — subscribeVisibleLogicalRange 는 스크롤 중 매 프레임 호출되므로
* 사용자가 잠시 멈췄을 때 한 번만 실제 API 요청이 나가도록 한다.
*/
const loadMoreRef = useRef<() => void>(() => {});
loadMoreRef.current = useCallback(() => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
debounceTimer.current = null;
doLoadMore();
}, DEBOUNCE_MS);
}, [doLoadMore]);
return { isLoadingMore, loadMoreRef };
}
+228
View File
@@ -0,0 +1,228 @@
/**
* useIndicatorSettings
*
* 전역 지표 파라미터 및 시각 설정(색상·선굵기·수평선)을 DB와 동기화하는 훅.
*
* - 앱 시작 시 DB에서 로드 → indicatorRegistry.ts 하드코딩 기본값을 완전 대체
* - 지표 파라미터 또는 시각 설정이 변경되면 DB에 저장
* - 백엔드 Ta4j 계산 시에도 동일한 파라미터 값 사용
*
* 사용 예:
* ```tsx
* const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
*
* // 지표 추가 시: DB 저장값 우선 적용 (파라미터 + 색상)
* const params = getParams(type, def.defaultParams);
* const { plots, hlines } = getVisualConfig(type, def.plots, def.hlines);
*
* // 지표 설정 저장 시: 파라미터 + 시각 설정 모두 DB에 반영
* saveParams(updated.type, updated.params);
* saveVisual(updated.type, updated.plots, updated.hlines);
* ```
*/
import { useEffect, useRef, useCallback } from 'react';
import { getIndicatorDef, mergePlotDefs, PlotDef, HLineDef } from '../utils/indicatorRegistry';
import { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig';
import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig';
import {
DEFAULT_ICHIMOKU_CLOUD_COLORS,
type IchimokuCloudColors,
resolveIchimokuCloudColors,
} from '../utils/ichimokuConfig';
import {
loadIndicatorSettings,
saveIndicatorSettings,
saveAllIndicatorSettings,
loadIndicatorVisualSettings,
saveIndicatorVisualSettings,
} from '../utils/backendApi';
type ParamMap = Record<string, unknown>;
type AllSettings = Record<string, ParamMap>;
export interface IndicatorVisual {
plots?: PlotDef[];
hlines?: HLineDef[];
cloudColors?: IchimokuCloudColors;
}
type VisualCache = Record<string, IndicatorVisual>;
// ── 전역 싱글톤 캐시 — 여러 슬롯에서 공유하여 중복 네트워크 요청 방지 ──
let _cache: AllSettings | null = null;
let _loadPromise: Promise<AllSettings> | null = null;
let _visualCache: VisualCache | null = null;
let _visualLoadPromise: Promise<VisualCache> | null = null;
function ensureLoaded(): Promise<AllSettings> {
if (_cache !== null) return Promise.resolve(_cache);
if (_loadPromise) return _loadPromise;
_loadPromise = loadIndicatorSettings().then(data => {
_cache = (data as AllSettings) ?? {};
_loadPromise = null;
return _cache;
});
return _loadPromise;
}
function ensureVisualLoaded(): Promise<VisualCache> {
if (_visualCache !== null) return Promise.resolve(_visualCache);
if (_visualLoadPromise) return _visualLoadPromise;
_visualLoadPromise = loadIndicatorVisualSettings().then(data => {
_visualCache = (data as VisualCache) ?? {};
_visualLoadPromise = null;
return _visualCache;
});
return _visualLoadPromise;
}
/** 캐시를 강제로 무효화 (테스트 또는 로그아웃 시) */
export function invalidateIndicatorSettingsCache() {
_cache = null;
_loadPromise = null;
_visualCache = null;
_visualLoadPromise = null;
}
// ─────────────────────────────────────────────────────────────────────────────
export function useIndicatorSettings(sessionKey = 0) {
// 로그인/로그아웃 시 지표 설정 재로드
useEffect(() => {
invalidateIndicatorSettingsCache();
ensureLoaded().catch(err =>
console.error('[useIndicatorSettings] params load failed', err)
);
ensureVisualLoaded().catch(err =>
console.error('[useIndicatorSettings] visual load failed', err)
);
}, [sessionKey]);
// ── 파라미터 ────────────────────────────────────────────────────────────
/**
* 지표 타입에 대한 파라미터를 반환.
* DB 저장값 → defaultParams 순으로 우선순위 적용.
*/
const getParams = useCallback(
(
type: string,
defaults?: Record<string, number | string | boolean>
): Record<string, number | string | boolean> => {
const dbParams = _cache?.[type] ?? {};
const fallback = defaults ?? getIndicatorDef(type)?.defaultParams ?? {};
const merged = { ...fallback, ...dbParams } as Record<string, number | string | boolean>;
if (type === 'SMA') {
return normalizeSmaConfig({ id: '', type: 'SMA', params: merged }).params;
}
if (type === 'IchimokuCloud') {
return normalizeIchimokuConfig({ id: '', type: 'IchimokuCloud', params: merged }).params;
}
return merged;
},
[]
);
/** 특정 지표 파라미터를 DB에 저장하고 캐시를 업데이트 */
const saveParams = useCallback(
(type: string, params: Record<string, number | string | boolean>) => {
if (_cache) {
_cache = { ..._cache, [type]: params as ParamMap };
} else {
_cache = { [type]: params as ParamMap };
}
saveIndicatorSettings(type, params as ParamMap).catch(err =>
console.error(`[useIndicatorSettings] saveParams failed for ${type}`, err)
);
},
[]
);
/** 현재 활성 지표 목록 전체를 DB에 저장 */
const saveAll = useCallback(
(allParams: AllSettings) => {
_cache = { ...allParams };
saveAllIndicatorSettings(allParams).catch(err =>
console.error('[useIndicatorSettings] saveAll failed', err)
);
},
[]
);
// ── 시각 설정 (색상·선굵기·수평선) ──────────────────────────────────────
/**
* 지표의 시각 설정을 반환.
* DB 저장값 → registry defaults 순으로 우선순위 적용.
*
* @param type 지표 타입 (e.g. "RSI")
* @param defaultPlots 없을 경우 사용할 기본 플롯 (indicatorRegistry.plots)
* @param defaultHlines 없을 경우 사용할 기본 수평선 (indicatorRegistry.hlines)
*/
const getVisualConfig = useCallback(
(
type: string,
defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[]
): { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors } => {
const saved = _visualCache?.[type];
const def = getIndicatorDef(type);
const registryPlots = defaultPlots ?? def?.plots ?? [];
let plots = mergePlotDefs(saved?.plots as PlotDef[] | undefined, registryPlots);
const hlines = (saved?.hlines as HLineDef[] | undefined) ?? defaultHlines ?? def?.hlines ?? [];
if (type === 'SMA') {
plots = normalizeSmaConfig({
id: '',
type: 'SMA',
params: createDefaultSmaParams(),
plots,
}).plots ?? createDefaultSmaPlots();
}
if (type === 'IchimokuCloud') {
plots = mergeIchimokuPlots(plots, registryPlots);
}
const cloudColors = type === 'IchimokuCloud'
? resolveIchimokuCloudColors(saved?.cloudColors ?? DEFAULT_ICHIMOKU_CLOUD_COLORS)
: undefined;
// 깊은 복사: 여러 슬롯이 같은 참조를 공유하지 않도록
return {
plots: plots.map(p => ({ ...p })),
hlines: hlines.map(h => ({ ...h })),
cloudColors: cloudColors ? { ...cloudColors } : undefined,
};
},
[]
);
/**
* 지표의 시각 설정을 DB에 저장하고 캐시를 업데이트.
* IndicatorSettingsModal 저장 콜백에서 호출.
*/
const saveVisual = useCallback(
(
type: string,
plots?: PlotDef[],
hlines?: HLineDef[],
cloudColors?: IchimokuCloudColors,
) => {
const visual: IndicatorVisual = { plots, hlines };
if (cloudColors) visual.cloudColors = cloudColors;
if (_visualCache) {
_visualCache = { ..._visualCache, [type]: visual };
} else {
_visualCache = { [type]: visual };
}
saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }).catch(err =>
console.error(`[useIndicatorSettings] saveVisual failed for ${type}`, err)
);
},
[]
);
return { getParams, saveParams, saveAll, getVisualConfig, saveVisual };
}
@@ -0,0 +1,133 @@
/**
* useLiveStrategyMarkers
*
* STOMP WebSocket 으로 /sub/charts/{market}/{candleType} 구독:
* - signal 필드(BUY/SELL) 수신 시 마커 누적 (종목별)
* - 동일 종목·타임스탬프·시그널 중복 방지
* - activeMarket 차트에만 마커 렌더링, 알림 팝업은 모든 모니터링 종목에 대해 발생
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { parseStompJson } from '../utils/stompMessage';
import { subscribeStompTopic } from '../utils/stompChartBroker';
export interface LiveMarker {
time: number;
signal: 'BUY' | 'SELL';
price: number;
market: string;
}
export interface MarketCandleSub {
market: string;
candleType: string;
}
interface UseLiveStrategyMarkersOptions {
/** 실시간 체크 ON 인 종목 목록 */
markets: string[];
/** 종목별 STOMP 분봉 (없으면 candleType 기본값 사용) */
subscriptions?: MarketCandleSub[];
activeMarket: string;
candleType?: string;
enabled: boolean;
onMarkersChange: (markers: LiveMarker[]) => void;
onSignal?: (marker: LiveMarker) => void;
}
export function useLiveStrategyMarkers({
markets,
subscriptions,
activeMarket,
candleType = '1m',
enabled,
onMarkersChange,
onSignal,
}: UseLiveStrategyMarkersOptions) {
const [liveMarkers, setLiveMarkers] = useState<LiveMarker[]>([]);
const markersByMarketRef = useRef<Record<string, LiveMarker[]>>({});
const seenRef = useRef<Set<string>>(new Set());
const onMarkersChangeRef = useRef(onMarkersChange);
onMarkersChangeRef.current = onMarkersChange;
const onSignalRef = useRef(onSignal);
onSignalRef.current = onSignal;
const pushMarker = useCallback((market: string, data: {
time: number; close: number; signal: string;
}) => {
if (data.signal !== 'BUY' && data.signal !== 'SELL') return;
const key = `${market}:${data.time}:${data.signal}`;
if (seenRef.current.has(key)) return;
seenRef.current.add(key);
const newMarker: LiveMarker = {
time: data.time,
signal: data.signal as 'BUY' | 'SELL',
price: data.close,
market,
};
const prev = markersByMarketRef.current[market] ?? [];
markersByMarketRef.current[market] = [...prev, newMarker];
if (market === activeMarket) {
const active = markersByMarketRef.current[activeMarket] ?? [];
setLiveMarkers(active);
onMarkersChangeRef.current(active);
}
onSignalRef.current?.(newMarker);
}, [activeMarket]);
const clearMarkers = useCallback(() => {
seenRef.current.clear();
markersByMarketRef.current = {};
setLiveMarkers([]);
onMarkersChangeRef.current([]);
}, []);
/** 활성 차트 종목 변경 시 해당 종목 마커만 차트에 반영 */
useEffect(() => {
if (!enabled) return;
const active = markersByMarketRef.current[activeMarket] ?? [];
setLiveMarkers(active);
onMarkersChangeRef.current(active);
}, [activeMarket, enabled]);
const marketsKey = markets.join(',');
const subsKey = subscriptions?.map(s => `${s.market}:${s.candleType}`).join(',') ?? '';
useEffect(() => {
if (!enabled || markets.length === 0) {
clearMarkers();
return;
}
const subs: MarketCandleSub[] = subscriptions?.length
? subscriptions
: markets.map(m => ({ market: m, candleType }));
const unsubs = subs.map(({ market, candleType: ct }) => {
const topic = `/sub/charts/${market}/${ct}`;
return subscribeStompTopic(topic, msg => {
const data = parseStompJson<{
time: number;
close: number;
signal?: string;
}>(msg);
if (!data?.signal || data.signal === 'NONE') return;
pushMarker(market, { time: data.time, close: data.close, signal: data.signal });
});
});
return () => {
unsubs.forEach(u => u());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marketsKey, subsKey, candleType, enabled]);
return { liveMarkers, clearMarkers };
}
+261
View File
@@ -0,0 +1,261 @@
/**
* 업비트 마켓 목록 + 실시간 시세 훅
*
* 동작:
* 1) 앱 마운트 시 /v1/market/all 로 전체 마켓 목록 + 한글명 로드
* 2) /v1/ticker?markets=... 로 초기 시세 로드 (100개 단위 분할)
* 3) WebSocket ticker 구독으로 실시간 시세 수신 (배치 업데이트 500ms)
* 4) REST 폴링 30초마다 보조 갱신 (WebSocket 누락 방지)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
export interface TickerData {
market: string;
koreanName: string;
tradePrice: number | null;
changeRate: number | null; // 전일대비 등락률 (0.01 = +1%)
changePrice: number | null; // 전일대비 등락금액
accTradePrice24: number; // 24h 누적 거래대금 (KRW) 정렬용 (0 fallback)
accTradeVolume24: number | null; // 24h 누적 거래량 (코인)
openingPrice: number | null;
highPrice: number | null;
lowPrice: number | null;
change: ChangeDir; // RISE / FALL / EVEN
}
export interface MarketInfo {
market: string;
koreanName: string;
englishName: string;
}
const REST_POLL_INTERVAL = 30_000; // 30초 보조 폴링
const WS_BATCH_MS = 400; // WebSocket 업데이트 배치 간격
// ── 내부 API 타입 ─────────────────────────────────────────────────────────
function toChangeDir(raw: UpbitTickerRaw): ChangeDir {
if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change;
const r = raw.signed_change_rate ?? 0;
return r > 0 ? 'RISE' : r < 0 ? 'FALL' : 'EVEN';
}
interface UpbitMarketAll {
market: string;
korean_name: string;
english_name: string;
}
interface UpbitTickerRaw {
market?: string; // REST
code?: string; // WebSocket
trade_price: number;
signed_change_rate: number;
signed_change_price: number;
acc_trade_price_24h: number;
acc_trade_volume_24h?: number;
opening_price: number;
high_price: number;
low_price: number;
change?: 'RISE' | 'FALL' | 'EVEN';
}
function getMarketCode(raw: UpbitTickerRaw): string {
return (raw.code ?? raw.market ?? '') as string;
}
async function fetchAllMarkets(): Promise<MarketInfo[]> {
const res = await fetch('/upbit-api/v1/market/all?isDetails=true');
if (!res.ok) throw new Error(`market/all ${res.status}`);
const data: UpbitMarketAll[] = await res.json();
const krw = data.filter(m => m.market.startsWith('KRW-'));
const nameMap: Record<string, string> = {};
krw.forEach(m => { nameMap[m.market] = m.korean_name; });
setMarketNames(nameMap);
return krw.map(m => ({
market: m.market,
koreanName: m.korean_name,
englishName: m.english_name,
}));
}
async function fetchAllTickers(markets: string[]): Promise<UpbitTickerRaw[]> {
const results: UpbitTickerRaw[] = [];
for (let i = 0; i < markets.length; i += 100) {
const chunk = markets.slice(i, i + 100);
try {
const res = await fetch(`/upbit-api/v1/ticker?markets=${chunk.join(',')}`);
if (res.ok) results.push(...(await res.json()));
} catch { /* 부분 실패 무시 */ }
}
return results;
}
function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
return {
market: getMarketCode(raw),
koreanName,
tradePrice: raw.trade_price ?? null,
changeRate: raw.signed_change_rate ?? null,
changePrice: raw.signed_change_price ?? null,
accTradePrice24: raw.acc_trade_price_24h ?? 0,
accTradeVolume24: raw.acc_trade_volume_24h ?? null,
openingPrice: raw.opening_price ?? null,
highPrice: raw.high_price ?? null,
lowPrice: raw.low_price ?? null,
change: toChangeDir(raw),
};
}
// ── WebSocket 연결 관리 ───────────────────────────────────────────────────
function createTickerWs(
markets: string[],
onBatch: (rawList: UpbitTickerRaw[]) => void,
): () => void {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
let ws: WebSocket | null = null;
let destroyed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// 배치 버퍼: 너무 빈번한 state 업데이트 방지 (500ms 단위로 모아서 전달)
const batch: Map<string, UpbitTickerRaw> = new Map();
let batchTimer: ReturnType<typeof setTimeout> | null = null;
function flushBatch() {
batchTimer = null;
if (batch.size === 0) return;
onBatch(Array.from(batch.values()));
batch.clear();
}
function connect() {
if (destroyed) return;
ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ws?.send(JSON.stringify([
{ ticket: 'market-panel-ticker' },
{ type: 'ticker', codes: markets },
]));
};
ws.onmessage = (e: MessageEvent) => {
try {
const decoder = new TextDecoder('utf-8');
const text = typeof e.data === 'string'
? e.data
: decoder.decode(e.data as ArrayBuffer);
const msg: UpbitTickerRaw & { type?: string } = JSON.parse(text);
if (msg.type !== 'ticker' && !msg.code) return;
const code = getMarketCode(msg);
if (!code) return;
batch.set(code, msg);
if (!batchTimer) {
batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
}
} catch { /* ignore */ }
};
ws.onerror = () => { /* ignore onclose will handle */ };
ws.onclose = () => {
if (!destroyed) {
reconnectTimer = setTimeout(connect, 5_000);
}
};
}
connect();
return () => {
destroyed = true;
if (batchTimer) { clearTimeout(batchTimer); flushBatch(); }
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
};
}
// ── 훅 ──────────────────────────────────────────────────────────────────
export function useMarketTicker() {
const [tickers, setTickers] = useState<Map<string, TickerData>>(new Map());
const [marketInfos, setMarketInfos] = useState<MarketInfo[]>([]);
const [loading, setLoading] = useState(true);
const [usdRate, setUsdRate] = useState(1_300);
const mountedRef = useRef(true);
const marketListRef = useRef<string[]>([]);
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
if (!mountedRef.current) return;
setTickers(prev => {
const next = new Map(prev);
rawList.forEach(raw => {
const code = getMarketCode(raw);
if (!code) return;
const info = marketInfoMapRef.current.get(code);
const name = info?.koreanName ?? next.get(code)?.koreanName ?? code.replace(/^KRW-/, '');
next.set(code, rawToTicker(raw, name));
});
return next;
});
const usdt = rawList.find(t => getMarketCode(t) === 'KRW-USDT');
if (usdt?.trade_price) setUsdRate(usdt.trade_price);
}, []);
useEffect(() => {
mountedRef.current = true;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let destroyWs: (() => void) | null = null;
(async () => {
// 1) 마켓 목록
let infos: MarketInfo[] = [];
try { infos = await fetchAllMarkets(); }
catch { /* 실패 시 빈 목록 */ }
if (!mountedRef.current) return;
infos.forEach(m => marketInfoMapRef.current.set(m.market, m));
setMarketInfos(infos);
const markets = infos.map(m => m.market);
if (!markets.includes('KRW-USDT')) markets.push('KRW-USDT');
marketListRef.current = markets;
// 2) 초기 시세 (REST)
try {
const rawList = await fetchAllTickers(markets);
if (mountedRef.current) applyRawList(rawList);
} catch { /* ignore */ }
if (!mountedRef.current) return;
setLoading(false);
// 3) WebSocket 실시간 구독
destroyWs = createTickerWs(markets, applyRawList);
// 4) REST 보조 폴링 (30초 WebSocket 누락 보완)
pollTimer = setInterval(async () => {
if (!mountedRef.current) return;
try {
const rawList = await fetchAllTickers(marketListRef.current);
applyRawList(rawList);
} catch { /* ignore */ }
}, REST_POLL_INTERVAL);
})();
return () => {
mountedRef.current = false;
destroyWs?.();
if (pollTimer) clearInterval(pollTimer);
};
}, [applyRawList]);
return { tickers, marketInfos, loading, usdRate };
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
/** CSS 미디어 쿼리 매칭 여부 (모바일·태블릿 분기용) */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setMatches(mq.matches);
onChange();
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [query]);
return matches;
}
/** 차트 메인 레이아웃 모바일 분기 (≤768px) */
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 768px)');
}
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';
import { fetchMenuPermissions } from '../utils/backendApi';
import {
type MenuPermissionsDto,
type UserRole,
canAccessMenu,
normalizeRole,
} from '../utils/permissions';
let _cache: MenuPermissionsDto | null = null;
let _promise: Promise<MenuPermissionsDto> | null = null;
export function invalidateMenuPermissionsCache() {
_cache = null;
_promise = null;
}
function loadPermissions(): Promise<MenuPermissionsDto> {
if (_cache) return Promise.resolve(_cache);
if (_promise) return _promise;
_promise = fetchMenuPermissions().then(data => {
const normalized: MenuPermissionsDto = {
role: normalizeRole(data.role),
permissions: data.permissions ?? {},
};
_cache = normalized;
_promise = null;
return normalized;
}).catch(err => {
console.error('[useMenuPermissions] load failed', err);
_promise = null;
return { role: 'GUEST' as UserRole, permissions: { chart: true, settings: true } };
});
return _promise;
}
export function useMenuPermissions(sessionKey = 0) {
const [data, setData] = useState<MenuPermissionsDto>(_cache ?? { role: 'GUEST', permissions: {} });
const [loaded, setLoaded] = useState(_cache !== null);
useEffect(() => {
setLoaded(false);
invalidateMenuPermissionsCache();
loadPermissions().then(d => {
setData(d);
setLoaded(true);
});
}, [sessionKey]);
const can = useCallback(
(menuId: string) => canAccessMenu(data.permissions, menuId),
[data.permissions],
);
return { role: data.role, permissions: data.permissions, isLoaded: loaded, can };
}
+280
View File
@@ -0,0 +1,280 @@
/**
* 백엔드 STOMP /sub/charts/{market}/{candleType} 실시간 캔들 (Blueprint 중앙 집중 경로)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import type { OHLCVBar, Timeframe } from '../types';
import { parseStompJson } from '../utils/stompMessage';
import { subscribeStompTopic } from '../utils/stompChartBroker';
import { timeframeToCandleType } from '../utils/chartCandleType';
import { getCandleStart, TF_SECONDS } from '../utils/upbitApi';
import { DISPLAY_COUNT, WARMUP_COUNT } from './useUpbitData';
import type { WsStatus } from './useUpbitData';
import { API_BASE } from '../utils/backendApi';
/** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */
const STOMP_TICK_CANDLE_TYPE = '1m';
/** 표시 분봉 버킷 — 히스토리 마지막 봉과 STOMP time 불일치 시 마지막 봉 갱신용 */
function resolveChartBarTime(
bucketTime: number,
displayTf: Timeframe,
lastChartTime: number | undefined,
): number {
if (lastChartTime == null || bucketTime >= lastChartTime) return bucketTime;
const tfSec = TF_SECONDS[displayTf] ?? 60;
if (lastChartTime - bucketTime < tfSec) return lastChartTime;
return bucketTime;
}
async function pinChartWatch(market: string, candleType: string): Promise<void> {
try {
await fetch(`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, {
method: 'POST',
});
} catch {
/* watch 실패해도 STOMP 구독은 계속 */
}
}
interface StompCandlePayload {
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
interface Options {
onTickUpdate: (bar: OHLCVBar) => void;
onNewCandle: (bar: OHLCVBar) => void;
enabled?: boolean;
}
interface Return {
bars: OHLCVBar[];
latestBar: OHLCVBar | null;
wsStatus: WsStatus;
isLoading: boolean;
error: string | null;
}
async function fetchInitialBars(market: string, candleType: string, count: number): Promise<OHLCVBar[]> {
const params = new URLSearchParams({
market,
type: candleType,
count: String(count),
});
const res = await fetch(`${API_BASE}/candles/history?${params}`);
if (!res.ok) throw new Error(`history ${res.status}`);
const data = (await res.json()) as StompCandlePayload[];
return data.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
}));
}
async function fetchInitialBarsWithRetry(
market: string,
candleType: string,
count: number,
attempts = 3,
): Promise<OHLCVBar[]> {
let lastErr: Error | null = null;
for (let i = 0; i < attempts; i++) {
try {
return await fetchInitialBars(market, candleType, count);
} catch (e) {
lastErr = e as Error;
if (i < attempts - 1) {
await new Promise(r => setTimeout(r, 400 * (i + 1)));
}
}
}
throw lastErr ?? new Error('history fetch failed');
}
export function useStompChartData(
market: string,
timeframe: Timeframe,
opts: Options,
): Return {
const candleType = timeframeToCandleType(timeframe);
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const barsRef = useRef<OHLCVBar[]>([]);
const optsRef = useRef(opts);
optsRef.current = opts;
const lastTimeRef = useRef<number>(0);
/** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */
const last1mVolRef = useRef<number>(0);
const last1mTimeRef = useRef<number>(0);
useEffect(() => {
if (opts.enabled === false) return;
let cancelled = false;
setIsLoading(true);
setError(null);
setBars([]);
barsRef.current = [];
last1mVolRef.current = 0;
last1mTimeRef.current = 0;
lastTimeRef.current = 0;
const count = DISPLAY_COUNT + WARMUP_COUNT;
fetchInitialBarsWithRetry(market, candleType, count)
.then(newBars => {
if (cancelled) return;
barsRef.current = newBars;
setBars(newBars);
setLatestBar(newBars[newBars.length - 1] ?? null);
lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0;
setIsLoading(false);
})
.catch(err => {
if (cancelled) return;
setError(`STOMP 초기 로딩 실패: ${(err as Error).message}`);
setIsLoading(false);
});
return () => { cancelled = true; };
}, [market, candleType, opts.enabled]);
const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => {
const oneMin: OHLCVBar = {
time: data.time,
open: data.open,
high: data.high,
low: data.low,
close: data.close,
volume: data.volume,
};
const pushBar = (bar: OHLCVBar) => {
const current = barsRef.current;
if (current.length === 0) {
barsRef.current = [bar];
setLatestBar(bar);
optsRef.current.onNewCandle(bar);
lastTimeRef.current = bar.time;
return;
}
const last = current[current.length - 1];
if (bar.time === last.time) {
barsRef.current[current.length - 1] = bar;
setLatestBar(bar);
optsRef.current.onTickUpdate(bar);
} else if (bar.time > last.time) {
barsRef.current = [...current, bar];
setLatestBar(bar);
optsRef.current.onNewCandle(bar);
} else {
const patched = { ...bar, time: last.time };
barsRef.current[current.length - 1] = patched;
setLatestBar(patched);
optsRef.current.onTickUpdate(patched);
}
lastTimeRef.current = bar.time;
};
const lastChart = barsRef.current[barsRef.current.length - 1];
// 1m 차트: STOMP 페이로드를 그대로 사용
if (displayTf === '1m') {
const t = resolveChartBarTime(oneMin.time, '1m', lastChart?.time);
pushBar({ ...oneMin, time: t });
return;
}
// 상위 분봉: 1m 틱을 useUpbitData 와 동일하게 집계
const candleStart = resolveChartBarTime(
getCandleStart(oneMin.time * 1000, displayTf),
displayTf,
lastChart?.time,
);
const current = barsRef.current;
if (current.length === 0) {
pushBar({
time: candleStart,
open: oneMin.open,
high: oneMin.high,
low: oneMin.low,
close: oneMin.close,
volume: oneMin.volume,
});
last1mTimeRef.current = oneMin.time;
last1mVolRef.current = oneMin.volume;
return;
}
let volDelta = oneMin.volume;
if (oneMin.time === last1mTimeRef.current) {
volDelta = oneMin.volume - last1mVolRef.current;
} else {
last1mTimeRef.current = oneMin.time;
}
last1mVolRef.current = oneMin.volume;
const last = current[current.length - 1];
if (last.time === candleStart) {
const updated: OHLCVBar = {
time: candleStart,
open: last.open,
high: Math.max(last.high, oneMin.high),
low: Math.min(last.low, oneMin.low),
close: oneMin.close,
volume: Math.max(0, last.volume + volDelta),
};
pushBar(updated);
} else if (candleStart > last.time) {
pushBar({
time: candleStart,
open: oneMin.open,
high: oneMin.high,
low: oneMin.low,
close: oneMin.close,
volume: oneMin.volume,
});
last1mTimeRef.current = oneMin.time;
last1mVolRef.current = oneMin.volume;
}
}, []);
useEffect(() => {
if (opts.enabled === false) {
setWsStatus('disconnected');
return;
}
setWsStatus('connecting');
setError(null);
last1mVolRef.current = 0;
last1mTimeRef.current = 0;
void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE);
const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`;
const unsub = subscribeStompTopic(topic, msg => {
setWsStatus('connected');
const data = parseStompJson<StompCandlePayload>(msg);
if (data?.time == null || typeof data.time !== 'number') return;
applyCandle(data, timeframe);
});
const t = window.setTimeout(() => setWsStatus('connected'), 800);
return () => {
window.clearTimeout(t);
unsub();
setWsStatus('disconnected');
};
}, [market, timeframe, opts.enabled, applyCandle]);
return { bars, latestBar, wsStatus, isLoading, error };
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import {
type TradeAlertPopupLayout,
type TradeAlertPopupPosition,
normalizeTradeAlertGridCols,
} from '../utils/tradeAlertPopupLayout';
export interface ToastPageCapacity {
pageSize: number;
effectiveCols: number;
effectiveRows: number;
}
const MENUBAR_H = 52;
const EDGE_PAD = 32;
const TOOLBAR_RESERVE = 88;
const CARD_GAP = 10;
/** 우측 ‹ › 슬라이드 버튼 + 간격 */
const NAV_BTN_RESERVE = 80;
const CARD = {
full: { w: 380, h: 158 },
compact:{ w: 200, h: 86 },
strip: { w: 280, h: 86 },
} as const;
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
export function computeToastPageCapacity(
layout: TradeAlertPopupLayout,
position: TradeAlertPopupPosition,
gridCols: number,
viewportW: number,
viewportH: number,
): ToastPageCapacity {
const availW = position === 'bottom'
? viewportW - EDGE_PAD - NAV_BTN_RESERVE
: Math.min(420, viewportW - EDGE_PAD - NAV_BTN_RESERVE);
const availH = position === 'bottom'
? Math.min(viewportH * 0.52, 440) - TOOLBAR_RESERVE
: viewportH - MENUBAR_H - EDGE_PAD - TOOLBAR_RESERVE;
if (layout === 'single') {
return { pageSize: 1, effectiveCols: 1, effectiveRows: 1 };
}
if (layout === 'strip') {
const cols = clamp(
Math.floor((availW + CARD_GAP) / (CARD.strip.w + CARD_GAP)),
1,
8,
);
return { pageSize: cols, effectiveCols: cols, effectiveRows: 1 };
}
if (layout === 'grid') {
const configured = normalizeTradeAlertGridCols(gridCols);
const maxCols = clamp(
Math.floor((availW + CARD_GAP) / (CARD.compact.w + CARD_GAP)),
1,
4,
);
const cols = Math.min(configured, maxCols);
const rows = clamp(
Math.floor((availH + CARD_GAP) / (CARD.compact.h + CARD_GAP)),
1,
6,
);
return {
pageSize: clamp(cols * rows, 1, 24),
effectiveCols: cols,
effectiveRows: rows,
};
}
// stack
const rows = clamp(
Math.floor((availH + CARD_GAP) / (CARD.full.h + CARD_GAP)),
1,
8,
);
return { pageSize: rows, effectiveCols: 1, effectiveRows: rows };
}
export function useToastPageCapacity(
layout: TradeAlertPopupLayout,
position: TradeAlertPopupPosition,
gridCols: number,
): ToastPageCapacity {
const [capacity, setCapacity] = useState<ToastPageCapacity>(() =>
computeToastPageCapacity(
layout,
position,
gridCols,
typeof window !== 'undefined' ? window.innerWidth : 1280,
typeof window !== 'undefined' ? window.innerHeight : 800,
),
);
useEffect(() => {
const update = () => {
setCapacity(computeToastPageCapacity(
layout,
position,
gridCols,
window.innerWidth,
window.innerHeight,
));
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [layout, position, gridCols]);
return capacity;
}
+173
View File
@@ -0,0 +1,173 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { OHLCVBar, Timeframe } from '../types';
import {
UpbitWebSocketClient,
getCandleStart,
type UpbitTrade,
type UpbitMessage,
} from '../utils/upbitApi';
import { OBV_DEEP_HISTORY } from '../utils/upbitApi';
import { fetchUpbitCandlesCached, invalidateMarketCache } from '../utils/requestCache';
export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
interface UpbitDataOptions {
/**
* 같은 캔들 내 틱 수신 시 콜백.
* React state 변경 없이 차트를 직접 업데이트한다.
*/
onTickUpdate: (bar: OHLCVBar) => void;
/**
* 새 캔들이 시작될 때 콜백 (완성된 신규 bar 전달).
* React state 변경 없이 차트에 바를 추가한다.
*/
onNewCandle: (newBar: OHLCVBar) => void;
/** false면 REST/WebSocket 모두 비활성화 (시뮬레이션 모드용) */
enabled?: boolean;
/** OBV 등 누적 지표: 상장 이력에 가깝게 장기 캔들 로드 */
deepObvHistory?: boolean;
}
interface UseUpbitDataReturn {
/** 초기 200개 캔들 (심볼/타임프레임 변경 시에만 갱신) */
bars: OHLCVBar[];
/** 현재 진행 중인 캔들의 최신 스냅샷 (UI 표시용) */
latestBar: OHLCVBar | null;
wsStatus: WsStatus;
isLoading: boolean;
error: string | null;
}
/** 화면에 표시할 캔들 수 */
export const DISPLAY_COUNT = 200;
/**
* 지표 워밍업 기간 (안전 마진 포함)
* - 일목균형표 Span B: 52바
* - OBV 누적: 과거 봉이 많을수록 업비트 절대값에 근접 → 600바 워밍업
*/
export const WARMUP_COUNT = 600;
/** OBV 사용 시 초기 페치 봉 수 */
export { OBV_DEEP_HISTORY };
export function useUpbitData(
market: string,
timeframe: Timeframe,
opts: UpbitDataOptions,
): UseUpbitDataReturn {
// bars = 초기 FETCH_COUNT개 (REST), 실시간 수신 시 변경 안 함 → TradingChart setData 재호출 방지
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const barsRef = useRef<OHLCVBar[]>([]);
const optsRef = useRef(opts);
optsRef.current = opts;
// ── 1. REST API: 초기 캔들 로딩 (표시용 + 워밍업 합산) ─────────────────
// 이전 market 값을 기억해 종목 변경 시에만 캐시를 무효화한다.
const prevMarketRef = useRef<string>('');
useEffect(() => {
if (opts.enabled === false) return;
let cancelled = false;
setIsLoading(true);
setError(null);
setBars([]);
setLatestBar(null);
barsRef.current = [];
// 종목(market)이 변경된 경우에는 캐시를 무효화해 신규 요청을 강제한다.
// 타임프레임만 바뀐 경우도 캐시 키가 다르므로 자동으로 새 요청이 발생한다.
const marketChanged = prevMarketRef.current !== '' && prevMarketRef.current !== market;
prevMarketRef.current = market;
const fetchCount = opts.deepObvHistory
? OBV_DEEP_HISTORY
: DISPLAY_COUNT + WARMUP_COUNT;
fetchUpbitCandlesCached(market, timeframe, fetchCount, marketChanged)
.then(newBars => {
if (cancelled) return;
barsRef.current = newBars;
setBars(newBars);
setLatestBar(newBars[newBars.length - 1] ?? null);
setIsLoading(false);
})
.catch(err => {
if (cancelled) return;
setError(`데이터 로딩 실패: ${(err as Error).message}`);
setIsLoading(false);
});
return () => { cancelled = true; };
}, [market, timeframe, opts.enabled, opts.deepObvHistory]);
// ── 2. WebSocket: 실시간 체결 구독 ────────────────────────────────────
const handleMessage = useCallback((msg: UpbitMessage) => {
if (msg.type !== 'trade') return;
const trade = msg as UpbitTrade;
const currentBars = barsRef.current;
if (currentBars.length === 0) return;
const candleStart = getCandleStart(trade.trade_timestamp, timeframe);
const lastBar = currentBars[currentBars.length - 1];
if (lastBar.time === candleStart) {
// ── 동일 캔들: close/high/low/volume 갱신 ──────────────────────────
const updated: OHLCVBar = {
...lastBar,
close: trade.trade_price,
high: Math.max(lastBar.high, trade.trade_price),
low: Math.min(lastBar.low, trade.trade_price),
volume: lastBar.volume + trade.trade_volume,
};
barsRef.current[currentBars.length - 1] = updated;
// UI 표시용 latestBar만 갱신 (bars state는 변경하지 않음)
setLatestBar(updated);
// 차트 직접 업데이트 (React re-render 없음)
optsRef.current.onTickUpdate(updated);
} else if (candleStart > lastBar.time) {
// ── 새 캔들: barsRef에만 추가, bars state는 건드리지 않음 ──────────
const newBar: OHLCVBar = {
time: candleStart,
open: trade.trade_price,
high: trade.trade_price,
low: trade.trade_price,
close: trade.trade_price,
volume: trade.trade_volume,
};
barsRef.current = [...barsRef.current, newBar];
setLatestBar(newBar);
// 차트에 새 바만 추가 + 인디케이터 last point 업데이트 (React re-render 없음)
optsRef.current.onNewCandle(newBar);
}
}, [timeframe]);
useEffect(() => {
if (opts.enabled === false) return;
setWsStatus('connecting');
setError(null); // 재연결 시 기존 오류 초기화
const client = new UpbitWebSocketClient({
market,
onMessage: handleMessage,
onConnect: () => { setWsStatus('connected'); setError(null); },
onDisconnect: () => setWsStatus('disconnected'),
// 연결 끊김은 자동 재연결되므로 에러 표시만 (상태는 disconnected 유지)
onError: (e) => { console.warn('[UpbitWS]', e); },
});
return () => client.destroy();
}, [market, timeframe, handleMessage, opts.enabled]);
return { bars, latestBar, wsStatus, isLoading, error };
}
+222
View File
@@ -0,0 +1,222 @@
/**
* 업비트 실시간 호가 훅
*
* frontend_golden/src/hooks/useUpbitOrderbook.ts 를 현재 프로젝트에 맞게 변환:
* - WebSocket: /upbit-ws (nginx 프록시 → wss://api.upbit.com/websocket/v1)
* - REST: /upbit-api/v1/orderbook
* - console.log 제거, 재연결 로직 강화
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
// ── 타입 ────────────────────────────────────────────────────────────────────
export interface OrderbookUnit {
ask_price: number;
bid_price: number;
ask_size: number;
bid_size: number;
}
export interface OrderbookDisplayUnit {
price: number;
size: number;
percentage: number; // 최대 잔량 대비 비율 (0–100), 배경 바 넓이
type: 'ask' | 'bid';
}
export interface OrderbookState {
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
timestamp: number;
connected: boolean;
}
export type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
interface UpbitObWsMessage {
type: string;
code: string;
total_ask_size: number;
total_bid_size: number;
orderbook_units: OrderbookUnit[];
timestamp: number;
stream_type?: string;
}
// ── 상수 ────────────────────────────────────────────────────────────────────
const RECONNECT_DELAY = 3_000;
const INITIAL_STATE: OrderbookState = {
asks: [], bids: [], totalAskSize: 0, totalBidSize: 0, timestamp: 0, connected: false,
};
// ── 훅 ──────────────────────────────────────────────────────────────────────
export function useUpbitOrderbook(market: string) {
const [orderbook, setOrderbook] = useState<OrderbookState>(INITIAL_STATE);
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
const wsRef = useRef<WebSocket | null>(null);
const reconnTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const isConnectingRef = useRef(false);
// ── 데이터 파싱 → state ─────────────────────────────────────────────────
const processData = useCallback((msg: UpbitObWsMessage) => {
const units = msg.orderbook_units;
if (!units || units.length === 0) return;
const maxSize = Math.max(
...units.map(u => u.ask_size),
...units.map(u => u.bid_size),
);
// 매도: 높은 가격 → 낮은 가격 (역순)
const asks: OrderbookDisplayUnit[] = units
.map(u => ({
price: u.ask_price,
size: u.ask_size,
percentage: maxSize > 0 ? (u.ask_size / maxSize) * 100 : 0,
type: 'ask' as const,
}))
.reverse();
// 매수: 높은 가격 → 낮은 가격 (정순 1번 unit이 best bid)
const bids: OrderbookDisplayUnit[] = units.map(u => ({
price: u.bid_price,
size: u.bid_size,
percentage: maxSize > 0 ? (u.bid_size / maxSize) * 100 : 0,
type: 'bid' as const,
}));
setOrderbook({
asks,
bids,
totalAskSize: msg.total_ask_size,
totalBidSize: msg.total_bid_size,
timestamp: msg.timestamp,
connected: true,
});
}, []);
// ── REST 초기 스냅샷 ────────────────────────────────────────────────────
const fetchSnapshot = useCallback(async () => {
try {
const res = await fetch(`/upbit-api/v1/orderbook?markets=${market}`);
if (!res.ok) return;
const data: Array<{
market: string;
timestamp: number;
total_ask_size: number;
total_bid_size: number;
orderbook_units: OrderbookUnit[];
}> = await res.json();
if (data && data[0]) {
const d = data[0];
processData({
type: 'orderbook',
code: d.market,
total_ask_size: d.total_ask_size,
total_bid_size: d.total_bid_size,
orderbook_units: d.orderbook_units,
timestamp: d.timestamp,
stream_type: 'SNAPSHOT',
});
}
} catch { /* ignore */ }
}, [market, processData]);
// ── WebSocket 연결 ──────────────────────────────────────────────────────
const connect = useCallback(() => {
if (!mountedRef.current) return;
if (isConnectingRef.current) return;
if (wsRef.current?.readyState === WebSocket.OPEN) return;
isConnectingRef.current = true;
setWsStatus('connecting');
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) { ws.close(); return; }
isConnectingRef.current = false;
setWsStatus('connected');
ws.send(JSON.stringify([
{ ticket: `ob-${market}-${Date.now()}` },
{ type: 'orderbook', codes: [market] },
]));
};
ws.onmessage = async (e: MessageEvent) => {
if (!mountedRef.current) return;
try {
let text: string;
if (typeof e.data === 'string') {
text = e.data;
} else if (e.data instanceof ArrayBuffer) {
text = new TextDecoder('utf-8').decode(e.data);
} else if (e.data instanceof Blob) {
text = await (e.data as Blob).text();
} else return;
const msg: UpbitObWsMessage = JSON.parse(text);
if (msg.type === 'orderbook' && msg.code === market) {
processData(msg);
}
} catch { /* ignore */ }
};
ws.onerror = () => {
isConnectingRef.current = false;
if (mountedRef.current) setWsStatus('error');
};
ws.onclose = () => {
isConnectingRef.current = false;
if (!mountedRef.current) return;
setWsStatus('disconnected');
setOrderbook(prev => ({ ...prev, connected: false }));
// 자동 재연결
reconnTimerRef.current = setTimeout(connect, RECONNECT_DELAY);
};
}, [market, processData]);
// ── 연결 해제 ────────────────────────────────────────────────────────────
const disconnect = useCallback(() => {
if (reconnTimerRef.current) { clearTimeout(reconnTimerRef.current); reconnTimerRef.current = null; }
if (wsRef.current) {
const ws = wsRef.current;
ws.onopen = null; ws.onmessage = null; ws.onerror = null; ws.onclose = null;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
wsRef.current = null;
}
isConnectingRef.current = false;
}, []);
// ── 라이프사이클 ─────────────────────────────────────────────────────────
useEffect(() => {
mountedRef.current = true;
setOrderbook(INITIAL_STATE);
fetchSnapshot();
connect();
return () => {
mountedRef.current = false;
disconnect();
};
}, [market]); // eslint-disable-line react-hooks/exhaustive-deps
// ── 스프레드 계산 ────────────────────────────────────────────────────────
const spread = useMemo(() => {
const { asks, bids } = orderbook;
if (!asks.length || !bids.length) return { bestAsk: 0, bestBid: 0, spread: 0, pct: 0 };
const bestAsk = asks[asks.length - 1].price; // asks 배열 마지막 = 가장 낮은 매도가
const bestBid = bids[0].price; // bids 배열 첫번째 = 가장 높은 매수가
const diff = bestAsk - bestBid;
return { bestAsk, bestBid, spread: diff, pct: bestBid > 0 ? (diff / bestBid) * 100 : 0 };
}, [orderbook.asks, orderbook.bids]);
return { orderbook, wsStatus, spread };
}
+119
View File
@@ -0,0 +1,119 @@
/**
* 업비트 최근 체결 (호가창 좌측 체결 목록용)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
export interface RecentTrade {
price: number;
volume: number;
side: 'ask' | 'bid'; // ask=매도체결, bid=매수체결
time: number;
}
const MAX_TRADES = 12;
interface UpbitTradeRaw {
market: string;
trade_price: number;
trade_volume: number;
ask_bid: 'ASK' | 'BID';
timestamp: number;
}
interface UpbitTradeWs {
type: string;
code: string;
trade_price: number;
trade_volume: number;
ask_bid: 'ASK' | 'BID';
timestamp: number;
}
export function useUpbitRecentTrades(market: string) {
const [trades, setTrades] = useState<RecentTrade[]>([]);
const [strength, setStrength] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const mountedRef = useRef(true);
const pushTrade = useCallback((t: RecentTrade) => {
setTrades(prev => {
const next = [t, ...prev].slice(0, MAX_TRADES);
const buyVol = next.filter(x => x.side === 'bid').reduce((s, x) => s + x.volume, 0);
const sellVol = next.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
else if (buyVol > 0) setStrength(100);
return next;
});
}, []);
const fetchSnapshot = useCallback(async () => {
try {
const res = await fetch(`/upbit-api/v1/trades/ticks?market=${market}&count=${MAX_TRADES}`);
if (!res.ok) return;
const data: UpbitTradeRaw[] = await res.json();
const list: RecentTrade[] = data.map(d => ({
price: d.trade_price,
volume: d.trade_volume,
side: d.ask_bid === 'ASK' ? 'ask' : 'bid',
time: d.timestamp,
}));
setTrades(list);
const buyVol = list.filter(x => x.side === 'bid').reduce((s, x) => s + x.volume, 0);
const sellVol = list.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
} catch { /* ignore */ }
}, [market]);
useEffect(() => {
mountedRef.current = true;
setTrades([]);
setStrength(null);
fetchSnapshot();
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify([
{ ticket: `tr-${market}-${Date.now()}` },
{ type: 'trade', codes: [market] },
]));
};
ws.onmessage = async (e: MessageEvent) => {
if (!mountedRef.current) return;
try {
let text: string;
if (typeof e.data === 'string') text = e.data;
else if (e.data instanceof ArrayBuffer) text = new TextDecoder().decode(e.data);
else if (e.data instanceof Blob) text = await e.data.text();
else return;
const msg: UpbitTradeWs = JSON.parse(text);
if (msg.type === 'trade' && msg.code === market) {
pushTrade({
price: msg.trade_price,
volume: msg.trade_volume,
side: msg.ask_bid === 'ASK' ? 'ask' : 'bid',
time: msg.timestamp,
});
}
} catch { /* ignore */ }
};
return () => {
mountedRef.current = false;
if (wsRef.current) {
wsRef.current.onopen = null;
wsRef.current.onmessage = null;
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
};
}, [market, fetchSnapshot, pushTrade]);
return { trades, strength };
}
+112
View File
@@ -0,0 +1,112 @@
/**
* 차트 워크스페이스 설정을 DB(백엔드)에 자동 저장·복원하는 훅.
*
* 사용법:
* const { isLoaded, saveNow } = useWorkspacePersist({ layoutId, syncOptions, slots, setLayoutId, setSlots });
*
* - 마운트 시 DB에서 설정을 로드하여 상태를 복원한다.
* - 설정이 변경될 때마다 debounce(2초) 후 자동 저장한다.
* - saveNow() 를 호출하면 즉시 저장한다.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
loadWorkspace,
saveWorkspace,
loadWatchlist,
loadHoldings,
WatchlistItem,
HoldingsItem,
} from '../utils/backendApi';
export interface WorkspaceState {
layoutId: string;
syncOptions: unknown;
slots: unknown[];
}
interface UseWorkspacePersistOptions {
/** 로그인/로그아웃 시 증가 → DB에서 워크스페이스 재로드 */
sessionKey?: number;
/** 현재 워크스페이스 상태 */
state: WorkspaceState;
/** DB 로드 후 상태를 반영하는 콜백 */
onLoad: (data: WorkspaceState) => void;
/** 관심종목 로드 콜백 */
onWatchlistLoad?: (items: WatchlistItem[]) => void;
/** 보유종목 로드 콜백 */
onHoldingsLoad?: (items: HoldingsItem[]) => void;
/** 자동 저장 debounce ms (기본 2000) */
debounceMs?: number;
/** 자동 저장 비활성화 */
noAutoSave?: boolean;
}
export function useWorkspacePersist({
sessionKey = 0,
state,
onLoad,
onWatchlistLoad,
onHoldingsLoad,
debounceMs = 2000,
noAutoSave = false,
}: UseWorkspacePersistOptions) {
const [isLoaded, setIsLoaded] = useState(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstRender = useRef(true);
// ── 초기 로드 (세션 변경 시 재로드) ─────────────────────────────────────────
useEffect(() => {
let cancelled = false;
setIsLoaded(false);
isFirstRender.current = true;
(async () => {
const [ws, watchlist, holdings] = await Promise.all([
loadWorkspace(),
onWatchlistLoad ? loadWatchlist() : Promise.resolve(null),
onHoldingsLoad ? loadHoldings() : Promise.resolve(null),
]);
if (cancelled) return;
if (ws) {
onLoad({
layoutId: ws.layoutId,
syncOptions: ws.syncOptions,
slots: ws.slots,
});
}
if (watchlist && onWatchlistLoad) onWatchlistLoad(watchlist);
if (holdings && onHoldingsLoad) onHoldingsLoad(holdings);
setIsLoaded(true);
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionKey]);
// ── 자동 저장 (debounce) ──────────────────────────────────────────────────
useEffect(() => {
if (!isLoaded || noAutoSave) return;
if (isFirstRender.current) { isFirstRender.current = false; return; }
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveWorkspace(state as Parameters<typeof saveWorkspace>[0]);
}, debounceMs);
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [state, isLoaded, noAutoSave, debounceMs]);
// ── 즉시 저장 ──────────────────────────────────────────────────────────────
const saveNow = useCallback(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
return saveWorkspace(state as Parameters<typeof saveWorkspace>[0]);
}, [state]);
return { isLoaded, saveNow };
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

Some files were not shown because too many files have changed in this diff Show More