diff --git a/app/README.md b/app/README.md index 3778e07..7ec1b07 100644 --- a/app/README.md +++ b/app/README.md @@ -34,11 +34,20 @@ npm run cap:android # or cap:ios Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`. +### 로그인 시 "Failed to fetch" + +- APK는 빌드 시 `.env`의 `VITE_API_BASE_URL`이 번들에 박힙니다 (기본: `http://exdev.co.kr/api`). +- `capacitor.config.ts`의 `androidScheme`은 **`http`** 여야 합니다. `https`이면 WebView가 `https://localhost`에서 뜨고 HTTP API 호출이 차단됩니다. +- 설정에 `localhost` API가 저장돼 있으면 앱 시작 시 자동으로 제거합니다. + ## FCM (Firebase) -- Android: `android/app/google-services.json` +- Android: `app/android/app/google-services.json` — [FCM_SETUP.md](android/FCM_SETUP.md) 참고 + goldenApp(`goldenanalysisapp`)과 동일 프로젝트, 패키지 `com.goldenchart.app` 등록 필요 +- 설정: `./scripts/setup-android-fcm.sh [다운로드한 json 경로]` - iOS: `ios/App/GoogleService-Info.plist` + Push Notifications capability -- Backend: `FIREBASE_ENABLED=true` + service account JSON +- Backend: `FIREBASE_ENABLED=true` + `firebase-service-account.json` +- 앱: 로그인 후 설정에서 **FCM 푸시** ON + 알림 권한 허용 ## Structure diff --git a/app/android/FCM_SETUP.md b/app/android/FCM_SETUP.md new file mode 100644 index 0000000..83b7a4a --- /dev/null +++ b/app/android/FCM_SETUP.md @@ -0,0 +1,52 @@ +# Android FCM 설정 (goldenApp / goldenanalysisapp) + +기존 **goldenApp** (`~/dev/goldenAnalysis/goldenApp`) 과 동일 Firebase 프로젝트를 사용합니다. + +## 1. Firebase Console + +1. [Firebase Console](https://console.firebase.google.com/) → 프로젝트 **goldenanalysisapp** +2. **앱 추가** → Android +3. 패키지 이름: `com.goldenchart.app` (Capacitor `applicationId` 와 동일) +4. `google-services.json` 다운로드 + +## 2. 프로젝트에 배치 + +```bash +./scripts/setup-android-fcm.sh ~/Downloads/google-services.json +# 또는 +cp ~/Downloads/google-services.json app/android/app/google-services.json +``` + +## 3. 빌드 + +```bash +npm run cap:sync +cd app/android && ./gradlew assembleRelease +``` + +`google-services.json` 이 없으면 Gradle 로그에 +`google-services plugin not applied` 가 나오며 **푸시가 동작하지 않습니다**. + +## 4. 서버 (exdev) + +- `FIREBASE_ENABLED=true` +- `firebase-service-account.json` (goldenanalysisapp 서비스 계정) +- 앱 설정에서 **FCM 푸시** ON (`fcm_push_enabled`) + +## 5. 앱에서 확인 + +1. 로그인 후 **설정 → FCM 푸시** ON +2. 알림 권한 허용 +3. **테스트 발송** 버튼 +4. 전략 시그널 발생 시 푸시 수신 + +## goldenApp 과의 차이 + +| 항목 | goldenApp | GoldenChart (Capacitor) | +|------|-----------|-------------------------| +| 패키지 | `com.golden.app` | `com.goldenchart.app` | +| FCM SDK | 직접 Firebase Messaging | `@capacitor/push-notifications` | +| 토큰 등록 | `POST /api/fcm/token` | 동일 (`registerFcmToken`) | +| 알림 채널 | `golden_alerts` | `goldenchart_trade_signals` | + +로그인 사용자는 **userId** 기준으로 FCM 토큰을 조회하므로, 웹·모바일 deviceId 가 달라도 푸시를 받을 수 있습니다. diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index c07e659..2fb6e28 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.goldenchart.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2 - versionName "1.1" + versionCode 7 + versionName "1.6" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/app/android/app/google-services.json.reference b/app/android/app/google-services.json.reference new file mode 100644 index 0000000..b6ae214 --- /dev/null +++ b/app/android/app/google-services.json.reference @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "353782909389", + "project_id": "goldenanalysisapp", + "storage_bucket": "goldenanalysisapp.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84", + "android_client_info": { + "package_name": "com.golden.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 845d075..a17ac3c 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + + + + + - - - - diff --git a/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java b/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java index d08d6bf..e6f2555 100644 --- a/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java +++ b/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java @@ -1,5 +1,41 @@ package com.goldenchart.app; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.os.Build; +import android.os.Bundle; + import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +/** + * Capacitor 메인 액티비티 — FCM 알림 채널 생성 (goldenApp / Android 8+) + */ +public class MainActivity extends BridgeActivity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + createNotificationChannel(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + String channelId = getString(R.string.default_notification_channel_id); + String channelName = getString(R.string.default_notification_channel_name); + String channelDescription = getString(R.string.default_notification_channel_description); + + NotificationChannel channel = new NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription(channelDescription); + channel.enableVibration(true); + channel.enableLights(true); + + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) { + manager.createNotificationChannel(channel); + } + } +} diff --git a/app/android/app/src/main/res/values/strings.xml b/app/android/app/src/main/res/values/strings.xml index f21ce15..58f9e6b 100644 --- a/app/android/app/src/main/res/values/strings.xml +++ b/app/android/app/src/main/res/values/strings.xml @@ -1,7 +1,8 @@ - + GoldenChart GoldenChart - com.goldenchart.app - com.goldenchart.app + goldenchart_trade_signals + 매매 시그널 알림 + 전략 조건 일치 시 매수·매도 푸시 알림 diff --git a/app/capacitor.config.ts b/app/capacitor.config.ts index 1471206..586f0b6 100644 --- a/app/capacitor.config.ts +++ b/app/capacitor.config.ts @@ -5,10 +5,14 @@ const config: CapacitorConfig = { appName: 'GoldenChart', webDir: 'dist', server: { - androidScheme: 'https', + // https 스킴이면 WebView가 https://localhost → http API 호출이 mixed content로 차단됨 (Failed to fetch) + androidScheme: 'http', iosScheme: 'capacitor', }, plugins: { + CapacitorHttp: { + enabled: true, + }, SplashScreen: { launchAutoHide: true, backgroundColor: '#0f0f23', diff --git a/app/src/App.tsx b/app/src/App.tsx index ffa39b7..9310b87 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -43,7 +43,7 @@ function MainApp() { const defaults = resolveAppDefaults(settings); useEffect(() => { - if (!isLoaded || !defaults.fcmPushEnabled) return; + if (!isLoaded) return; void initFcmPush({ onForeground: (_title, _body, data) => { if (data?.market && data?.signalType) { @@ -67,7 +67,7 @@ function MainApp() { } }, }); - }, [isLoaded, defaults.fcmPushEnabled, addNotification, refreshHistory, setTab, openVirtualFocus]); + }, [isLoaded, addNotification, refreshHistory, setTab, openVirtualFocus]); useEffect(() => { document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark'); @@ -105,6 +105,7 @@ function AppRoot() { isAppEntered, handleLoginSuccess, handleGuestEnter, + sessionKey, } = useAuth(); const [nativeReady, setNativeReady] = useState(false); @@ -136,7 +137,11 @@ function AppRoot() { return ( - + diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx index f8172e3..267bb7c 100644 --- a/app/src/contexts/AuthContext.tsx +++ b/app/src/contexts/AuthContext.tsx @@ -6,16 +6,20 @@ import React, { useMemo, useState, } from 'react'; +import { Capacitor } from '@capacitor/core'; import { clearAuthSession, fetchAuthMe, getAuthSession, initStorage, + refreshApiBaseFromStorage, setAuthSession, + storageGetSync, + storageRemove, type AuthSession, type LoginResponse, } from '../lib/shared'; -import { invalidateAppSettingsCache } from '../hooks/useAppSettings'; +import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings'; function normalizeRole(role: string): AuthSession['role'] { return role === 'ADMIN' ? 'ADMIN' : 'USER'; @@ -53,10 +57,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { let cancelled = false; void (async () => { await initStorage(); + if (Capacitor.isNativePlatform()) { + const savedApi = storageGetSync('gc_api_base_url'); + if (savedApi && /localhost|127\.0\.0\.1/i.test(savedApi)) { + await storageRemove('gc_api_base_url'); + } + } + refreshApiBaseFromStorage(); const stored = getAuthSession(); if (stored) { try { - const me = await fetchAuthMe(); + const me = await Promise.race([ + fetchAuthMe(), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('session verify timeout')), 15_000); + }), + ]); if (me && !cancelled) { const session = loginToSession(me); setAuthSession(session); @@ -84,7 +100,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setAuthUser(session); setGuestMode(false); invalidateAppSettingsCache(); - setSessionKey(k => k + 1); + void reloadAppSettingsCache().finally(() => { + setSessionKey(k => k + 1); + }); }, []); const handleGuestEnter = useCallback(() => { @@ -92,7 +110,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setAuthUser(null); setGuestMode(true); invalidateAppSettingsCache(); - setSessionKey(k => k + 1); + void reloadAppSettingsCache().finally(() => { + setSessionKey(k => k + 1); + }); }, []); const handleLogout = useCallback(async () => { diff --git a/app/src/hooks/useAppSettings.ts b/app/src/hooks/useAppSettings.ts index f8a4118..cd872f5 100644 --- a/app/src/hooks/useAppSettings.ts +++ b/app/src/hooks/useAppSettings.ts @@ -4,4 +4,5 @@ export { subscribeAppSettings, resolveAppDefaults, invalidateAppSettingsCache, + reloadAppSettingsCache, } from '@frontend/hooks/useAppSettings'; diff --git a/app/src/screens/LoginScreen.tsx b/app/src/screens/LoginScreen.tsx index b0bf50e..09d5774 100644 --- a/app/src/screens/LoginScreen.tsx +++ b/app/src/screens/LoginScreen.tsx @@ -1,6 +1,5 @@ -/** 웹 SplashScreen과 동일한 로그인 진입 화면 */ -import SplashScreen from '@frontend/components/SplashScreen'; import type { LoginResponse } from '../lib/shared'; +import MobileLoginScreen from './MobileLoginScreen'; interface Props { onLoginSuccess: (res: LoginResponse) => void; @@ -8,5 +7,5 @@ interface Props { } export default function LoginScreen({ onLoginSuccess, onGuest }: Props) { - return ; + return ; } diff --git a/app/src/screens/MobileLoginScreen.tsx b/app/src/screens/MobileLoginScreen.tsx new file mode 100644 index 0000000..c659d3a --- /dev/null +++ b/app/src/screens/MobileLoginScreen.tsx @@ -0,0 +1,97 @@ +/** + * 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구) + */ +import React, { useEffect, useState } from 'react'; +import { loginUser, type LoginResponse } from '../lib/shared'; +import '@frontend/styles/splashScreen.css'; + +const DEFAULT_USERNAME = 'admin'; +const DEFAULT_PASSWORD = 'admin'; +const APP_VERSION = '1.2'; + +interface Props { + onLoginSuccess: (res: LoginResponse) => void; + onGuest: () => void; +} + +export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) { + const [username, setUsername] = useState(DEFAULT_USERNAME); + const [password, setPassword] = useState(DEFAULT_PASSWORD); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [slowHint, setSlowHint] = useState(false); + + useEffect(() => { + if (!loading) { + setSlowHint(false); + return; + } + const t = window.setTimeout(() => setSlowHint(true), 5000); + return () => window.clearTimeout(t); + }, [loading]); + + 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 ( +
+
+
+ +
+
+

GoldenChart

+ +
+ setUsername(ev.target.value)} + disabled={loading} + placeholder="ID" + /> + setPassword(ev.target.value)} + disabled={loading} + placeholder="••••••••" + /> + {error &&

{error}

} + {slowHint && loading && !error && ( +

+ 서버 응답이 느립니다. Wi‑Fi·모바일 데이터를 확인해 주세요… +

+ )} + +
+ +

+ 버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot +

+
+ + +
+
+ ); +} diff --git a/app/src/screens/notifications/NotificationsScreen.tsx b/app/src/screens/notifications/NotificationsScreen.tsx index 6d44907..8f2752b 100644 --- a/app/src/screens/notifications/NotificationsScreen.tsx +++ b/app/src/screens/notifications/NotificationsScreen.tsx @@ -1,7 +1,8 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTradeNotification } from '../../contexts/TradeNotificationContext'; import { useNavigation } from '../../contexts/NavigationContext'; import { useAuth } from '../../contexts/AuthContext'; +import { useAppSettings, reloadAppSettingsCache } from '../../hooks/useAppSettings'; import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore'; import NotificationListRow from './NotificationListRow'; import NotificationDetailScreen from './NotificationDetailScreen'; @@ -15,7 +16,8 @@ export default function NotificationsScreen() { goNotifyTrade, goVirtualDetail, } = useNavigation(); - const { sessionKey } = useAuth(); + const { sessionKey, guestMode, authUser } = useAuth(); + const { isLoaded: settingsLoaded } = useAppSettings(sessionKey); const { allNotifications, unreadCount, @@ -28,16 +30,48 @@ export default function NotificationsScreen() { const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey }); const [refreshing, setRefreshing] = useState(false); + const [pullY, setPullY] = useState(0); + const listRef = useRef(null); + const touchStartY = useRef(0); const selectedItem = useMemo( () => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null, [allNotifications, notifyNav.notifyId], ); - const onRefresh = async () => { + const onRefresh = useCallback(async () => { + if (refreshing) return; setRefreshing(true); - await refreshHistory(); - setRefreshing(false); + try { + await reloadAppSettingsCache(); + await refreshHistory({ serverOnly: true, rawServerList: true }); + } finally { + setRefreshing(false); + setPullY(0); + } + }, [refreshing, refreshHistory]); + + useEffect(() => { + if (!settingsLoaded) return; + void refreshHistory({ serverOnly: true, rawServerList: true }); + }, [settingsLoaded, sessionKey, refreshHistory]); + + const onTouchStart = (e: React.TouchEvent) => { + const el = listRef.current; + if (!el || el.scrollTop > 0) return; + touchStartY.current = e.touches[0].clientY; + }; + + const onTouchMove = (e: React.TouchEvent) => { + const el = listRef.current; + if (!el || el.scrollTop > 0) return; + const dy = e.touches[0].clientY - touchStartY.current; + if (dy > 0) setPullY(Math.min(dy, 72)); + }; + + const onTouchEnd = () => { + if (pullY >= 48) void onRefresh(); + else setPullY(0); }; if (notifyNav.view === 'detail' && selectedItem) { @@ -70,39 +104,76 @@ export default function NotificationsScreen() { } return ( -
+

알림 {unreadCount > 0 && `(${unreadCount})`}

-
- +
+
- {allNotifications.length === 0 ? ( -
-

알림 없음

-

전략 조건 충족 시 알림이 표시됩니다

-
- ) : ( -
- {allNotifications.map(item => ( - { - markAsRead(item.id); - goNotifyDetail(item.id, item.market); - }} - onTrade={side => { - markAsRead(item.id); - goNotifyTrade(item.market, side); - }} - onDelete={() => void deleteNotification(item.id)} - /> - ))} -
+ {guestMode && ( +

+ 게스트 모드 — 웹과 동일 목록은 로그인 후 새로고침하세요. +

)} + {!guestMode && authUser && ( +

+ {authUser.username} · 서버 이력과 동기화됩니다 +

+ )} + +
+ {(pullY > 0 || refreshing) && ( +
0 ? `${pullY / 4}px` : 8, fontSize: 12, color: 'var(--gc-text-muted)' }}> + {refreshing ? '서버에서 불러오는 중…' : pullY >= 48 ? '놓으면 새로고침' : '당겨서 새로고침'} +
+ )} + + {allNotifications.length === 0 ? ( +
+

알림 없음

+

전략 조건 충족 시 알림이 표시됩니다

+ +
+ ) : ( +
+ {allNotifications.map(item => ( + { + markAsRead(item.id); + goNotifyDetail(item.id, item.market); + }} + onTrade={side => { + markAsRead(item.id); + goNotifyTrade(item.market, side); + }} + onDelete={() => void deleteNotification(item.id)} + /> + ))} +
+ )} +
{allNotifications.length > 0 && (
diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx index 23e06ac..be07dd3 100644 --- a/app/src/screens/virtual/VirtualTradingScreen.tsx +++ b/app/src/screens/virtual/VirtualTradingScreen.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useAuth } from '../../contexts/AuthContext'; +import { useAppSettings } from '../../hooks/useAppSettings'; import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore'; import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy'; import { useNavigation } from '../../contexts/NavigationContext'; @@ -20,6 +21,7 @@ export default function VirtualTradingScreen() { goVirtualHistory, } = useNavigation(); const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth(); + const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey); const core = useVirtualTradingCore({ settingsSessionKey }); const { @@ -156,15 +158,15 @@ export default function VirtualTradingScreen() { />
- {loading ? ( -
로딩 중…
+ {!settingsLoaded || loading ? ( +
{!settingsLoaded ? '설정 동기화 중…' : '로딩 중…'}
) : targets.length === 0 ? (

투자 대상 없음

{guestMode ? ( -

게스트 모드입니다. 웹과 같은 목록을 보려면 동일 계정으로 로그인하세요.

+

게스트 모드입니다. 웹과 같은 목록을 보려면 동일 계정으로 로그인하세요.

) : ( -

웹과 동일 계정({authUser?.username}) · + 버튼으로 종목 추가

+

웹({authUser?.username})과 동일 DB입니다. ↻ 동기화 또는 + 로 종목 추가

)}
) : ( diff --git a/app/src/services/fcm.ts b/app/src/services/fcm.ts index 24a9ba2..faea306 100644 --- a/app/src/services/fcm.ts +++ b/app/src/services/fcm.ts @@ -5,6 +5,9 @@ import { import { PushNotifications } from '@capacitor/push-notifications'; import { Capacitor } from '@capacitor/core'; +/** AndroidManifest default_notification_channel_id 와 동일 */ +export const FCM_CHANNEL_ID = 'goldenchart_trade_signals'; + export interface FcmPayload { type?: string; market?: string; @@ -19,42 +22,77 @@ type FcmHandlers = { }; let initialized = false; +let listenersAttached = false; -export async function initFcmPush(handlers: FcmHandlers = {}): Promise { - if (!Capacitor.isNativePlatform()) { - console.info('[FCM] Web dev — native push skipped'); - return false; +async function ensureAndroidNotificationChannel(): Promise { + if (Capacitor.getPlatform() !== 'android') return; + try { + await PushNotifications.createChannel({ + id: FCM_CHANNEL_ID, + name: '매매 시그널 알림', + description: '전략 조건 일치 시 매수·매도 푸시', + importance: 5, + visibility: 1, + vibration: true, + }); + } catch (e) { + console.warn('[FCM] createChannel failed', e); } - if (initialized) return true; +} - const perm = await PushNotifications.requestPermissions(); - if (perm.receive !== 'granted') return false; +function attachListeners(handlers: FcmHandlers): void { + if (listenersAttached) return; + listenersAttached = true; - await PushNotifications.addListener('registration', async token => { + void PushNotifications.addListener('registration', async token => { try { await registerFcmToken(token.value); - console.info('[FCM] token registered'); + console.info('[FCM] token registered with server'); } catch (e) { console.warn('[FCM] token register failed', e); } }); - await PushNotifications.addListener('registrationError', err => { + void PushNotifications.addListener('registrationError', err => { console.warn('[FCM] registration error', err); }); - await PushNotifications.addListener('pushNotificationReceived', notification => { + void PushNotifications.addListener('pushNotificationReceived', notification => { const title = notification.title ?? 'GoldenChart'; const body = notification.body ?? ''; handlers.onForeground?.(title, body, notification.data as FcmPayload); }); - await PushNotifications.addListener('pushNotificationActionPerformed', action => { + void PushNotifications.addListener('pushNotificationActionPerformed', action => { handlers.onTap?.(action.notification.data as FcmPayload); }); +} - await PushNotifications.register(); - initialized = true; +/** + * FCM 토큰 등록 (로그인 후 호출 — 서버 fcm_push_enabled 와 별개로 토큰만 등록) + */ +export async function initFcmPush(handlers: FcmHandlers = {}): Promise { + if (!Capacitor.isNativePlatform()) { + console.info('[FCM] Web dev — native push skipped'); + return false; + } + + await ensureAndroidNotificationChannel(); + attachListeners(handlers); + + const perm = await PushNotifications.checkPermissions(); + if (perm.receive !== 'granted') { + const req = await PushNotifications.requestPermissions(); + if (req.receive !== 'granted') { + console.warn('[FCM] notification permission denied'); + return false; + } + } + + if (!initialized) { + await PushNotifications.register(); + initialized = true; + } return true; } diff --git a/app_golden/build.gradle b/app_golden/build.gradle new file mode 100644 index 0000000..309dd32 --- /dev/null +++ b/app_golden/build.gradle @@ -0,0 +1,112 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' +} + +android { + namespace 'com.analysis.goldenanalysis' + compileSdk 34 + + defaultConfig { + applicationId "com.analysis.goldenanalysis" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + // 빌드 시간을 버전 이름에 포함하여 빌드 확인 가능 + buildConfigField "long", "BUILD_TIMESTAMP", System.currentTimeMillis() + "L" + } + + // signingConfigs는 buildTypes보다 먼저 정의되어야 함 + signingConfigs { + release { + def keystoreFile = file("${rootProject.projectDir}/golden-analysis.keystore") + if (keystoreFile.exists()) { + storeFile keystoreFile + storePassword System.getenv("KEYSTORE_PASSWORD") ?: "goldenanalysis" + keyAlias System.getenv("KEY_ALIAS") ?: "golden-analysis" + keyPassword System.getenv("KEY_PASSWORD") ?: "goldenanalysis" + v1SigningEnabled true + v2SigningEnabled true + } + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + // 서명 설정 적용 + def keystoreFile = file("${rootProject.projectDir}/golden-analysis.keystore") + if (keystoreFile.exists()) { + signingConfig signingConfigs.release + } + } + debug { + applicationIdSuffix ".debug" + versionNameSuffix "-debug" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + viewBinding true + buildConfig true // BuildConfig 생성 활성화 + } + + // 캐시 문제 방지를 위한 설정 + gradle.projectsEvaluated { + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" + } + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' + implementation 'androidx.navigation:navigation-ui-ktx:2.7.7' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + + // Room components + implementation "androidx.room:room-runtime:2.6.1" + kapt "androidx.room:room-compiler:2.6.1" + implementation "androidx.room:room-ktx:2.6.1" + + // WorkManager + implementation "androidx.work:work-runtime-ktx:2.9.0" + + // Retrofit for networking + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.squareup.okhttp3:okhttp:4.11.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0' + + // Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + + // Lifecycle components + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0" +} diff --git a/app_golden/proguard-rules.pro b/app_golden/proguard-rules.pro new file mode 100644 index 0000000..39b3f7a --- /dev/null +++ b/app_golden/proguard-rules.pro @@ -0,0 +1,18 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Keep Retrofit and Gson +-keepattributes Signature +-keepattributes *Annotation* +-keep class com.google.gson.** { *; } +-keep class com.squareup.retrofit2.** { *; } + +# Keep Room Database +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-dontwarn androidx.room.paging.** + diff --git a/app_golden/src/main/AndroidManifest.xml b/app_golden/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6a26707 --- /dev/null +++ b/app_golden/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/MainActivity.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/MainActivity.kt new file mode 100644 index 0000000..f20e78b --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/MainActivity.kt @@ -0,0 +1,74 @@ +package com.analysis.goldenanalysis.ui + +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import com.analysis.goldenanalysis.R +import com.analysis.goldenanalysis.databinding.ActivityMainBinding +import com.google.android.material.bottomnavigation.BottomNavigationView + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + try { + Log.d("MainActivity", "onCreate started") + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + Log.d("MainActivity", "Layout inflated") + + // Setup bottom navigation + val navView: BottomNavigationView = binding.navView + + Log.d("MainActivity", "Getting NavHostFragment") + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment_activity_main) as? NavHostFragment + + if (navHostFragment == null) { + Log.e("MainActivity", "NavHostFragment not found!") + Toast.makeText(this, "Navigation 초기화 실패", Toast.LENGTH_LONG).show() + return + } + + val navController = navHostFragment.navController + Log.d("MainActivity", "NavController obtained") + + // Passing each menu ID as a set of Ids because each + // menu should be considered as top level destinations. + val appBarConfiguration = AppBarConfiguration( + setOf( + R.id.navigation_dashboard, + R.id.navigation_portfolio, + R.id.navigation_chart, + R.id.navigation_alerts, + R.id.navigation_scheduler, + R.id.navigation_settings + ) + ) + + try { + setupActionBarWithNavController(navController, appBarConfiguration) + navView.setupWithNavController(navController) + Log.d("MainActivity", "Navigation setup complete") + } catch (e: Exception) { + Log.e("MainActivity", "Error setting up navigation", e) + // Continue without action bar + navView.setupWithNavController(navController) + } + + } catch (e: Exception) { + Log.e("MainActivity", "Fatal error in onCreate", e) + Toast.makeText(this, "앱 초기화 중 오류 발생: ${e.message}", Toast.LENGTH_LONG).show() + } + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/SplashActivity.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/SplashActivity.kt new file mode 100644 index 0000000..c88d5e5 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/SplashActivity.kt @@ -0,0 +1,46 @@ +package com.analysis.goldenanalysis.ui + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.analysis.goldenanalysis.R + +class SplashActivity : AppCompatActivity() { + + private val splashTimeOut: Long = 2000 // 2 seconds + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + try { + Log.d("SplashActivity", "onCreate started") + setContentView(R.layout.activity_splash) + Log.d("SplashActivity", "Layout set") + + // Navigate to MainActivity after splash timeout + Handler(Looper.getMainLooper()).postDelayed({ + try { + Log.d("SplashActivity", "Starting MainActivity") + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() + } catch (e: Exception) { + Log.e("SplashActivity", "Error starting MainActivity", e) + } + }, splashTimeOut) + } catch (e: Exception) { + Log.e("SplashActivity", "Error in onCreate", e) + // Try to start MainActivity directly + try { + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() + } catch (e2: Exception) { + Log.e("SplashActivity", "Failed to recover", e2) + } + } + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/AlertsFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/AlertsFragment.kt new file mode 100644 index 0000000..836b9fd --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/AlertsFragment.kt @@ -0,0 +1,209 @@ +package com.analysis.goldenanalysis.ui.alert + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.analysis.goldenanalysis.R +import com.analysis.goldenanalysis.data.api.ApiClientFactory +import com.analysis.goldenanalysis.data.model.AlertNotificationDto +import com.analysis.goldenanalysis.databinding.FragmentAlertsBinding +import com.analysis.goldenanalysis.databinding.ItemAlertPopupCardBinding +import com.analysis.goldenanalysis.databinding.ItemAlertStockRowBinding +import com.analysis.goldenanalysis.util.AlertMessageParser +import com.analysis.goldenanalysis.util.AlertSignalInference +import com.analysis.goldenanalysis.util.UpbitWebUrls +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * 미읽음 알림 — 프론트 AlertSnackbar와 유사한 카드 스택 UI + */ +class AlertsFragment : Fragment() { + + private var _binding: FragmentAlertsBinding? = null + private val binding get() = _binding!! + + private var cryptoMap: Map = emptyMap() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentAlertsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar) + binding.toolbar.inflateMenu(R.menu.menu_alerts) + binding.toolbar.setOnMenuItemClickListener(::onMenuItem) + + binding.swipeRefresh.setOnRefreshListener { loadAll() } + loadAll() + } + + private fun onMenuItem(item: MenuItem): Boolean { + if (item.itemId == R.id.action_all_notifications) { + findNavController().navigate(R.id.action_alerts_to_notification_list) + return true + } + return false + } + + private fun loadAll() { + viewLifecycleOwner.lifecycleScope.launch { + binding.progress.visibility = View.VISIBLE + binding.tvEmpty.visibility = View.GONE + try { + val api = ApiClientFactory.alertApi(requireContext()) + val cryptos = withContext(Dispatchers.IO) { + runCatching { api.getCryptoList() }.getOrElse { emptyList() } + } + cryptoMap = cryptos.associate { it.koreanName to it.symbol } + val unread = withContext(Dispatchers.IO) { + runCatching { api.getUnreadNotifications(null) }.getOrElse { emptyList() } + } + renderCards(unread) + } catch (e: Exception) { + Toast.makeText(requireContext(), "알림 로드 실패: ${e.message}", Toast.LENGTH_LONG).show() + binding.tvEmpty.visibility = View.VISIBLE + } finally { + binding.progress.visibility = View.GONE + binding.swipeRefresh.isRefreshing = false + } + } + } + + private fun renderCards(notifications: List) { + binding.cardsContainer.removeAllViews() + if (notifications.isEmpty()) { + binding.tvEmpty.visibility = View.VISIBLE + return + } + binding.tvEmpty.visibility = View.GONE + val inflater = layoutInflater + notifications.forEach { n -> + val card = ItemAlertPopupCardBinding.inflate(inflater, binding.cardsContainer, false) + val (title, pairs) = AlertMessageParser.titleAndPairs(n) + card.tvCardTitle.text = title + + card.btnClose.setOnClickListener { + n.id?.let { id -> dismissNotification(id) } + } + card.btnInfo.setOnClickListener { + n.id?.let { id -> + SignalDetailBottomSheet.newInstance( + notificationId = id, + rowSymbol = n.symbol, + signal = AlertSignalInference.inferFromNotification(n, null) ?: "BUY", + ).show(parentFragmentManager, "signal_detail") + } ?: Toast.makeText(requireContext(), "알림 ID 없음", Toast.LENGTH_SHORT).show() + } + + if (pairs.isNotEmpty()) { + card.scrollRows.visibility = View.VISIBLE + card.tvSingleLineMessage.visibility = View.GONE + card.containerRows.removeAllViews() + pairs.forEach { pair -> + val row = ItemAlertStockRowBinding.inflate(inflater, card.containerRows, false) + val resolved = AlertMessageParser.resolveStockStrategyPairRow(pair, cryptoMap) + row.tvKoreanName.text = resolved.koreanName + row.tvSymbol.text = resolved.displaySymbol + + val marketSym = resolved.navSymbol + val upbitUrl = UpbitWebUrls.getUpbitWebExchangeUrl(marketSym, n.timeInterval) + + row.btnUpbit.setOnClickListener { + if (upbitUrl != null) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(upbitUrl))) + } else { + Toast.makeText(requireContext(), "업비트 URL을 만들 수 없습니다.", Toast.LENGTH_SHORT).show() + } + } + + row.btnAlertList.setOnClickListener { + openAlertListForSymbol(marketSym, resolved.koreanName, n) + } + row.root.setOnClickListener { + openAlertListForSymbol(marketSym, resolved.koreanName, n) + } + + row.btnSignalDetail.setOnClickListener { + val nid = n.id ?: return@setOnClickListener + val sig = AlertSignalInference.inferFromNotification(n, resolved.koreanName) ?: "BUY" + SignalDetailBottomSheet.newInstance(nid, marketSym, sig) + .show(parentFragmentManager, "signal_detail") + } + + row.root.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + card.containerRows.addView(row.root) + } + } else { + card.scrollRows.visibility = View.GONE + card.tvSingleLineMessage.visibility = View.VISIBLE + card.tvSingleLineMessage.text = + n.message?.split("\n")?.firstOrNull()?.take(200).orEmpty() + } + + binding.cardsContainer.addView(card.root) + } + } + + private fun openAlertListForSymbol( + marketSym: String?, + koreanName: String, + notification: AlertNotificationDto, + ) { + val prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + marketSym?.let { + prefs.edit() + .putString("pending_chart_symbol", it) + .putString("pending_chart_korean", koreanName) + .apply() + } + findNavController().navigate( + R.id.action_alerts_to_notification_list, + bundleOf( + "filterSymbol" to (marketSym ?: ""), + "filterKorean" to koreanName, + "highlightId" to (notification.id ?: 0L), + ), + ) + } + + private fun dismissNotification(id: Long) { + viewLifecycleOwner.lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + ApiClientFactory.alertApi(requireContext()).markAsRead(id) + } + loadAll() + } catch (e: Exception) { + Toast.makeText(requireContext(), "닫기 실패: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/NotificationHistoryAdapter.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/NotificationHistoryAdapter.kt new file mode 100644 index 0000000..c6186ca --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/NotificationHistoryAdapter.kt @@ -0,0 +1,101 @@ +package com.analysis.goldenanalysis.ui.alert + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import com.analysis.goldenanalysis.data.model.AlertNotificationDto +import com.analysis.goldenanalysis.databinding.ItemAlertStockRowBinding +import com.analysis.goldenanalysis.databinding.ItemNotificationHistoryBinding +import com.analysis.goldenanalysis.util.AlertMessageParser +import com.analysis.goldenanalysis.util.AlertSignalInference + +class NotificationHistoryAdapter( + private val onUpbit: (symbol: String?, timeInterval: String?) -> Unit, + private val onAlertListRow: (notification: AlertNotificationDto, symbol: String?, korean: String) -> Unit, + private val onSignalDetail: (notificationId: Long, rowSymbol: String?, signal: String) -> Unit, + private val onDelete: (Long) -> Unit, +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + private var filterSymbol: String = "" + private var filterKorean: String = "" + private var cryptoMap: Map = emptyMap() + + fun setFilter(symbol: String, korean: String) { + filterSymbol = symbol.trim() + filterKorean = korean.trim() + } + + fun submit(list: List, map: Map) { + cryptoMap = map + items.clear() + val filtered = list.filter { n -> + if (filterSymbol.isEmpty() && filterKorean.isEmpty()) return@filter true + val msg = n.message.orEmpty() + if (filterSymbol.isNotEmpty() && msg.contains(filterSymbol, ignoreCase = true)) return@filter true + if (filterKorean.isNotEmpty() && msg.contains(filterKorean)) return@filter true + if (filterSymbol.isNotEmpty() && n.symbol.equals(filterSymbol, ignoreCase = true)) return@filter true + false + } + items.addAll(filtered) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val binding = ItemNotificationHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return VH(binding) + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: VH, position: Int) { + holder.bind(items[position]) + } + + inner class VH(private val binding: ItemNotificationHistoryBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(n: AlertNotificationDto) { + val (title, pairs) = AlertMessageParser.titleAndPairs(n) + binding.tvTitle.text = title + binding.tvTime.text = n.triggeredAt ?: n.notifiedAt ?: "" + + binding.btnDelete.setOnClickListener { + n.id?.let { onDelete(it) } + } + + binding.containerRows.removeAllViews() + if (pairs.isEmpty()) { + binding.containerRows.visibility = View.GONE + } else { + binding.containerRows.visibility = View.VISIBLE + val inflater = LayoutInflater.from(binding.root.context) + pairs.forEach { pair -> + val resolved = AlertMessageParser.resolveStockStrategyPairRow(pair, cryptoMap) + val row = ItemAlertStockRowBinding.inflate(inflater, binding.containerRows, false) + row.tvKoreanName.text = resolved.koreanName + row.tvSymbol.text = resolved.displaySymbol + val sym = resolved.navSymbol + row.btnUpbit.setOnClickListener { onUpbit(sym, n.timeInterval) } + row.btnAlertList.setOnClickListener { + onAlertListRow(n, sym, resolved.koreanName) + } + row.root.setOnClickListener { + onAlertListRow(n, sym, resolved.koreanName) + } + row.btnSignalDetail.setOnClickListener { + val id = n.id ?: return@setOnClickListener + val sig = AlertSignalInference.inferFromNotification(n, resolved.koreanName) ?: "BUY" + onSignalDetail(id, sym, sig) + } + row.root.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + binding.containerRows.addView(row.root) + } + } + } + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/NotificationListFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/NotificationListFragment.kt new file mode 100644 index 0000000..750d5a3 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/NotificationListFragment.kt @@ -0,0 +1,114 @@ +package com.analysis.goldenanalysis.ui.alert + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.analysis.goldenanalysis.R +import com.analysis.goldenanalysis.data.api.ApiClientFactory +import com.analysis.goldenanalysis.data.model.AlertNotificationDto +import com.analysis.goldenanalysis.databinding.FragmentNotificationListBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * 전체 알림 목록 (프론트 NotificationListPage 유사) + */ +class NotificationListFragment : Fragment() { + + private var _binding: FragmentNotificationListBinding? = null + private val binding get() = _binding!! + + private lateinit var adapter: NotificationHistoryAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentNotificationListBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationIcon(com.analysis.goldenanalysis.R.drawable.ic_arrow_back) + binding.toolbar.setNavigationOnClickListener { findNavController().navigateUp() } + binding.toolbar.title = getString(R.string.alerts_menu_all) + + adapter = NotificationHistoryAdapter( + onUpbit = { sym, interval -> + val url = com.analysis.goldenanalysis.util.UpbitWebUrls.getUpbitWebExchangeUrl(sym, interval) + if (url != null) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + else Toast.makeText(requireContext(), "업비트 URL 오류", Toast.LENGTH_SHORT).show() + }, + onAlertListRow = { _, _, _ -> /* 목록 화면 내 동작 */ }, + onSignalDetail = { id, sym, signal -> + SignalDetailBottomSheet.newInstance(id, sym, signal) + .show(parentFragmentManager, "signal_detail") + }, + onDelete = { id -> deleteNotification(id) }, + ) + binding.recycler.layoutManager = LinearLayoutManager(requireContext()) + binding.recycler.adapter = adapter + + val filterSym = arguments?.getString("filterSymbol").orEmpty() + val filterKr = arguments?.getString("filterKorean").orEmpty() + adapter.setFilter(filterSym, filterKr) + + binding.swipeRefresh.setOnRefreshListener { loadPage() } + loadPage() + } + + private fun loadPage() { + viewLifecycleOwner.lifecycleScope.launch { + binding.progress.visibility = View.VISIBLE + try { + val api = ApiClientFactory.alertApi(requireContext()) + val (page, map) = withContext(Dispatchers.IO) { + val p = runCatching { api.getNotifications(null, 0, 50) }.getOrNull() + val m = runCatching { api.getCryptoList().associate { it.koreanName to it.symbol } } + .getOrElse { emptyMap() } + p to m + } + val list = page?.content.orEmpty() + adapter.submit(list, map) + binding.tvEmpty.visibility = if (adapter.itemCount == 0) View.VISIBLE else View.GONE + } catch (e: Exception) { + Toast.makeText(requireContext(), "목록 로드 실패: ${e.message}", Toast.LENGTH_LONG).show() + } finally { + binding.progress.visibility = View.GONE + binding.swipeRefresh.isRefreshing = false + } + } + } + + private fun deleteNotification(id: Long) { + viewLifecycleOwner.lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + ApiClientFactory.alertApi(requireContext()).deleteNotification(id) + } + loadPage() + } catch (e: Exception) { + Toast.makeText(requireContext(), "삭제 실패: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/SignalDetailBottomSheet.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/SignalDetailBottomSheet.kt new file mode 100644 index 0000000..63f3eb6 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/alert/SignalDetailBottomSheet.kt @@ -0,0 +1,105 @@ +package com.analysis.goldenanalysis.ui.alert + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope +import com.analysis.goldenanalysis.data.api.ApiClientFactory +import com.analysis.goldenanalysis.data.model.AlertNotificationDetailDto +import com.analysis.goldenanalysis.databinding.BottomSheetSignalDetailBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SignalDetailBottomSheet : BottomSheetDialogFragment() { + + private var _binding: BottomSheetSignalDetailBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = BottomSheetSignalDetailBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val id = arguments?.getLong(ARG_ID) ?: return dismiss() + val symbol = arguments?.getString(ARG_SYMBOL) + val signal = arguments?.getString(ARG_SIGNAL) ?: "BUY" + binding.tvTitle.text = if (signal == "SELL") "📉 매도 신호 상세" else "📈 매수 신호 상세" + binding.tvBody.text = getString(com.analysis.goldenanalysis.R.string.loading) + binding.btnClose.setOnClickListener { dismiss() } + + viewLifecycleOwner.lifecycleScope.launch { + try { + val api = ApiClientFactory.alertApi(requireContext()) + val detail = withContext(Dispatchers.IO) { + api.getNotificationDetail(id, symbol?.trim()?.takeIf { it.isNotEmpty() }) + } + binding.tvBody.text = buildDetailText(detail) + } catch (e: Exception) { + binding.tvBody.text = e.message ?: "오류" + Toast.makeText(requireContext(), "상세를 불러오지 못했습니다.", Toast.LENGTH_SHORT).show() + } + } + } + + private fun buildDetailText(d: AlertNotificationDetailDto): String { + val sb = StringBuilder() + d.koreanName?.let { sb.appendLine("종목: $it (${d.symbol ?: ""})") } + d.message?.let { sb.appendLine().appendLine(it) } + d.conditionDescription?.let { sb.appendLine().appendLine("조건: $it") } + d.timeInterval?.let { sb.appendLine("시간봉: $it") } + val ind = d.indicatorData + if (ind != null) { + sb.appendLine().appendLine("— 지표 스냅샷 —") + ind.time?.let { sb.appendLine("시간: $it") } + ind.price?.let { sb.appendLine("가격: $it") } + ind.rsi?.let { sb.appendLine("RSI: $it") } + ind.macdLine?.let { sb.appendLine("MACD: $it") } + ind.signalLine?.let { sb.appendLine("시그널: $it") } + ind.cci?.let { sb.appendLine("CCI: $it") } + } + d.snapshotClose?.let { sb.appendLine().appendLine("봉 종가(스냅): $it") } + val conds = d.satisfiedConditions.orEmpty() + if (conds.isNotEmpty()) { + sb.appendLine().appendLine("— 충족 조건 —") + conds.forEach { c -> + sb.appendLine( + listOfNotNull(c.indicatorType, c.conditionType, c.description) + .joinToString(" ") + ) + } + } + return sb.toString().trim() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val ARG_ID = "id" + private const val ARG_SYMBOL = "symbol" + private const val ARG_SIGNAL = "signal" + + fun newInstance(notificationId: Long, rowSymbol: String?, signal: String): SignalDetailBottomSheet { + val f = SignalDetailBottomSheet() + f.arguments = bundleOf( + ARG_ID to notificationId, + ARG_SYMBOL to rowSymbol, + ARG_SIGNAL to signal, + ) + return f + } + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/chart/ChartFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/chart/ChartFragment.kt new file mode 100644 index 0000000..341051d --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/chart/ChartFragment.kt @@ -0,0 +1,247 @@ +package com.analysis.goldenanalysis.ui.chart + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.net.http.SslError +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.JavascriptInterface +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.analysis.goldenanalysis.BuildConfig +import com.analysis.goldenanalysis.databinding.FragmentChartBinding + +/** + * 차트 Fragment + * - Frontend의 모바일 차트 페이지를 WebView로 표시 + */ +class ChartFragment : Fragment() { + + private var _binding: FragmentChartBinding? = null + private val binding get() = _binding!! + + private val TAG = "ChartFragment" + private val JS_TAG = "ChartFragment-JS" + + private fun getFrontendBaseUrl(): String { + val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + return prefs.getString("frontend_url", "http://exdev.co.kr/mobile/chart") + ?: "http://exdev.co.kr/mobile/chart" + } + + /** 알림에서 저장한 심볼이 있으면 쿼리 gaSymbol 붙이고 prefs 에서 제거 (프론트 연동 시 사용). */ + private fun buildUrlConsumingPendingChartSymbol(): String { + val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + val base = getFrontendBaseUrl() + val sym = prefs.getString("pending_chart_symbol", null)?.trim().orEmpty() + if (sym.isNotEmpty()) { + prefs.edit() + .remove("pending_chart_symbol") + .remove("pending_chart_korean") + .apply() + val sep = if (base.contains("?")) "&" else "?" + return "$base${sep}gaSymbol=${Uri.encode(sym)}" + } + return base + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d(TAG, "onCreateView") + _binding = FragmentChartBinding.inflate(inflater, container, false) + return binding.root + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Log.d(TAG, "onViewCreated") + + try { + // Frontend 모바일 페이지를 WebView로 로드 + setupWebView() + } catch (e: Exception) { + Log.e(TAG, "Error in onViewCreated", e) + Toast.makeText(context, "초기화 오류: ${e.message}", Toast.LENGTH_LONG).show() + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + binding.webView.apply { + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + super.onPageStarted(view, url, favicon) + Log.d(TAG, "Page started loading: $url") + binding.progressBar.visibility = View.VISIBLE + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + Log.d(TAG, "Page finished loading: $url") + + // JavaScript로 페이지 내용 확인 + view?.evaluateJavascript("document.body.innerHTML.length") { result -> + Log.d(TAG, "Page content length: $result") + if (result == "0" || result == "null") { + Log.e(TAG, "Page loaded but content is empty!") + Toast.makeText(context, "페이지가 비어있습니다", Toast.LENGTH_LONG).show() + } + } + + binding.progressBar.visibility = View.GONE + } + + override fun onReceivedError( + view: WebView?, + errorCode: Int, + description: String?, + failingUrl: String? + ) { + super.onReceivedError(view, errorCode, description, failingUrl) + Log.e(TAG, "WebView Error: code=$errorCode, desc=$description, url=$failingUrl") + Toast.makeText(context, "페이지 로딩 오류 ($errorCode): $description", Toast.LENGTH_LONG).show() + binding.progressBar.visibility = View.GONE + } + + @Deprecated("Deprecated in API level 23") + override fun onReceivedError( + view: WebView?, + request: android.webkit.WebResourceRequest?, + error: android.webkit.WebResourceError? + ) { + super.onReceivedError(view, request, error) + if (request?.isForMainFrame == true) { + Log.e(TAG, "WebView Resource Error: ${error?.description}") + } + } + + // HTTPS 오류 무시 (개발 환경에서만 사용 권장) + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + Log.w(TAG, "SSL Error: ${error?.primaryError} on ${error?.url}. Proceeding anyway.") + handler?.proceed() // 인증서 무시 + } + } + + webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { + Log.d(JS_TAG, "${consoleMessage?.message()} -- From ${consoleMessage?.sourceId()} : ${consoleMessage?.lineNumber()}") + return true + } + + override fun onProgressChanged(view: WebView?, newProgress: Int) { + super.onProgressChanged(view, newProgress) + if (newProgress < 100) { + binding.progressBar.visibility = View.VISIBLE + } else { + binding.progressBar.visibility = View.GONE + } + } + } + + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + cacheMode = WebSettings.LOAD_NO_CACHE // 캐시 비활성화 (디버깅용) + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = false // 모바일에서는 줌 컨트롤 비활성화 + displayZoomControls = false + setSupportZoom(true) + allowFileAccess = true + allowContentAccess = true + databaseEnabled = true + + // 네트워크 설정 + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + + // User Agent 설정 (모바일 명시) + userAgentString = userAgentString + " GoldenAnalysisMobile/1.0" + + // WebView 디버깅 활성화 (크롬 개발자 도구에서 확인 가능) + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true) + } + } + + // JavaScript 인터페이스 추가 + addJavascriptInterface(WebAppInterface(requireContext()), "Android") + + // Frontend 모바일 페이지 로드 + val frontendUrl = buildUrlConsumingPendingChartSymbol() + Log.d(TAG, "Loading Frontend URL: $frontendUrl") + binding.progressBar.visibility = View.VISIBLE + + // WebView 강제 재개 + onResume() + resumeTimers() + + // 약간의 지연 후 로드 (WebView 초기화 대기) + post { + Log.d(TAG, "Starting to load URL...") + loadUrl(frontendUrl) + Log.d(TAG, "loadUrl() called") + } + } + } + + override fun onResume() { + super.onResume() + Log.d(TAG, "Fragment onResume") + try { + binding.webView.onResume() + binding.webView.resumeTimers() + // 알림 탭 등에서 pending_chart_symbol 저장 후 차트 탭으로 온 경우 + val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + if (!prefs.getString("pending_chart_symbol", null).isNullOrBlank()) { + val url = buildUrlConsumingPendingChartSymbol() + Log.d(TAG, "Reload chart URL: $url") + binding.webView.loadUrl(url) + } + } catch (e: Exception) { + Log.e(TAG, "Error in onResume", e) + } + } + + override fun onPause() { + super.onPause() + Log.d(TAG, "Fragment onPause") + try { + binding.webView.onPause() + binding.webView.pauseTimers() + } catch (e: Exception) { + Log.e(TAG, "Error in onPause", e) + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding.webView.destroy() + _binding = null + } + + // JavaScript에서 호출할 인터페이스 + class WebAppInterface(private val mContext: Context) { + @JavascriptInterface + fun showToast(toast: String) { + Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show() + } + + @JavascriptInterface + fun log(message: String) { + Log.d("ChartFragment-JS", message) + } + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/dashboard/DashboardFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/dashboard/DashboardFragment.kt new file mode 100644 index 0000000..4433495 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/dashboard/DashboardFragment.kt @@ -0,0 +1,53 @@ +package com.analysis.goldenanalysis.ui.dashboard + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.analysis.goldenanalysis.R +import com.analysis.goldenanalysis.databinding.FragmentDashboardBinding + +class DashboardFragment : Fragment() { + + private var _binding: FragmentDashboardBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d("DashboardFragment", "onCreateView") + _binding = FragmentDashboardBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Log.d("DashboardFragment", "onViewCreated") + try { + setupViews() + } catch (e: Exception) { + Log.e("DashboardFragment", "Error in setupViews", e) + } + } + + private fun setupViews() { + // TODO: Implement dashboard data loading from API + try { + binding.tvTotalAssets.text = "0원" + binding.tvProfitLoss.text = "0.00%" + binding.tvTodayChange.text = "0원" + } catch (e: Exception) { + Log.e("DashboardFragment", "Error setting text", e) + // Views might not exist in layout + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/portfolio/PortfolioFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/portfolio/PortfolioFragment.kt new file mode 100644 index 0000000..ffb406d --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/portfolio/PortfolioFragment.kt @@ -0,0 +1,42 @@ +package com.analysis.goldenanalysis.ui.portfolio + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.analysis.goldenanalysis.databinding.FragmentPortfolioBinding + +class PortfolioFragment : Fragment() { + + private var _binding: FragmentPortfolioBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentPortfolioBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupViews() + } + + private fun setupViews() { + binding.fabAdd.setOnClickListener { + // TODO: Open add portfolio item dialog + } + + // TODO: Load portfolio items from API + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/ScheduleDialogFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/ScheduleDialogFragment.kt new file mode 100644 index 0000000..cf90e87 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/ScheduleDialogFragment.kt @@ -0,0 +1,135 @@ +package com.analysis.goldenanalysis.ui.scheduler + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.fragment.app.DialogFragment +import com.analysis.goldenanalysis.R +import com.analysis.goldenanalysis.data.model.Schedule +import com.analysis.goldenanalysis.databinding.DialogScheduleBinding + +class ScheduleDialogFragment : DialogFragment() { + + private var _binding: DialogScheduleBinding? = null + private val binding get() = _binding!! + + private var schedule: Schedule? = null + private var onSaveListener: ((Schedule) -> Unit)? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.Theme_GoldenAnalysis) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogScheduleBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupSpinners() + setupListeners() + + // If editing existing schedule + schedule?.let { populateFields(it) } + } + + private fun setupSpinners() { + // Interval spinner + val intervals = arrayOf("1분", "5분", "15분", "30분", "1시간", "4시간", "1일") + val intervalAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, intervals) + intervalAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.spinnerInterval.adapter = intervalAdapter + + // Notification method spinner + val methods = arrayOf("앱 알림", "이메일", "SMS") + val methodAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, methods) + methodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.spinnerNotification.adapter = methodAdapter + } + + private fun setupListeners() { + binding.btnSave.setOnClickListener { + saveSchedule() + } + + binding.btnCancel.setOnClickListener { + dismiss() + } + } + + private fun populateFields(schedule: Schedule) { + binding.apply { + etScheduleName.setText(schedule.name) + + // Set spinner selections + val intervalPos = (spinnerInterval.adapter as ArrayAdapter) + .getPosition(schedule.interval) + if (intervalPos >= 0) spinnerInterval.setSelection(intervalPos) + + val methodPos = (spinnerNotification.adapter as ArrayAdapter) + .getPosition(schedule.notificationMethod) + if (methodPos >= 0) spinnerNotification.setSelection(methodPos) + + etIndicators.setText(schedule.indicators) + etConditions.setText(schedule.conditions) + } + } + + private fun saveSchedule() { + val name = binding.etScheduleName.text.toString() + if (name.isBlank()) { + binding.etScheduleName.error = "스케쥴 이름을 입력하세요" + return + } + + val newSchedule = Schedule( + id = schedule?.id ?: 0, + name = name, + interval = binding.spinnerInterval.selectedItem.toString(), + indicators = binding.etIndicators.text.toString(), + notificationMethod = binding.spinnerNotification.selectedItem.toString(), + conditions = binding.etConditions.text.toString(), + isEnabled = schedule?.isEnabled ?: true, + createdAt = schedule?.createdAt ?: System.currentTimeMillis(), + lastExecutedAt = schedule?.lastExecutedAt ?: 0 + ) + + onSaveListener?.invoke(newSchedule) + dismiss() + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + fun setOnSaveListener(listener: (Schedule) -> Unit) { + onSaveListener = listener + } + + companion object { + fun newInstance(schedule: Schedule? = null): ScheduleDialogFragment { + val fragment = ScheduleDialogFragment() + fragment.schedule = schedule + return fragment + } + } +} + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerAdapter.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerAdapter.kt new file mode 100644 index 0000000..eea9df0 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerAdapter.kt @@ -0,0 +1,72 @@ +package com.analysis.goldenanalysis.ui.scheduler + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.analysis.goldenanalysis.data.model.Schedule +import com.analysis.goldenanalysis.databinding.ItemScheduleBinding +import java.text.SimpleDateFormat +import java.util.* + +class SchedulerAdapter( + private val onEdit: (Schedule) -> Unit, + private val onDelete: (Schedule) -> Unit, + private val onToggle: (Schedule, Boolean) -> Unit +) : ListAdapter(ScheduleDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScheduleViewHolder { + val binding = ItemScheduleBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ScheduleViewHolder(binding) + } + + override fun onBindViewHolder(holder: ScheduleViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class ScheduleViewHolder( + private val binding: ItemScheduleBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(schedule: Schedule) { + binding.apply { + tvScheduleName.text = schedule.name + tvInterval.text = schedule.interval + tvIndicators.text = schedule.indicators + switchEnabled.isChecked = schedule.isEnabled + + // Format last executed time + if (schedule.lastExecutedAt > 0) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + tvLastExecuted.text = "마지막 실행: ${sdf.format(Date(schedule.lastExecutedAt))}" + } else { + tvLastExecuted.text = "실행 기록 없음" + } + + // Click listeners + root.setOnClickListener { onEdit(schedule) } + btnEdit.setOnClickListener { onEdit(schedule) } + btnDelete.setOnClickListener { onDelete(schedule) } + switchEnabled.setOnCheckedChangeListener { _, isChecked -> + onToggle(schedule, isChecked) + } + } + } + } + + private class ScheduleDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean { + return oldItem == newItem + } + } +} + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerFragment.kt new file mode 100644 index 0000000..3581bf4 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerFragment.kt @@ -0,0 +1,87 @@ +package com.analysis.goldenanalysis.ui.scheduler + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.analysis.goldenanalysis.data.model.Schedule +import com.analysis.goldenanalysis.databinding.FragmentSchedulerBinding + +class SchedulerFragment : Fragment() { + + private var _binding: FragmentSchedulerBinding? = null + private val binding get() = _binding!! + + private val viewModel: SchedulerViewModel by viewModels() + private lateinit var adapter: SchedulerAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSchedulerBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupRecyclerView() + setupObservers() + setupListeners() + } + + private fun setupRecyclerView() { + adapter = SchedulerAdapter( + onEdit = { schedule -> showEditDialog(schedule) }, + onDelete = { schedule -> viewModel.delete(schedule) }, + onToggle = { schedule, enabled -> + viewModel.update(schedule.copy(isEnabled = enabled)) + } + ) + + binding.rvScheduler.apply { + layoutManager = LinearLayoutManager(context) + adapter = this@SchedulerFragment.adapter + } + } + + private fun setupObservers() { + viewModel.allSchedules.observe(viewLifecycleOwner) { schedules -> + adapter.submitList(schedules) + binding.tvEmptyState.visibility = + if (schedules.isEmpty()) View.VISIBLE else View.GONE + } + } + + private fun setupListeners() { + binding.fabAdd.setOnClickListener { + showAddDialog() + } + } + + private fun showAddDialog() { + val dialog = ScheduleDialogFragment.newInstance() + dialog.setOnSaveListener { schedule -> + viewModel.insert(schedule) + } + dialog.show(childFragmentManager, "AddScheduleDialog") + } + + private fun showEditDialog(schedule: Schedule) { + val dialog = ScheduleDialogFragment.newInstance(schedule) + dialog.setOnSaveListener { updatedSchedule -> + viewModel.update(updatedSchedule) + } + dialog.show(childFragmentManager, "EditScheduleDialog") + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerViewModel.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerViewModel.kt new file mode 100644 index 0000000..2f90ed0 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/scheduler/SchedulerViewModel.kt @@ -0,0 +1,35 @@ +package com.analysis.goldenanalysis.ui.scheduler + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.viewModelScope +import com.analysis.goldenanalysis.data.database.AppDatabase +import com.analysis.goldenanalysis.data.model.Schedule +import com.analysis.goldenanalysis.data.repository.ScheduleRepository +import kotlinx.coroutines.launch + +class SchedulerViewModel(application: Application) : AndroidViewModel(application) { + + private val repository: ScheduleRepository + val allSchedules: LiveData> + + init { + val scheduleDao = AppDatabase.getDatabase(application).scheduleDao() + repository = ScheduleRepository(scheduleDao) + allSchedules = repository.allSchedules + } + + fun insert(schedule: Schedule) = viewModelScope.launch { + repository.insert(schedule) + } + + fun update(schedule: Schedule) = viewModelScope.launch { + repository.update(schedule) + } + + fun delete(schedule: Schedule) = viewModelScope.launch { + repository.delete(schedule) + } +} + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/ui/settings/SettingsFragment.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..2d96493 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/ui/settings/SettingsFragment.kt @@ -0,0 +1,83 @@ +package com.analysis.goldenanalysis.ui.settings + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatDelegate +import androidx.fragment.app.Fragment +import com.analysis.goldenanalysis.data.api.ApiClientFactory +import com.analysis.goldenanalysis.databinding.FragmentSettingsBinding + +class SettingsFragment : Fragment() { + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + + private lateinit var prefs: SharedPreferences + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + setupViews() + } + + private fun setupViews() { + // Load saved settings + binding.switchNotification.isChecked = prefs.getBoolean("notifications_enabled", true) + binding.switchDarkMode.isChecked = prefs.getBoolean("dark_mode", false) + // Frontend URL 설정 (차트 페이지) + // 로컬 Docker: http://192.168.219.106/mobile/chart + // 운영: https://exdev.co.kr/mobile/chart + val defaultFrontendUrl = "https://exdev.co.kr/mobile/chart" + binding.etApiServer.setText(prefs.getString("frontend_url", defaultFrontendUrl)) + binding.etApiBackend.setText(prefs.getString("api_base_url", "")) + + // Version info + binding.tvVersionValue.text = "1.0.0" + + // Setup listeners + binding.switchNotification.setOnCheckedChangeListener { _, isChecked -> + prefs.edit().putBoolean("notifications_enabled", isChecked).apply() + } + + binding.switchDarkMode.setOnCheckedChangeListener { _, isChecked -> + prefs.edit().putBoolean("dark_mode", isChecked).apply() + applyTheme(isChecked) + } + + binding.btnSaveServer.setOnClickListener { + val frontendUrl = binding.etApiServer.text.toString() + val apiBackend = binding.etApiBackend.text.toString().trim() + val ed = prefs.edit().putString("frontend_url", frontendUrl) + if (apiBackend.isEmpty()) ed.remove("api_base_url") else ed.putString("api_base_url", apiBackend) + ed.apply() + ApiClientFactory.reset() + android.widget.Toast.makeText(context, "저장되었습니다", android.widget.Toast.LENGTH_SHORT).show() + } + } + + private fun applyTheme(isDarkMode: Boolean) { + AppCompatDelegate.setDefaultNightMode( + if (isDarkMode) AppCompatDelegate.MODE_NIGHT_YES + else AppCompatDelegate.MODE_NIGHT_NO + ) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/util/AlertMessageParser.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/util/AlertMessageParser.kt new file mode 100644 index 0000000..b15804d --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/util/AlertMessageParser.kt @@ -0,0 +1,122 @@ +package com.analysis.goldenanalysis.util + +import com.analysis.goldenanalysis.data.model.AlertNotificationDto + +data class StockStrategyPair( + val koreanName: String, + val strategyName: String, + val symbol: String? = null, +) + +data class ResolvedStockRow( + val koreanName: String, + val strategyName: String, + val displaySymbol: String, + val navSymbol: String?, +) + +object AlertMessageParser { + + private val marketCodeRe = Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE) + + fun extractStrategyNames(message: String): List? { + if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null + val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() } + val names = mutableListOf() + for (i in 1 until lines.size) { + val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue + val idx = lines[i].indexOf(sep) + if (idx >= 0) { + val name = lines[i].substring(idx + sep.length).trim() + if (name.isNotEmpty()) names.add(name) + } + } + return if (names.isEmpty()) null else names.distinct() + } + + fun extractStockStrategyPairs(message: String): List? { + if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null + val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() } + val pairs = mutableListOf() + for (i in 1 until lines.size) { + val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue + val idx = lines[i].indexOf(sep) + if (idx >= 0) { + val kn = lines[i].substring(0, idx).trim() + val sn = lines[i].substring(idx + sep.length).trim() + if (kn.isNotEmpty()) pairs.add(StockStrategyPair(kn, sn)) + } + } + return pairs.ifEmpty { null } + } + + fun resolveSymbols(koreanNames: List, cryptoMap: Map): List> { + val entries = cryptoMap.entries.toList() + fun norm(s: String) = s.replace(Regex("\\s+"), " ").trim() + val out = mutableListOf>() + for (kn in koreanNames) { + val n = norm(kn) + var matched = entries.find { norm(it.key) == n || it.key == kn } + if (matched == null) { + matched = entries.find { + norm(it.key).contains(n) || n.contains(norm(it.key)) + } + } + if (matched != null) out.add(matched.key to matched.value) + } + return out + } + + private fun koreanNameForMarketSymbol(symbol: String, cryptoMap: Map, fallback: String): String { + val s = symbol.trim().uppercase() + cryptoMap.entries.forEach { (k, v) -> + if (v.uppercase() == s) return k + } + return fallback + } + + fun resolveStockStrategyPairRow(pair: StockStrategyPair, cryptoMap: Map): ResolvedStockRow { + if (!pair.symbol.isNullOrBlank()) { + val nav = pair.symbol.trim().uppercase() + val left = pair.koreanName.trim() + val kn = if (left.isNotEmpty() && !marketCodeRe.matches(left)) pair.koreanName + else koreanNameForMarketSymbol(nav, cryptoMap, left.ifEmpty { nav }) + return ResolvedStockRow(kn, pair.strategyName, nav, nav) + } + val raw = pair.koreanName.trim() + if (marketCodeRe.matches(raw)) { + val nav = raw.uppercase() + val kn = koreanNameForMarketSymbol(nav, cryptoMap, raw) + return ResolvedStockRow(kn, pair.strategyName, nav, nav) + } + val resolved = resolveSymbols(listOf(pair.koreanName), cryptoMap).firstOrNull() + return ResolvedStockRow( + pair.koreanName, + pair.strategyName, + resolved?.second ?: pair.strategyName, + resolved?.second, + ) + } + + fun titleAndPairs(notification: AlertNotificationDto): Pair> { + val msg = notification.message.orEmpty() + if (msg.contains("알림 조건 충족") && msg.contains("개)")) { + val strategyNames = extractStrategyNames(msg).orEmpty() + val title = when { + strategyNames.isEmpty() -> "🎯 통합 알림" + strategyNames.size <= 3 -> strategyNames.joinToString(", ") + else -> "${strategyNames.take(2).joinToString(", ")} 외 ${strategyNames.size - 2}개 전략" + } + val pairs = extractStockStrategyPairs(msg).orEmpty() + return title to pairs + } + if (!notification.koreanName.isNullOrBlank()) { + val firstLine = msg.split("\n").firstOrNull()?.trim().orEmpty() + val strategyName = firstLine.replace(Regex("^[\\s🧪🎯✅]*"), "").trim().ifEmpty { "알림" } + return strategyName to listOf( + StockStrategyPair(notification.koreanName!!, strategyName, notification.symbol) + ) + } + return (notification.symbol ?: "알림") to emptyList() + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/util/AlertSignalInference.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/util/AlertSignalInference.kt new file mode 100644 index 0000000..d8a22ff --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/util/AlertSignalInference.kt @@ -0,0 +1,44 @@ +package com.analysis.goldenanalysis.util + +import com.analysis.goldenanalysis.data.model.AlertNotificationDto + +object AlertSignalInference { + + fun inferFromStrategyName(strategyName: String): String? { + val s = strategyName.trim() + if (s.isEmpty()) return null + val hint = s.replace(Regex("\\s"), "") + if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL" + if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY" + if (s.contains("데드크로스")) return "SELL" + if (s.contains("골든크로스")) return "BUY" + return null + } + + fun inferFromNotification(n: AlertNotificationDto, koreanNameForRow: String?): String? { + val row = koreanNameForRow?.trim() + if (!row.isNullOrEmpty()) { + val pairs = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty() + val pair = pairs.find { it.koreanName.trim() == row } + pair?.strategyName?.let { inferFromStrategyName(it) }?.let { return it } + } + val raw = "${n.message.orEmpty()}\n${n.conditionDescription.orEmpty()}" + val pairsAll = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty() + if (pairsAll.size == 1) { + inferFromStrategyName(pairsAll[0].strategyName)?.let { return it } + } + val hint = raw.replace(Regex("\\s"), "") + if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL" + if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY" + if (raw.contains("데드크로스")) return "SELL" + if (raw.contains("골든크로스")) return "BUY" + val masked = raw.replace("과매도", "").replace("과매수", "") + if (Regex("매도조건|매도신호").containsMatchIn(masked) && + !Regex("매수조건|매수신호").containsMatchIn(masked) + ) return "SELL" + if (Regex("매수조건|매수신호").containsMatchIn(masked) && + !Regex("매도조건|매도신호").containsMatchIn(masked) + ) return "BUY" + return null + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/util/ApiPrefs.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/util/ApiPrefs.kt new file mode 100644 index 0000000..2f5a682 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/util/ApiPrefs.kt @@ -0,0 +1,35 @@ +package com.analysis.goldenanalysis.util + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri + +object ApiPrefs { + private const val PREFS = "app_prefs" + private const val KEY_API_BASE = "api_base_url" + private const val KEY_FRONTEND = "frontend_url" + + fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** + * 백엔드 REST 베이스 (…/api/ 형태, Retrofit용). + * [api_base_url]이 있으면 사용, 없으면 [frontend_url]의 origin + /api/ + */ + fun getApiBaseUrl(context: Context): String { + val p = prefs(context) + val explicit = p.getString(KEY_API_BASE, null)?.trim().orEmpty() + if (explicit.isNotEmpty()) { + var b = explicit.trimEnd('/') + if (!b.endsWith("/api")) b = "$b/api" + return "$b/" + } + val front = p.getString(KEY_FRONTEND, "https://exdev.co.kr/mobile/chart") + ?: "https://exdev.co.kr/mobile/chart" + val uri = Uri.parse(front) + val scheme = uri.scheme ?: "https" + val host = uri.host ?: "exdev.co.kr" + val port = if (uri.port == -1 || uri.port == 80 || uri.port == 443) "" else ":${uri.port}" + return "$scheme://$host$port/api/" + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/util/ScheduleManager.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/util/ScheduleManager.kt new file mode 100644 index 0000000..1494fcb --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/util/ScheduleManager.kt @@ -0,0 +1,62 @@ +package com.analysis.goldenanalysis.util + +import android.content.Context +import androidx.work.* +import com.analysis.goldenanalysis.data.model.Schedule +import com.analysis.goldenanalysis.worker.ScheduleWorker +import java.util.concurrent.TimeUnit + +object ScheduleManager { + + fun scheduleWork(context: Context, schedule: Schedule) { + val workManager = WorkManager.getInstance(context) + + if (!schedule.isEnabled) { + cancelWork(context, schedule.id) + return + } + + val intervalMinutes = getIntervalInMinutes(schedule.interval) + val inputData = Data.Builder() + .putLong("schedule_id", schedule.id) + .build() + + val workRequest = PeriodicWorkRequestBuilder( + intervalMinutes, + TimeUnit.MINUTES + ) + .setInputData(inputData) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .addTag("schedule_${schedule.id}") + .build() + + workManager.enqueueUniquePeriodicWork( + "schedule_${schedule.id}", + ExistingPeriodicWorkPolicy.REPLACE, + workRequest + ) + } + + fun cancelWork(context: Context, scheduleId: Long) { + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork("schedule_$scheduleId") + } + + private fun getIntervalInMinutes(interval: String): Long { + return when (interval) { + "1분" -> 15 // Minimum 15 minutes for WorkManager + "5분" -> 15 + "15분" -> 15 + "30분" -> 30 + "1시간" -> 60 + "4시간" -> 240 + "1일" -> 1440 + else -> 60 + } + } +} + diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/util/UpbitWebUrls.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/util/UpbitWebUrls.kt new file mode 100644 index 0000000..1eb1a60 --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/util/UpbitWebUrls.kt @@ -0,0 +1,33 @@ +package com.analysis.goldenanalysis.util + +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object UpbitWebUrls { + + fun mapAlertTimeIntervalToResolution(timeInterval: String?): String? { + if (timeInterval.isNullOrBlank()) return null + val raw = timeInterval.trim() + if (Regex("^\\d+M$").matches(raw)) { + return mapOf("1M" to "1M", "3M" to "3M", "6M" to "6M", "12M" to "12M")[raw] + } + val key = raw.lowercase() + val map = mapOf( + "1m" to "1", "3m" to "3", "5m" to "5", "10m" to "10", + "15m" to "15", "30m" to "30", "60m" to "60", "1h" to "60", + "4h" to "240", "240m" to "240", "1d" to "1D", "1w" to "1W", + ) + return map[key] + } + + fun getUpbitWebExchangeUrl(market: String?, timeInterval: String?): String? { + if (market.isNullOrBlank()) return null + var m = market.trim().uppercase() + if (m.startsWith("CRIX.UPBIT.")) m = m.removePrefix("CRIX.UPBIT.") + if (!Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE).matches(m)) return null + val code = "CRIX.UPBIT.$m" + val base = "https://www.upbit.com/exchange?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8.name()) + val res = mapAlertTimeIntervalToResolution(timeInterval) + return if (res != null) "$base&resolution=" + URLEncoder.encode(res, StandardCharsets.UTF_8.name()) else base + } +} diff --git a/app_golden/src/main/java/com/analysis/goldenanalysis/worker/ScheduleWorker.kt b/app_golden/src/main/java/com/analysis/goldenanalysis/worker/ScheduleWorker.kt new file mode 100644 index 0000000..08e39ba --- /dev/null +++ b/app_golden/src/main/java/com/analysis/goldenanalysis/worker/ScheduleWorker.kt @@ -0,0 +1,78 @@ +package com.analysis.goldenanalysis.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.analysis.goldenanalysis.R +import com.analysis.goldenanalysis.data.database.AppDatabase +import com.analysis.goldenanalysis.data.repository.ScheduleRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ScheduleWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + private val repository: ScheduleRepository by lazy { + val dao = AppDatabase.getDatabase(context).scheduleDao() + ScheduleRepository(dao) + } + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + try { + val scheduleId = inputData.getLong("schedule_id", -1) + if (scheduleId == -1L) return@withContext Result.failure() + + val schedule = repository.getScheduleById(scheduleId) + if (schedule == null || !schedule.isEnabled) { + return@withContext Result.failure() + } + + // TODO: Call API to check indicators + // For now, just show notification + showNotification(schedule.name, "스케쥴이 실행되었습니다") + + // Update last executed timestamp + repository.updateLastExecutedAt(scheduleId, System.currentTimeMillis()) + + Result.success() + } catch (e: Exception) { + Result.failure() + } + } + } + + private fun showNotification(title: String, message: String) { + val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + + val channelId = "schedule_notifications" + + // Create notification channel for Android O and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + "스케쥴 알림", + NotificationManager.IMPORTANCE_DEFAULT + ) + notificationManager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(applicationContext, channelId) + .setSmallIcon(R.drawable.ic_logo) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .build() + + notificationManager.notify(System.currentTimeMillis().toInt(), notification) + } +} + diff --git a/app_golden/src/main/res/drawable/ic_analytics.xml b/app_golden/src/main/res/drawable/ic_analytics.xml new file mode 100644 index 0000000..1a5411b --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_analytics.xml @@ -0,0 +1,9 @@ + + + diff --git a/app_golden/src/main/res/drawable/ic_arrow_back.xml b/app_golden/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..763729f --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/app_golden/src/main/res/drawable/ic_chart.xml b/app_golden/src/main/res/drawable/ic_chart.xml new file mode 100644 index 0000000..8af9366 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_chart.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app_golden/src/main/res/drawable/ic_dashboard.xml b/app_golden/src/main/res/drawable/ic_dashboard.xml new file mode 100644 index 0000000..3ce8c2e --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_dashboard.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app_golden/src/main/res/drawable/ic_format_list.xml b/app_golden/src/main/res/drawable/ic_format_list.xml new file mode 100644 index 0000000..8f55c73 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_format_list.xml @@ -0,0 +1,9 @@ + + + diff --git a/app_golden/src/main/res/drawable/ic_launcher_foreground.xml b/app_golden/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..fb29d43 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app_golden/src/main/res/drawable/ic_logo.xml b/app_golden/src/main/res/drawable/ic_logo.xml new file mode 100644 index 0000000..cfdb899 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app_golden/src/main/res/drawable/ic_nav_notifications.xml b/app_golden/src/main/res/drawable/ic_nav_notifications.xml new file mode 100644 index 0000000..ece8416 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_nav_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/app_golden/src/main/res/drawable/ic_notifications_filled.xml b/app_golden/src/main/res/drawable/ic_notifications_filled.xml new file mode 100644 index 0000000..d14d8d2 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_notifications_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app_golden/src/main/res/drawable/ic_open_in_new.xml b/app_golden/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 0000000..0c8d8ca --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,9 @@ + + + diff --git a/app_golden/src/main/res/drawable/ic_portfolio.xml b/app_golden/src/main/res/drawable/ic_portfolio.xml new file mode 100644 index 0000000..5f64d18 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_portfolio.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app_golden/src/main/res/drawable/ic_scheduler.xml b/app_golden/src/main/res/drawable/ic_scheduler.xml new file mode 100644 index 0000000..8b2c755 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_scheduler.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app_golden/src/main/res/drawable/ic_settings.xml b/app_golden/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..eb4ce34 --- /dev/null +++ b/app_golden/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app_golden/src/main/res/drawable/splash_background.xml b/app_golden/src/main/res/drawable/splash_background.xml new file mode 100644 index 0000000..b7605bd --- /dev/null +++ b/app_golden/src/main/res/drawable/splash_background.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app_golden/src/main/res/layout/activity_main.xml b/app_golden/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..8bc6e3b --- /dev/null +++ b/app_golden/src/main/res/layout/activity_main.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/app_golden/src/main/res/layout/activity_splash.xml b/app_golden/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..684f235 --- /dev/null +++ b/app_golden/src/main/res/layout/activity_splash.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + diff --git a/app_golden/src/main/res/layout/bottom_sheet_signal_detail.xml b/app_golden/src/main/res/layout/bottom_sheet_signal_detail.xml new file mode 100644 index 0000000..7962866 --- /dev/null +++ b/app_golden/src/main/res/layout/bottom_sheet_signal_detail.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/app_golden/src/main/res/layout/dialog_schedule.xml b/app_golden/src/main/res/layout/dialog_schedule.xml new file mode 100644 index 0000000..8a6e615 --- /dev/null +++ b/app_golden/src/main/res/layout/dialog_schedule.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
- -
-
MACD+RSI
-
볼린저밴드
-
이평크로스
-
-
-
15분
-
1시간
-
4시간
-
일봉
-
- -
-
💰 총 수익률
-
+23.5%
-
₩10,000,000 → ₩12,350,000
-
- -
-
-
🎯
-
68%
-
승률
-
-
-
🔄
-
25회
-
총 거래
-
-
-
-
+₩94K
-
평균 수익
-
-
-
📉
-
-8.2%
-
최대 낙폭
-
-
- -
-
- 📈 차트 - 전체보기 → -
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
- -
-
- 🎯 전략 정확도 -
-
-
-
-
70%
-
정확도
-
-
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
-
-
- -
-
- 📋 최근 거래 - 전체보기 → -
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58,450,000
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55,520,000
-
-
-
-
-
-
- - - - - - diff --git a/frontend_golden/public/ui-mockups/mobile/mobile-02-card-scroll.html b/frontend_golden/public/ui-mockups/mobile/mobile-02-card-scroll.html deleted file mode 100644 index 3176f01..0000000 --- a/frontend_golden/public/ui-mockups/mobile/mobile-02-card-scroll.html +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - Mobile-02 카드 스크롤 - - - - -
-
- 백테스트 - Mobile-02 -
-
- -
-
-
- ⚙️ 설정 -
-
-
-
-
종목
- -
-
-
전략
- -
-
-
-
-
시간봉
- -
-
-
기간
- -
-
- -
-
- -
-
-
-
BTC/KRW
-
MACD+RSI · 1시간
-
-
-
+23.5%
-
₩2,350,000
-
-
-
- -
-
-
💰
-
₩12.35M
-
최종 자산
-
-
-
🎯
-
68%
-
승률
-
-
-
🔄
-
25회
-
총 거래
-
-
-
📉
-
-8.2%
-
MDD
-
-
- -
-
- 📈 가격 차트 -
-
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
-
-
-
- -
-
- 🎯 정확도 -
-
-
-
-
-
70%
-
정확도
-
-
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
-
-
-
- -
-
- 📋 거래 내역 - 전체 → -
-
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58,450,000
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55,520,000
-
-
-
-
-
-
-
- - - - diff --git a/frontend_golden/public/ui-mockups/mobile/mobile-03-fullscreen-chart.html b/frontend_golden/public/ui-mockups/mobile/mobile-03-fullscreen-chart.html deleted file mode 100644 index a40c92e..0000000 --- a/frontend_golden/public/ui-mockups/mobile/mobile-03-fullscreen-chart.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - Mobile-03 풀스크린 차트 - - - - -
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
-
- -
-
- - 백테스트 - Mobile-03 -
-
-
-

BTC/KRW

-

MACD+RSI · 1H · 3개월

-
-
-
₩58,450,000
-
+2.34%
-
-
-
- - - -
-
-
-
💰 총 수익률
-
+23.5%
-
-
-
70%
-
🎯 정확도
-
-
- -
-
- 💰 -
-
₩12.35M
-
최종 자산
-
-
-
- 🎯 -
-
68%
-
승률
-
-
-
- 🔄 -
-
25회
-
총 거래
-
-
-
- 📉 -
-
-8.2%
-
MDD
-
-
-
- -
-
- 📋 최근 거래 - 전체보기 → -
-
-
-
- SELL - +5.2% -
-
₩58,450,000
-
03.15 14:00
-
-
-
- BUY - - -
-
₩55,520,000
-
03.12 09:00
-
-
-
- SELL - -2.1% -
-
₩54,200,000
-
03.08 16:00
-
-
-
-
- - diff --git a/frontend_golden/public/ui-mockups/mobile/mobile-04-bottom-sheet.html b/frontend_golden/public/ui-mockups/mobile/mobile-04-bottom-sheet.html deleted file mode 100644 index 89c108d..0000000 --- a/frontend_golden/public/ui-mockups/mobile/mobile-04-bottom-sheet.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - - Mobile-04 바텀시트 - - - - -
-
-
- 백테스트 - Mobile-04 -
-
- -
-
-
-
-
BTC/KRW
-
비트코인 · MACD+RSI
-
-
-
₩58.45M
-
+2.34%
-
-
-
- -
-
- - - - -
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
- -
-
- -
-
-
+23.5%
-
💰 총 수익률
-
-
-
70%
-
🎯 정확도
-
-
- -
-
-
₩12.3M
-
최종 자산
-
-
-
68%
-
승률
-
-
-
25
-
거래
-
-
-
-8.2%
-
MDD
-
-
- -
- - -
-
- - diff --git a/frontend_golden/public/ui-mockups/mobile/mobile-05-minimal-dark.html b/frontend_golden/public/ui-mockups/mobile/mobile-05-minimal-dark.html deleted file mode 100644 index 0bae863..0000000 --- a/frontend_golden/public/ui-mockups/mobile/mobile-05-minimal-dark.html +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - Mobile-05 미니멀 다크 - - - - -
-
- 백테스트 - Mobile-05 -
-
- -
-
- BTC/KRW · 1H · MACD+RSI - +2.34% -
- -
-
+23.5%
-
₩10,000,000 → ₩12,350,000
-
- -
-
- 최종 자산 - ₩12,350,000 -
-
- 승률 - 68% (17/25) -
-
- 최대 낙폭 - -8.2% -
-
- -
-
- 전략 정확도 - 70% -
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
-
- -
-
- 가격 차트 - 3개월 -
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
- -
-
- 최근 거래 - 전체보기 → -
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58,450,000
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55,520,000
-
-
-
-
-
-
- - - - diff --git a/frontend_golden/public/ui-mockups/pattern-matching-search.html b/frontend_golden/public/ui-mockups/pattern-matching-search.html deleted file mode 100644 index c7552cc..0000000 --- a/frontend_golden/public/ui-mockups/pattern-matching-search.html +++ /dev/null @@ -1,1187 +0,0 @@ - - - - - - 주식차트 패턴 매칭 검색 - UI 목업 - - - -
- -
-
-
- 📊 - 패턴 라이브러리 -
-
- - - -
- -
-
- 🔄 반전 패턴 - 8 -
-
-
-
- - - -
-
헤드앤숄더
-
-
-
- - - -
-
역헤드앤숄더
-
-
-
- - - -
-
더블탑
-
-
-
- - - -
-
더블바텀
-
-
-
- - - -
-
트리플탑
-
-
-
- - - -
-
트리플바텀
-
-
-
- - -
-
- ➡️ 지속 패턴 - 6 -
-
-
-
- - - - - -
-
상승 플래그
-
-
-
- - - - - -
-
하락 플래그
-
-
-
- - - -
-
상승 웨지
-
-
-
- - - -
-
하락 웨지
-
-
-
- - - - -
-
대칭 삼각형
-
-
-
- - - -
-
박스권
-
-
-
- - -
-
- 🕯️ 캔들 패턴 - 4 -
-
-
-
- - - - - -
-
망치형
-
-
-
- - - - - -
-
상승장악형
-
-
-
- - - - -
-
도지
-
-
-
- - - - - -
-
쓰리화이트솔저
-
-
-
-
-
- - -
-
-
-
- - - -
- -
- -
- - - -
- -
- -
- -
-
-
- -
- - -
-
- -
-
-
-

패턴 입력

- 원하는 패턴을 그리거나 왼쪽에서 선택하세요 -
-
- - - -
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - 왼쪽 어깨 - 헤드 - 오른쪽 어깨 - 넥라인 - -
-
-
-
- - -
-
-
- 🎯 - 검색 결과 -
-
- -
-
- - -
- -
- - -
- -
- -
- - 75% -
-
-
- -
- 24개 종목 발견 - -
- -
- -
-
-
-
B
-
-

BTC/KRW

- 비트코인 -
-
-
-
94%
-
유사도
-
-
-
- - - -
-
-
-
+12.5%
-
예상 수익
-
-
-
3~5일
-
예상 기간
-
-
-
78%
-
성공률
-
-
-
- - -
-
-
-
E
-
-

ETH/KRW

- 이더리움 -
-
-
-
89%
-
유사도
-
-
-
- - - -
-
-
-
+8.3%
-
예상 수익
-
-
-
2~4일
-
예상 기간
-
-
-
72%
-
성공률
-
-
-
- - -
-
-
-
X
-
-

XRP/KRW

- 리플 -
-
-
-
85%
-
유사도
-
-
-
- - - -
-
-
-
+15.2%
-
예상 수익
-
-
-
4~7일
-
예상 기간
-
-
-
65%
-
성공률
-
-
-
-
- - -
-
-

📊 헤드앤숄더 패턴

- 하락 반전 -
-
-
- 평균 수익률 - +11.2% -
-
- 평균 기간 - 4.5일 -
-
- 성공률 - 73% -
-
- 발생 빈도 - 중간 -
-
-
-
-
- - - diff --git a/frontend_golden/public/ui-mockups/pc/pc-01-trading-terminal.html b/frontend_golden/public/ui-mockups/pc/pc-01-trading-terminal.html deleted file mode 100644 index 6d19876..0000000 --- a/frontend_golden/public/ui-mockups/pc/pc-01-trading-terminal.html +++ /dev/null @@ -1,714 +0,0 @@ - - - - - - PC-01 트레이딩 터미널 스타일 - - - - -
- - -
- PC-01 트레이딩 터미널 - 🔔 - ⚙️ -
-
- -
- - - - -
-
-
- BTC/KRW - ₩58,450,000 - +2.34% (+1,340,000) -
-
- - - - - -
-
- -
-
- 62,000,000 - 60,000,000 - 58,000,000 - 56,000,000 - 54,000,000 -
-
- 03/10 - 03/11 - 03/12 - 03/13 - 03/14 - 03/15 -
-
-
-
-
-
BUY
-
-
-
-
SELL
-
-
-
BUY
-
-
-
-
-
-
- -
-
-
MACD (12, 26, 9)
-
-
-
-
RSI (14)
-
-
-
-
Volume
-
-
-
-
- - - -
- - diff --git a/frontend_golden/public/ui-mockups/pc/pc-02-analytics-report.html b/frontend_golden/public/ui-mockups/pc/pc-02-analytics-report.html deleted file mode 100644 index 6156512..0000000 --- a/frontend_golden/public/ui-mockups/pc/pc-02-analytics-report.html +++ /dev/null @@ -1,553 +0,0 @@ - - - - - - PC-02 분석 리포트 - - - - - -
-
-

📊 투자 분석 리포트

-

KRW-BTC • MACD + RSI 전략 • 2024년 12월 7일

-
-
- PC-02 분석리포트 - -
-
- - -
-
분석 설정
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
📈
- 총 수익률 -
-
+23.45%
-
-
-
-
💰
- 순이익 -
-
+₩234만
-
-
-
-
🎯
- 승률 -
-
68.5%
-
-
-
-
🔄
- 총 거래 -
-
27회
-
-
- - -
- -
-
- 📈 가격 차트 & 매매 신호 -
- - 1h - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-
-
정확도 분석
-
-
- 전체 정확도 - 68.5% -
-
-
-
-
- 매수 정확도 - 72% -
-
-
-
-
- 매도 정확도 - 65% -
-
-
-
- -
-
자산 현황
-
- 초기 투자금 - ₩10,000,000 -
-
- 최종 자산 - ₩12,345,000 -
-
- 최대 수익 - ₩3,200,000 -
-
- 최대 손실 - ₩-850,000 -
-
- -
-
거래 통계
-
-
-
18
-
수익 거래
-
-
-
9
-
손실 거래
-
-
-
-
-
- - -
-
-

📋 거래 내역

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
유형시간가격수량손익
매도2024-12-07 14:00₩58,450,0000.142857+₩520,000
매수2024-12-05 09:00₩55,520,0000.142857-
매도2024-12-03 16:00₩54,200,0000.156250-₩180,000
매수2024-12-01 11:00₩55,350,0000.156250-
-
- - diff --git a/frontend_golden/public/ui-mockups/pc/pc-04-dark-pro.html b/frontend_golden/public/ui-mockups/pc/pc-04-dark-pro.html deleted file mode 100644 index 74d427f..0000000 --- a/frontend_golden/public/ui-mockups/pc/pc-04-dark-pro.html +++ /dev/null @@ -1,780 +0,0 @@ - - - - - - PC-04 다크 프로페셔널 - - - - -
- - - - -
-
-
-

투자전략 백테스트

- PC-04 다크 프로 -
-
- - -
-
- -
- -
-
- 종목: - KRW-BTC -
-
- 전략: - MACD + RSI -
-
- 시간봉: - 1h -
-
- 기간: - 500개 -
-
- 초기자본: - ₩1000만 -
-
- - -
-
-

KRW-BTC 백테스트 완료

-

MACD + RSI 전략 · 1h 봉 기준

-
-
-
-
+23.5%
-
총 수익률
-
-
-
₩12.35M
-
최종 자산
-
-
-
- - -
-
-
💰
-
투자 수익
-
₩235만
-
-
-
🎯
-
승률
-
68%
-
-
-
🔄
-
총 거래
-
25회
-
-
-
-
수익 거래
-
17회
-
-
-
-
손실 거래
-
8회
-
-
-
📉
-
최대 손실
-
-8.2%
-
-
- - -
-
-
- 📈 가격 차트 & 매매 시점 -
- - - -
-
-
-
-
-
-
-
-
BUY
-
-
-
-
SELL
-
-
-
BUY
-
-
-
-
-
-
- -
-
-
🎯 전략 정확도
-
-
-
70%
-
정확도
-
-
-
-
-
- 매수 정확도 - 76% -
-
-
-
-
- 매도 정확도 - 64% -
-
-
-
-
- -
-
- 📋 최근 거래 -
-
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58.45M
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55.52M
-
-
-
-
-
-
S
-
-
매도
-
03.08 16:00
-
-
-
₩54.2M
-
-2.1%
-
-
-
-
-
-
-
-
-
- - diff --git a/frontend_golden/public/ui-mockups/pc/pc-05-splitview.html b/frontend_golden/public/ui-mockups/pc/pc-05-splitview.html deleted file mode 100644 index f41f65f..0000000 --- a/frontend_golden/public/ui-mockups/pc/pc-05-splitview.html +++ /dev/null @@ -1,542 +0,0 @@ - - - - - - PC-05 스플릿뷰 스타일 - - - - -
- -
-
- - PC-05 스플릿뷰 -
- -
-

⚙️ 테스트 설정

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
- -
- -
-
-
-
BTC/KRW
-
MACD+RSI · 1H · 2024.01.01 - 03.15
-
-
-
+23.5%
-
₩2,350,000
-
-
-
- -
-
-
최종 자산
-
₩12.35M
-
-
-
승률
-
68%
-
-
-
총 거래
-
25회
-
-
-
수익 거래
-
17회
-
-
-
손실 거래
-
8회
-
-
-
최대 낙폭
-
-8.2%
-
-
- -
-

🎯 전략 정확도

-
-
-
-
70%
-
정확도
-
-
-
-
- 매수 신호 정확도 - 76% -
-
- 매도 신호 정확도 - 64% -
-
- 평균 수익률 - +4.2% -
-
-
-
-
- - -
-
- 📈 가격 차트 & 매매 시점 -
- - - -
-
- -
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
- -
-
- 📋 거래 내역 - 전체보기 → -
-
-
-
S
-
-
매도
-
2024.03.15 14:00
-
-
-
₩58,450,000
-
+5.2%
-
-
-
-
B
-
-
매수
-
2024.03.12 09:00
-
-
-
₩55,520,000
-
-
-
-
-
-
S
-
-
매도
-
2024.03.08 16:00
-
-
-
₩54,200,000
-
-2.1%
-
-
-
-
-
-
- - diff --git a/frontend_golden/public/ui-mockups/strategy-test-ui-v1-dashboard.html b/frontend_golden/public/ui-mockups/strategy-test-ui-v1-dashboard.html deleted file mode 100644 index e1c6461..0000000 --- a/frontend_golden/public/ui-mockups/strategy-test-ui-v1-dashboard.html +++ /dev/null @@ -1,615 +0,0 @@ - - - - - - 투자전략 테스트 UI - V1 대시보드 스타일 - - - - -
-
-

투자전략 백테스트

- V1 - 대시보드 스타일 -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - -
-
-
💰 총 수익률
-
+23.5%
-
초기 대비 +2,350,000원
-
-
-
📊 최종 자산
-
12,350,000
-
원금 10,000,000원
-
-
-
🎯 승률
-
68%
-
17승 8패
-
-
-
📉 최대 낙폭
-
-8.2%
-
2024.01.15 기록
-
-
-
🔄 총 거래
-
25
-
매수 13회 / 매도 12회
-
-
-
⚡ 평균 수익
-
+94,000
-
거래당 평균
-
-
- - -
- -
-
-

📈 BTC/KRW 가격 차트 & 매매 시점

-
- - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
매수 시점
-
매도 시점
-
MA(20)
-
MA(60)
-
-
- - - -
-
- - diff --git a/frontend_golden/public/ui-mockups/strategy-test-ui-v2-chart-focus.html b/frontend_golden/public/ui-mockups/strategy-test-ui-v2-chart-focus.html deleted file mode 100644 index a1458ad..0000000 --- a/frontend_golden/public/ui-mockups/strategy-test-ui-v2-chart-focus.html +++ /dev/null @@ -1,775 +0,0 @@ - - - - - - 투자전략 테스트 UI - V2 차트 중심 스타일 - - - - -
- - V2 - 차트 중심 스타일 -
- -
- - - - -
-
-
- BTC/KRW - ₩58,450,000 - ▲ +2.34% (+1,340,000) -
-
- - - - -
-
- -
-
- - - - -
-
-
- 62,000,000 - 60,000,000 - 58,000,000 - 56,000,000 - 54,000,000 -
-
- 03/10 - 03/11 - 03/12 - 03/13 - 03/14 - 03/15 -
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
-
- -
-
-
MACD (12, 26, 9)
-
-
-
-
-
-
RSI (14)
-
-
-
-
-
-
Volume
-
-
-
-
-
-
- - - -
- - diff --git a/frontend_golden/public/ui-mockups/strategy-test-ui-v3-tab-based.html b/frontend_golden/public/ui-mockups/strategy-test-ui-v3-tab-based.html deleted file mode 100644 index 267ed24..0000000 --- a/frontend_golden/public/ui-mockups/strategy-test-ui-v3-tab-based.html +++ /dev/null @@ -1,911 +0,0 @@ - - - - - - 투자전략 테스트 UI - V3 탭 기반 스타일 - - - - -
-
- - -
- V3 - 탭 기반 스타일 -
- -
- -
-
-

- ⚙️ 백테스트 설정 -

- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
💰 총 수익률
-
+21.5%
-
₩2,150,000 수익
-
-
-
📊 최종 자산
-
₩12.15M
-
원금 ₩10,000,000
-
-
-
🎯 승률
-
72%
-
18승 7패
-
-
-
🔄 총 거래
-
25
-
매수 13 / 매도 12
-
-
-
📉 최대 낙폭
-
-7.8%
-
2024.02.12 기록
-
-
-
⚡ 평균 수익
-
+₩86K
-
거래당 평균
-
-
- - - - - -
- -
-
-
- - - -
-
- - - - -
-
- -
-
-
- 62,000,000 - 60,000,000 - 58,000,000 - 56,000,000 - 54,000,000 -
-
- 03/08 - 03/09 - 03/10 - 03/11 - 03/12 - 03/13 - 03/14 - 03/15 -
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
- -
-
-
- 상승 캔들 -
-
-
- 하락 캔들 -
-
-
- 매수 시점 -
-
-
- 매도 시점 -
-
-
- MA(20) -
-
-
- MA(60) -
-
-
- - - - - - -
-
- - diff --git a/frontend_golden/public/ui-mockups/strategy-test-ui-v4-mobile-friendly.html b/frontend_golden/public/ui-mockups/strategy-test-ui-v4-mobile-friendly.html deleted file mode 100644 index a9b63ca..0000000 --- a/frontend_golden/public/ui-mockups/strategy-test-ui-v4-mobile-friendly.html +++ /dev/null @@ -1,726 +0,0 @@ - - - - - - 투자전략 테스트 UI - V4 모바일 친화 스타일 - - - - -
- - - - -
-
-
-
-
-
BTC/KRW
-
비트코인 · 업비트
-
-
- -
-
-
₩58,450,000
-
-
+₩1,340,000
-
+2.34%
-
-
-
- - -
-
MACD+RSI
-
볼린저밴드
-
이평선크로스
-
Stochastic
-
- -
-
15분
-
1시간
-
4시간
-
일봉
-
- - -
-
💰 총 수익률
-
+23.5%
-
₩10,000,000 → ₩12,350,000
-
- - -
-
-
🎯 승률
-
68%
-
17승 8패
-
-
-
🔄 총 거래
-
25
-
매수 13 / 매도 12
-
-
-
⚡ 평균 수익
-
+₩94K
-
거래당
-
-
-
📉 최대 낙폭
-
-8.2%
-
2024.01.15
-
-
- - -
📈 차트 분석
-
-
- 가격 & 매매 시점 -
- - - -
-
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
-
- - -
🎯 전략 정확도
-
-
- 예측 정확도 - 70% -
-
-
- 매수 -
-
-
- 76% -
-
- 매도 -
-
-
- 64% -
-
-
- - -
📋 최근 거래
-
-
- 거래 내역 - 전체보기 → -
-
-
S
-
-
매도
-
2024.03.15 14:00
-
-
-
₩58,450,000
-
+5.2%
-
-
-
-
B
-
-
매수
-
2024.03.12 09:00
-
-
-
₩55,520,000
-
진행중
-
-
-
-
S
-
-
매도
-
2024.03.08 16:00
-
-
-
₩54,200,000
-
-2.1%
-
-
-
-
B
-
-
매수
-
2024.03.05 11:00
-
-
-
₩55,350,000
-
-
-
-
-
-
- - - - - - - - diff --git a/frontend_golden/public/ui-mockups/tablet/tablet-01-grid-dashboard.html b/frontend_golden/public/ui-mockups/tablet/tablet-01-grid-dashboard.html deleted file mode 100644 index 01f7a4e..0000000 --- a/frontend_golden/public/ui-mockups/tablet/tablet-01-grid-dashboard.html +++ /dev/null @@ -1,508 +0,0 @@ - - - - - - Tablet-01 그리드 대시보드 - - - - -
- -
- Tablet-01 그리드 - -
-
- -
-
-
-
종목
- -
-
-
전략
- -
-
-
시간봉
- -
-
-
기간
- -
-
-
초기자본
- -
- -
- -
-
-
-
BTC/KRW
-
MACD+RSI · 1H · 3개월
-
-
-
+23.5%
-
₩2,350,000 수익
-
-
- -
-
💰
-
최종 자산
-
₩12.35M
-
-
-
🎯
-
승률
-
68%
-
-
-
🔄
-
총 거래
-
25회
-
-
-
📉
-
최대 낙폭
-
-8.2%
-
- -
-
- 📈 가격 차트 -
- - -
-
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
- -
-
🎯 정확도
-
-
-
70%
-
전체
-
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
- -
-
- 📋 최근 거래 - 전체보기 → -
-
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58.4M
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55.5M
-
-
-
-
-
-
S
-
-
매도
-
03.08 16:00
-
-
-
₩54.2M
-
-2.1%
-
-
-
-
B
-
-
매수
-
03.05 11:00
-
-
-
₩55.3M
-
-
-
-
-
-
-
-
- - diff --git a/frontend_golden/public/ui-mockups/tablet/tablet-02-card-stack.html b/frontend_golden/public/ui-mockups/tablet/tablet-02-card-stack.html deleted file mode 100644 index 29d955b..0000000 --- a/frontend_golden/public/ui-mockups/tablet/tablet-02-card-stack.html +++ /dev/null @@ -1,482 +0,0 @@ - - - - - - Tablet-02 카드 스택 - - - - -
- - Tablet-02 카드 -
- -
- -
-
- ⚙️ 테스트 설정 -
-
-
-
-
종목
- -
-
-
전략
- -
-
-
시간봉
- -
-
-
분석기간
- -
-
-
초기자본
- -
-
- -
-
- - -
-
-
-
BTC/KRW
-
MACD+RSI · 1시간봉 · 3개월
-
-
-
+23.5%
-
₩2,350,000 수익
-
-
-
- - -
-
-
-
-
💰
-
₩12.35M
-
최종 자산
-
-
-
🎯
-
68%
-
승률
-
-
-
🔄
-
25회
-
총 거래
-
-
-
📉
-
-8.2%
-
최대 낙폭
-
-
-
-
- - -
-
- 📈 가격 차트 -
- - -
-
-
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
- - -
-
-
- 🎯 정확도 -
-
-
-
-
70%
-
전체 정확도
-
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
-
- -
-
- 📋 최근 거래 -
-
-
-
S
-
-
매도
-
2024.03.15 14:00
-
-
-
₩58,450,000
-
+5.2%
-
-
-
-
B
-
-
매수
-
2024.03.12 09:00
-
-
-
₩55,520,000
-
-
-
-
-
-
S
-
-
매도
-
2024.03.08 16:00
-
-
-
₩54,200,000
-
-2.1%
-
-
-
-
-
-
- - diff --git a/frontend_golden/public/ui-mockups/tablet/tablet-03-split-horizontal.html b/frontend_golden/public/ui-mockups/tablet/tablet-03-split-horizontal.html deleted file mode 100644 index ae9bc97..0000000 --- a/frontend_golden/public/ui-mockups/tablet/tablet-03-split-horizontal.html +++ /dev/null @@ -1,272 +0,0 @@ - - - - - - Tablet-03 가로 분할 - - - - -
-
- -
- Tablet-03 가로분할 - -
-
- -
-
-
-
-
종목
- -
-
-
전략
- -
-
-
-
-
시간봉
- -
-
-
기간
- -
-
-
초기자본
- -
-
- -
- -
-
-
BTC/KRW
-
MACD+RSI · 1H · 3개월
-
-
-
+23.5%
-
₩2,350,000
-
-
-
-
₩12.35M
-
최종자산
-
-
-
68%
-
승률
-
-
-
25
-
거래
-
-
-
-8.2%
-
MDD
-
-
-
-
- -
-
-
- 📈 가격 차트 & 매매 시점 -
- - - -
-
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
-
-
- -
-
-
🎯 전략 정확도
-
-
-
-
70%
-
정확도
-
-
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
-
-
- -
📋 거래 내역
-
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58.4M
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55.5M
-
-
-
-
-
-
S
-
-
매도
-
03.08 16:00
-
-
-
₩54.2M
-
-2.1%
-
-
-
-
B
-
-
매수
-
03.05 11:00
-
-
-
₩55.3M
-
-
-
-
-
-
-
-
- - diff --git a/frontend_golden/public/ui-mockups/tablet/tablet-04-slide-panel.html b/frontend_golden/public/ui-mockups/tablet/tablet-04-slide-panel.html deleted file mode 100644 index 617ed45..0000000 --- a/frontend_golden/public/ui-mockups/tablet/tablet-04-slide-panel.html +++ /dev/null @@ -1,274 +0,0 @@ - - - - - - Tablet-04 슬라이드 패널 - - - - -
- - Tablet-04 슬라이드 -
- - - -
-
-
-
-
종목
- -
-
-
전략
- -
-
-
시간봉
- -
-
-
기간
- -
-
-
초기자본
- -
- -
-
- -
-
-
-
BTC/KRW
-
MACD+RSI · 1시간봉 · 3개월
-
-
-
+23.5%
-
₩2,350,000
-
-
-
- -
-
-
💰
-
₩12.35M
-
최종 자산
-
-
-
🎯
-
68%
-
승률
-
-
-
🔄
-
25회
-
총 거래
-
-
-
📉
-
-8.2%
-
최대 낙폭
-
-
-
-
+₩94K
-
평균 수익
-
-
- -
-
- 📈 가격 차트 -
- - -
-
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
- -
-
-
🎯 정확도
-
-
-
70%
-
전체 정확도
-
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
- -
-
- 📋 거래 내역 - 전체보기 → -
-
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58,450,000
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55,520,000
-
-
-
-
-
-
S
-
-
매도
-
03.08 16:00
-
-
-
₩54,200,000
-
-2.1%
-
-
-
-
-
-
- - diff --git a/frontend_golden/public/ui-mockups/tablet/tablet-05-compact-pro.html b/frontend_golden/public/ui-mockups/tablet/tablet-05-compact-pro.html deleted file mode 100644 index bdc6700..0000000 --- a/frontend_golden/public/ui-mockups/tablet/tablet-05-compact-pro.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - - Tablet-05 컴팩트 프로 - - - - -
- -
- Tablet-05 컴팩트 - - -
-
- -
-
-
-
종목
- -
-
-
전략
- -
-
-
시간봉
- -
-
-
기간
- -
-
-
초기자본
- -
- -
- -
-
-

BTC/KRW

-

MACD+RSI 전략 · 1시간봉 · 2024.01 - 03

-
-
-
+23.5%
-
₩2,350,000 수익
-
-
- -
-
-
₩12.35M
-
최종 자산
-
-
-
68%
-
승률
-
-
-
25회
-
총 거래
-
-
-
17회
-
수익
-
-
-
8회
-
손실
-
-
-
-8.2%
-
MDD
-
-
- -
-
-
- 📈 가격 차트 & 매매 시점 -
- - - -
-
-
-
-
-
-
-
B
-
-
-
-
S
-
-
-
B
-
-
-
-
-
- -
-
-
🎯 전략 정확도
-
-
-
70%
-
정확도
-
-
-
-
- 매수 - 76% -
-
-
-
-
- 매도 - 64% -
-
-
-
- -
-
- 📋 최근 거래 -
-
-
-
S
-
-
매도
-
03.15 14:00
-
-
-
₩58.4M
-
+5.2%
-
-
-
-
B
-
-
매수
-
03.12 09:00
-
-
-
₩55.5M
-
-
-
-
-
-
S
-
-
매도
-
03.08 16:00
-
-
-
₩54.2M
-
-2.1%
-
-
-
-
-
-
-
- - diff --git a/frontend_golden/src/App.tsx b/frontend_golden/src/App.tsx deleted file mode 100644 index fed7ced..0000000 --- a/frontend_golden/src/App.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import React, { useState } from 'react'; -import { - ThemeProvider, - CssBaseline, - Box, - AppBar, - Toolbar, - Typography, - Button, - Container, - IconButton, - Tooltip, - Chip, - Menu, - MenuItem, - ListItemIcon, - ListItemText, - Divider, - Badge, -} from '@mui/material'; -import { - Dashboard as DashboardIcon, - ShowChart, - CloudDownload, - Schedule, - Settings as SettingsIcon, - Analytics, - LightMode, - DarkMode, - Login as LoginIcon, - Logout as LogoutIcon, - AccountCircle, - Person, - Rule as RuleIcon, - AccountBalance as SimulationIcon, - TrendingUp as TrendingUpIcon, - Fullscreen, - FullscreenExit, - Notifications as NotificationsIcon, - ListAlt as OrderbookIcon, - FormatListBulleted as AlertListIcon, - AutoAwesome as AutoTradingIcon, - GridView as MultiChartIcon, - ClearAll as ClearAllIcon, - CompareArrows as CompareIcon, -} from '@mui/icons-material'; - -import { useAppTheme } from './contexts/ThemeContext'; -import { useIndicatorPopup } from './contexts/IndicatorPopupContext'; -import { useAuth } from './contexts/AuthContext'; -import { useAlertNotification } from './contexts/AlertNotificationContext'; -import { useNavigation } from './contexts/NavigationContext'; -import { useIsMobile } from './hooks/useIsMobile'; -import { MobileAppShell } from './components/MobileAppShell'; -import DashboardPage from './pages/DashboardPage'; -import PatternSearchPage from './pages/PatternSearchPage'; -import RisingStocksPage from './pages/RisingStocksPage'; -import StrategyTreeBuilderPage from './pages/StrategyTreeBuilderPage'; -import SimulationPage from './pages/SimulationPage'; -import DataCollectionPage from './pages/DataCollectionPage'; -import SchedulerPage from './pages/SchedulerPage'; -import SettingsPage from './pages/SettingsPage'; -import CandlestickTestPage from './pages/CandlestickTestPage'; -import CandlestickTestPage3 from './pages/CandlestickTestPage3'; -import AlertPage from './pages/AlertPage'; -import NotificationListPage from './pages/NotificationListPage'; -import OrderbookPage from './pages/OrderbookPage'; -import AutoTradingPage from './pages/AutoTradingPage'; -import MultiChartPage from './pages/MultiChartPage'; -import MultiChartPage2 from './pages/MultiChartPage2'; -import AlertListPage from './pages/AlertListPage'; -import ChartValidationPage from './pages/admin/ChartValidationPage'; -import LoginDialog from './components/LoginDialog'; -import SignUpDialog from './components/SignUpDialog'; -import IndicatorConfigPanel from './components/IndicatorConfigPanel'; -import IndicatorPopupWindow from './components/IndicatorPopupWindow'; -import AlertSnackbar from './components/alert/AlertSnackbar'; - -interface MenuItemType { - shortText: string; - fullText: string; - icon: React.ReactNode; - page: string; -} - - const menuItems: MenuItemType[] = [ - { shortText: '대시보드', fullText: '대시보드', icon: , page: 'dashboard' }, - { shortText: '멀티차트', fullText: '멀티차트', icon: , page: 'multi-chart' }, - { shortText: '멀티2', fullText: '멀티차트2', icon: , page: 'multi-chart-2' }, - { shortText: '추세', fullText: '상승종목 추세분석', icon: , page: 'rising-stocks' }, - { shortText: '패턴', fullText: '패턴검색', icon: , page: 'pattern-search' }, - { shortText: '투자', fullText: '모의투자', icon: , page: 'simulation' }, - { shortText: '전략', fullText: '전략설정', icon: , page: 'strategy-tree' }, - { shortText: '분석', fullText: '투자분석', icon: , page: 'candlestick-test' }, - { shortText: '분석3', fullText: '투자분석3 (프론트엔드 지표)', icon: , page: 'candlestick-test-3' }, - { shortText: '알림', fullText: '알림 설정', icon: , page: 'alerts' }, - { shortText: '알림목록', fullText: '알림목록', icon: , page: 'alert-list' }, - { shortText: '호가', fullText: '실시간 호가', icon: , page: 'orderbook' }, - { shortText: '자동매매', fullText: '자동매매', icon: , page: 'auto-trading' }, - { shortText: '검증', fullText: '차트 비교검증', icon: , page: 'chart-validation' }, - { shortText: '데이터', fullText: '데이터 수집', icon: , page: 'data-collection' }, - { shortText: '스케쥴', fullText: '스케쥴러', icon: , page: 'scheduler' }, - { shortText: '설정', fullText: '설정', icon: , page: 'settings' }, -]; - -function MainContent() { - const isMobile = useIsMobile(); - const { currentPage, navigateToPage } = useNavigation(); - const { themeMode, setThemeMode, theme } = useAppTheme(); - const { isAuthenticated, user, logout } = useAuth(); - const { isPopupOpen, closePopup, chartData, chartMargin } = useIndicatorPopup(); - const { unreadCount, notifications, dismissAllNotifications } = useAlertNotification(); - - // 로그인/회원가입 다이얼로그 상태 - const [loginDialogOpen, setLoginDialogOpen] = useState(false); - const [signUpDialogOpen, setSignUpDialogOpen] = useState(false); - - // 사용자 메뉴 상태 - const [userMenuAnchor, setUserMenuAnchor] = useState(null); - - // 전체화면 상태 - const [isFullscreen, setIsFullscreen] = useState(false); - - const handleUserMenuOpen = (event: React.MouseEvent) => { - setUserMenuAnchor(event.currentTarget); - }; - - const handleUserMenuClose = () => { - setUserMenuAnchor(null); - }; - - const handleLogout = () => { - logout(); - handleUserMenuClose(); - }; - - const renderPage = () => { - // 투자분석/투자분석3: 상태 유지를 위해 항상 마운트하되 css display로 노출 여부 제어 - if (currentPage === 'candlestick-test') return null; - if (currentPage === 'candlestick-test-3') return null; - - switch (currentPage) { - case 'dashboard': - return ; - case 'multi-chart': - return ; - case 'multi-chart-2': - return ; - case 'rising-stocks': - return ; - case 'pattern-search': - return ; - case 'simulation': - return ; - case 'strategy-tree': - return ; - case 'data-collection': - return ; - case 'scheduler': - return ; - case 'alerts': - return ; - case 'alert-list': - return ; - case 'notifications': - return ; - case 'orderbook': - return ; - case 'auto-trading': - return ; - case 'chart-validation': - return ; - case 'settings': - return ; - default: - return ; - } - }; - - const handleThemeToggle = () => { - setThemeMode(themeMode === 'dark' ? 'light' : 'dark'); - }; - - // 전체화면 토글 함수 - const handleFullscreenToggle = () => { - if (!document.fullscreenElement) { - // 전체화면으로 전환 - document.documentElement.requestFullscreen().catch((err) => { - console.error('전체화면 전환 실패:', err); - }); - } else { - // 전체화면 해제 - if (document.exitFullscreen) { - document.exitFullscreen(); - } - } - }; - - // 전체화면 상태 변경 감지 - React.useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => { - document.removeEventListener('fullscreenchange', handleFullscreenChange); - }; - }, []); - - // 다크 모드 AppBar 스타일 - const darkAppBarStyle = { - background: 'linear-gradient(90deg, #0a0a0f 0%, #12121a 50%, #1a1a2e 100%)', - borderBottom: '1px solid rgba(100, 149, 237, 0.2)', - boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)', - }; - - // 라이트 모드 AppBar 스타일 - const lightAppBarStyle = { - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - boxShadow: '0 2px 10px rgba(0, 0, 0, 0.15)', - }; - - // 모바일 접속 시 모바일 앱 UI (전체 화면 모바일 최적화) - if (isMobile) { - return ( - - - - - ); - } - - // 데스크톱: 기존 레이아웃 유지 - return ( - - - - {/* Top Navigation Bar */} - theme.zIndex.drawer + 100, - }} - > - - {/* Logo & Title */} - - - - Golden Analysis - - {/* - 투자전략 성능 분석 시스템 - */} - - - {/* Navigation Menu */} - - {menuItems.map((item) => ( - - navigateToPage(item.page)} - sx={{ - color: themeMode === 'dark' - ? (currentPage === item.page ? 'primary.main' : 'text.secondary') - : (currentPage === item.page ? '#ffffff' : 'rgba(255, 255, 255, 0.7)'), - backgroundColor: currentPage === item.page - ? (themeMode === 'dark' ? 'rgba(100, 149, 237, 0.15)' : 'rgba(255, 255, 255, 0.2)') - : 'transparent', - borderBottom: currentPage === item.page ? '2px solid' : '2px solid transparent', - borderBottomColor: currentPage === item.page - ? (themeMode === 'dark' ? 'primary.main' : '#ffffff') - : 'transparent', - borderRadius: 0, - '&:hover': { - backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.1)' : 'rgba(255, 255, 255, 0.15)', - color: themeMode === 'dark' ? 'primary.light' : '#ffffff', - }, - transition: 'all 0.2s ease-in-out', - }} - > - {item.page === 'alerts' && unreadCount > 0 ? ( - - {item.icon} - - ) : ( - item.icon - )} - - - ))} - - {/* Theme Toggle Button */} - - - {themeMode === 'dark' ? : } - - - - {/* Fullscreen Toggle Button */} - - - {isFullscreen ? : } - - - - {/* 알림팝업 전체 닫기 버튼 */} - - - - - - - - - {/* 로그인/사용자 버튼 */} - {isAuthenticated ? ( - <> - } - label={user?.name || user?.username || '사용자'} - onClick={handleUserMenuOpen} - sx={{ - ml: 2, - backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.2)' : 'rgba(255, 255, 255, 0.2)', - color: themeMode === 'dark' ? 'primary.main' : '#ffffff', - cursor: 'pointer', - '&:hover': { - backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.3)' : 'rgba(255, 255, 255, 0.3)', - }, - }} - /> - - - - - - - - - - - - - - - - - ) : ( - - )} - - - {/* Version */} - - v1.1.0 - - - - - {/* Main Content - Full Width */} - - - {/* 상태 유지가 필요한 투자분석 화면 */} - - - - {/* 투자분석3 (프론트엔드 지표 계산 버전) — 상태 유지 */} - - - - - {renderPage()} - - - - - {/* 로그인 다이얼로그 */} - setLoginDialogOpen(false)} - onSwitchToSignUp={() => { - setLoginDialogOpen(false); - setSignUpDialogOpen(true); - }} - /> - - {/* 회원가입 다이얼로그 */} - setSignUpDialogOpen(false)} - onSwitchToLogin={() => { - setSignUpDialogOpen(false); - setLoginDialogOpen(true); - }} - /> - - {/* 지표 설정 패널 (우측 하단 FAB) */} - - - {/* 보조지표 팝업 윈도우 */} - - - {/* 알림 스낵바 (모든 화면에서 표시) */} - - - ); -} - -function App() { - // Context providers are now in AppRouter.tsx - return ; -} - -export default App; diff --git a/frontend_golden/src/AppRouter.tsx b/frontend_golden/src/AppRouter.tsx deleted file mode 100644 index 1337b7c..0000000 --- a/frontend_golden/src/AppRouter.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import App from './App'; -import MobileChartPage from './pages/MobileChartPage'; -import RealtimeGaugeTestPage from './pages/RealtimeGaugeTestPage'; -import { AppThemeProvider } from './contexts/ThemeContext'; -import { ChartColorProvider } from './contexts/ChartColorContext'; -import { IndicatorVisibilityProvider } from './contexts/IndicatorVisibilityContext'; -import { IndicatorSettingsProvider } from './contexts/IndicatorSettingsContext'; -import { IndicatorPopupProvider } from './contexts/IndicatorPopupContext'; -import { AuthProvider } from './contexts/AuthContext'; -import { UpbitDataProvider } from './contexts/UpbitDataContext'; -import { UIThemeProvider } from './contexts/UIThemeContext'; -import { AlertNotificationProvider } from './contexts/AlertNotificationContext'; -import { NavigationProvider } from './contexts/NavigationContext'; - -/** - * 앱 라우터 - * - 데스크톱: / (기본 앱) - * - 모바일: /mobile/chart (모바일 차트 페이지) - */ -const AppRouter: React.FC = () => { - return ( - - - - - - - - - - - - - {/* 데스크톱 버전 */} - } /> - - {/* 모바일 차트 페이지 */} - } /> - - {/* 실시간 게이지 테스트 페이지 */} - } /> - - {/* 기본 리다이렉트 */} - } /> - - - - - - - - - - - - - ); -}; - -export default AppRouter; - diff --git a/frontend_golden/src/__tests__/realtime-update.test.tsx b/frontend_golden/src/__tests__/realtime-update.test.tsx deleted file mode 100644 index 6ffd377..0000000 --- a/frontend_golden/src/__tests__/realtime-update.test.tsx +++ /dev/null @@ -1,321 +0,0 @@ -/** - * 실시간 업데이트 통합 테스트 - */ -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useRealtimeCandles } from '../hooks/useRealtimeCandles'; -import { marketSocket } from '../services/marketSocket'; - -// Mock marketSocket -jest.mock('../services/marketSocket', () => ({ - marketSocket: { - connect: jest.fn().mockResolvedValue(undefined), - subscribe: jest.fn().mockReturnValue(() => {}), - unsubscribe: jest.fn(), - onConnectionStatusChange: jest.fn().mockReturnValue(() => {}), - onError: jest.fn().mockReturnValue(() => {}), - }, -})); - -describe('useRealtimeCandles Hook', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should connect and subscribe when enabled', async () => { - const { result } = renderHook(() => - useRealtimeCandles({ - market: 'KRW-BTC', - interval: '1m', - enabled: true, - }) - ); - - await waitFor(() => { - expect(marketSocket.connect).toHaveBeenCalled(); - expect(marketSocket.subscribe).toHaveBeenCalledWith( - 'KRW-BTC', - '1m', - expect.any(Function) - ); - }); - }); - - it('should not connect when disabled', () => { - renderHook(() => - useRealtimeCandles({ - market: 'KRW-BTC', - interval: '1m', - enabled: false, - }) - ); - - expect(marketSocket.connect).not.toHaveBeenCalled(); - expect(marketSocket.subscribe).not.toHaveBeenCalled(); - }); - - it('should update candles on snapshot', async () => { - const onUpdate = jest.fn(); - const { result } = renderHook(() => - useRealtimeCandles({ - market: 'KRW-BTC', - interval: '1m', - enabled: true, - onUpdate, - }) - ); - - // Simulate snapshot update - const mockUpdate = { - market: 'KRW-BTC', - interval: '1m', - candles: [ - { time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 }, - { time: '2026-03-07T01:01', open: 105, high: 115, low: 95, close: 110, volume: 1200 }, - ], - timestamp: Date.now(), - type: 'snapshot' as const, - }; - - // Get the callback that was passed to subscribe - const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0]; - const callback = subscribeCall[2]; - - act(() => { - callback(mockUpdate); - }); - - await waitFor(() => { - expect(result.current.candles).toHaveLength(2); - expect(result.current.updateCount).toBe(1); - expect(onUpdate).toHaveBeenCalledWith(mockUpdate.candles); - }); - }); - - it('should update existing candle on incremental update', async () => { - const onUpdate = jest.fn(); - const { result } = renderHook(() => - useRealtimeCandles({ - market: 'KRW-BTC', - interval: '1m', - enabled: true, - onUpdate, - }) - ); - - // Initial snapshot - const snapshotUpdate = { - market: 'KRW-BTC', - interval: '1m', - candles: [ - { time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 }, - ], - timestamp: Date.now(), - type: 'snapshot' as const, - }; - - const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0]; - const callback = subscribeCall[2]; - - act(() => { - callback(snapshotUpdate); - }); - - // Incremental update (same time, different values) - const incrementalUpdate = { - market: 'KRW-BTC', - interval: '1m', - candles: [ - { time: '2026-03-07T01:00', open: 100, high: 115, low: 85, close: 108, volume: 1500 }, - ], - timestamp: Date.now(), - type: 'update' as const, - }; - - act(() => { - callback(incrementalUpdate); - }); - - await waitFor(() => { - expect(result.current.candles).toHaveLength(1); - expect(result.current.candles[0].close).toBe(108); - expect(result.current.candles[0].volume).toBe(1500); - expect(result.current.updateCount).toBe(2); - }); - }); - - it('should add new candle on incremental update', async () => { - const onUpdate = jest.fn(); - const { result } = renderHook(() => - useRealtimeCandles({ - market: 'KRW-BTC', - interval: '1m', - enabled: true, - onUpdate, - }) - ); - - // Initial snapshot - const snapshotUpdate = { - market: 'KRW-BTC', - interval: '1m', - candles: [ - { time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 }, - ], - timestamp: Date.now(), - type: 'snapshot' as const, - }; - - const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0]; - const callback = subscribeCall[2]; - - act(() => { - callback(snapshotUpdate); - }); - - // New candle - const newCandleUpdate = { - market: 'KRW-BTC', - interval: '1m', - candles: [ - { time: '2026-03-07T01:01', open: 105, high: 115, low: 95, close: 110, volume: 1200 }, - ], - timestamp: Date.now(), - type: 'update' as const, - }; - - act(() => { - callback(newCandleUpdate); - }); - - await waitFor(() => { - expect(result.current.candles).toHaveLength(2); - expect(result.current.candles[1].time).toBe('2026-03-07T01:01'); - expect(result.current.updateCount).toBe(2); - }); - }); - - it('should cleanup on unmount', () => { - const { unmount } = renderHook(() => - useRealtimeCandles({ - market: 'KRW-BTC', - interval: '1m', - enabled: true, - }) - ); - - const unsubscribeFn = jest.fn(); - (marketSocket.subscribe as jest.Mock).mockReturnValue(unsubscribeFn); - - unmount(); - - // Note: In actual implementation, unsubscribe is called in cleanup - // This test verifies the hook structure - }); -}); - -describe('Real-time Chart Integration', () => { - it('should handle timeframe switching', async () => { - const { result, rerender } = renderHook( - ({ market, interval }) => - useRealtimeCandles({ - market, - interval, - enabled: true, - }), - { - initialProps: { market: 'KRW-BTC', interval: '1m' }, - } - ); - - // Initial subscription - expect(marketSocket.subscribe).toHaveBeenCalledWith( - 'KRW-BTC', - '1m', - expect.any(Function) - ); - - // Change interval - rerender({ market: 'KRW-BTC', interval: '5m' }); - - await waitFor(() => { - // Should resubscribe with new interval - expect(marketSocket.subscribe).toHaveBeenCalledWith( - 'KRW-BTC', - '5m', - expect.any(Function) - ); - }); - }); - - it('should handle market switching', async () => { - const { result, rerender } = renderHook( - ({ market, interval }) => - useRealtimeCandles({ - market, - interval, - enabled: true, - }), - { - initialProps: { market: 'KRW-BTC', interval: '1m' }, - } - ); - - // Change market - rerender({ market: 'KRW-ETH', interval: '1m' }); - - await waitFor(() => { - // Should resubscribe with new market - expect(marketSocket.subscribe).toHaveBeenCalledWith( - 'KRW-ETH', - '1m', - expect.any(Function) - ); - }); - }); -}); - -describe('Indicator Recalculation', () => { - it('should trigger indicator recalculation on candle update', async () => { - // This test would require mocking the full CandlestickTestPage component - // For now, we verify the hook behavior - const onUpdate = jest.fn(); - - const { result } = renderHook(() => - useRealtimeCandles({ - market: 'KRW-BTC', - interval: '1m', - enabled: true, - onUpdate, - }) - ); - - const mockUpdate = { - market: 'KRW-BTC', - interval: '1m', - candles: [ - { time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 }, - ], - timestamp: Date.now(), - type: 'update' as const, - }; - - const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0]; - const callback = subscribeCall[2]; - - act(() => { - callback(mockUpdate); - }); - - await waitFor(() => { - expect(onUpdate).toHaveBeenCalled(); - expect(onUpdate).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - time: '2026-03-07T01:00', - close: 105, - }), - ]) - ); - }); - }); -}); diff --git a/frontend_golden/src/components/AIChatPanel.tsx b/frontend_golden/src/components/AIChatPanel.tsx deleted file mode 100644 index fdfc5c8..0000000 --- a/frontend_golden/src/components/AIChatPanel.tsx +++ /dev/null @@ -1,1225 +0,0 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { - Dialog, - Box, - Typography, - TextField, - IconButton, - Avatar, - Paper, - CircularProgress, - Chip, - useTheme, - Tooltip, - Divider, -} from '@mui/material'; -import { - Send as SendIcon, - Close as CloseIcon, - SmartToy as AIIcon, - Person as PersonIcon, - Delete as DeleteIcon, - ContentCopy as CopyIcon, - AutoAwesome as SparkleIcon, - Build as ToolIcon, - Mic as MicIcon, - MicOff as MicOffIcon, - QuestionAnswer as QuestionIcon, - DragIndicator as DragIcon, -} from '@mui/icons-material'; -import { Menu, MenuItem, ListSubheader } from '@mui/material'; - -// Web Speech API 타입 정의 -interface SpeechRecognitionEvent extends Event { - results: SpeechRecognitionResultList; - resultIndex: number; -} - -interface SpeechRecognitionResultList { - length: number; - item(index: number): SpeechRecognitionResult; - [index: number]: SpeechRecognitionResult; -} - -interface SpeechRecognitionResult { - length: number; - item(index: number): SpeechRecognitionAlternative; - [index: number]: SpeechRecognitionAlternative; - isFinal: boolean; -} - -interface SpeechRecognitionAlternative { - transcript: string; - confidence: number; -} - -interface SpeechRecognition extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - maxAlternatives: number; - start(): void; - stop(): void; - abort(): void; - onresult: ((event: SpeechRecognitionEvent) => void) | null; - onerror: ((event: Event & { error: string }) => void) | null; - onend: (() => void) | null; - onstart: (() => void) | null; - onaudiostart: (() => void) | null; - onsoundstart: (() => void) | null; - onspeechstart: (() => void) | null; -} - -declare global { - interface Window { - SpeechRecognition: new () => SpeechRecognition; - webkitSpeechRecognition: new () => SpeechRecognition; - } -} - -interface ChatMessage { - id: string; - role: 'user' | 'assistant'; - content: string; - timestamp: Date; - isStreaming?: boolean; -} - -// UI 조작 명령 타입 -export interface UICommand { - action: string; - params: Record; -} - -interface AIChatPanelProps { - open: boolean; - onClose: () => void; - symbol: string; - timeInterval: string; - onUICommand?: (command: UICommand) => void; - dateRangeStart?: string; // 현재 화면에 표시된 시작 날짜 - dateRangeEnd?: string; // 현재 화면에 표시된 종료 날짜 -} - -const DEFAULT_WIDTH = 450; -const MIN_WIDTH = 350; -const MAX_WIDTH = 800; - -// UI 명령 마커 -const UI_COMMAND_PREFIX = '[[UI_COMMAND]]'; -const UI_COMMAND_SUFFIX = '[[/UI_COMMAND]]'; - -// 예시 문장 목록 (카테고리별) -const EXAMPLE_PROMPTS = { - 'MACD 분석': [ - 'MACD 골든크로스 지점을 차트에 표시해줘', - 'MACD 데드크로스 지점을 차트에 표시해줘', - ], - '이동평균선 분석': [ - '5일선과 20일선 골든크로스 표시해줘', - '5일선과 20일선 데드크로스 표시해줘', - '정배열 전환 시점 찾아줘', - '역배열 전환 시점 찾아줘', - ], - 'RSI 분석': [ - 'RSI 과매도 구간을 표시해줘', - 'RSI 과매수 구간을 표시해줘', - ], - '스토캐스틱 분석': [ - '스토캐스틱 골든크로스 표시해줘', - '스토캐스틱 데드크로스 표시해줘', - ], - '볼린저밴드 분석': [ - '볼린저밴드 하단 반등 지점 찾아줘', - '볼린저밴드 스퀴즈 구간 표시해줘', - ], - '추세 및 패턴': [ - 'ADX 강한 추세 구간 찾아줘', - '갭 상승 지점 표시해줘', - '갭 하락 지점 표시해줘', - '최근 30일 최고가와 최저가 표시해줘', - ], - '지지/저항': [ - '주요 지지선과 저항선 찾아줘', - ], - '복합 신호': [ - '강한 매수 신호 찾아줘 (MACD+RSI+Stochastic)', - '거래량 급증 지점을 표시해줘', - ], - '백테스트 실행': [ - '이동평균선 10일, 20일 골든크로스 시점에 매수, 데드크로스 시점에 매도 실행해줘', - '이동평균선 5일, 20일 골든크로스/데드크로스로 투자 실행해줘', - '이동평균선 20일, 60일 골든크로스 매수, 데드크로스 매도 실행', - ], - '종합 분석': [ - '비트코인 분석해줘', - '이더리움 분석해줘', - '현재 시장 상황 알려줘', - ], -}; - -const AIChatPanel: React.FC = ({ - open, - onClose, - symbol, - timeInterval, - onUICommand, - dateRangeStart, - dateRangeEnd, -}) => { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [dialogWidth, setDialogWidth] = useState(DEFAULT_WIDTH); - const [isResizing, setIsResizing] = useState(false); - const [isListening, setIsListening] = useState(false); - const [speechSupported, setSpeechSupported] = useState(false); - const [interimTranscript, setInterimTranscript] = useState(''); - const [pendingVoiceMessage, setPendingVoiceMessage] = useState(null); - const [exampleMenuAnchor, setExampleMenuAnchor] = useState(null); - - // 드래그 위치 상태 - const [position, setPosition] = useState({ x: window.innerWidth - DEFAULT_WIDTH - 20, y: 80 }); - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const recognitionRef = useRef(null); - const dialogRef = useRef(null); - - // UI 명령 파싱 및 실행 - const parseAndExecuteUICommands = useCallback((content: string) => { - if (!onUICommand) { - console.warn('⚠️ onUICommand가 정의되지 않음'); - return; - } - - console.log('🔍 UI 명령 파싱 시작, 컨텐츠 길이:', content.length); - - const regex = new RegExp(`${UI_COMMAND_PREFIX.replace(/[[\]]/g, '\\$&')}(.+?)${UI_COMMAND_SUFFIX.replace(/[[\]]/g, '\\$&')}`, 'g'); - let match; - let commandCount = 0; - - while ((match = regex.exec(content)) !== null) { - commandCount++; - try { - console.log('📦 UI 명령 발견:', match[1].substring(0, 100)); - const command = JSON.parse(match[1]) as UICommand; - console.log('✅ UI 명령 파싱 성공:', command.action, 'params:', Object.keys(command.params)); - onUICommand(command); - } catch (e) { - console.error('❌ UI 명령 파싱 실패:', e, 'Raw:', match[1].substring(0, 100)); - } - } - - if (commandCount === 0) { - console.log('⚠️ UI 명령을 찾지 못함'); - } else { - console.log(`✅ 총 ${commandCount}개의 UI 명령 실행 완료`); - } - }, [onUICommand]); - - // 메시지에서 UI 명령 마커 제거 - const removeUICommandMarkers = (content: string): string => { - const regex = new RegExp(`${UI_COMMAND_PREFIX.replace(/[[\]]/g, '\\$&')}.*?${UI_COMMAND_SUFFIX.replace(/[[\]]/g, '\\$&')}`, 'g'); - return content.replace(regex, '').trim(); - }; - - // 음성인식 에러 메시지 상태 - const [speechError, setSpeechError] = useState(null); - - // 음성인식 초기화 - useEffect(() => { - const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition; - console.log('SpeechRecognition API:', SpeechRecognitionAPI ? 'supported' : 'not supported'); - - if (SpeechRecognitionAPI) { - setSpeechSupported(true); - const recognition = new SpeechRecognitionAPI(); - recognition.continuous = true; // 연속 인식 모드 - recognition.interimResults = true; - recognition.lang = 'ko-KR'; // 한국어 설정 - recognition.maxAlternatives = 1; - - recognition.onstart = () => { - console.log('Speech recognition started'); - setIsListening(true); - setInterimTranscript(''); - setSpeechError(null); - }; - - recognition.onresult = (event: SpeechRecognitionEvent) => { - console.log('Speech recognition result:', event.results); - let finalTranscript = ''; - let interim = ''; - - for (let i = event.resultIndex; i < event.results.length; i++) { - const transcript = event.results[i][0].transcript; - console.log(`Result ${i}: "${transcript}", isFinal: ${event.results[i].isFinal}`); - if (event.results[i].isFinal) { - finalTranscript += transcript; - } else { - interim += transcript; - } - } - - if (finalTranscript) { - console.log('Final transcript:', finalTranscript); - // 음성 인식 완료 - 자동 전송을 위해 pendingVoiceMessage 설정 - setInputValue(prev => { - const newValue = prev + finalTranscript; - // 자동 전송 트리거 - setPendingVoiceMessage(newValue); - return newValue; - }); - setInterimTranscript(''); - // 음성인식 중지 (자동 전송 전) - if (recognitionRef.current) { - recognitionRef.current.stop(); - } - } else if (interim) { - console.log('Interim transcript:', interim); - setInterimTranscript(interim); - } - }; - - recognition.onerror = (event) => { - console.error('Speech recognition error:', event.error); - setIsListening(false); - setInterimTranscript(''); - - // 에러 메시지 설정 - const errorMessages: Record = { - 'not-allowed': '마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크를 허용해주세요.', - 'no-speech': '음성이 감지되지 않았습니다. 다시 시도해주세요.', - 'audio-capture': '마이크를 찾을 수 없습니다.', - 'network': '네트워크 오류가 발생했습니다.', - 'aborted': '음성 인식이 중단되었습니다.', - 'service-not-allowed': '음성인식 서비스를 사용할 수 없습니다.', - }; - setSpeechError(errorMessages[event.error] || `오류: ${event.error}`); - }; - - recognition.onend = () => { - console.log('Speech recognition ended'); - setIsListening(false); - // 음성 인식 종료 후 입력란에 포커스 - setTimeout(() => inputRef.current?.focus(), 100); - }; - - recognition.onaudiostart = () => { - console.log('Audio capturing started'); - }; - - recognition.onsoundstart = () => { - console.log('Sound detected'); - }; - - recognition.onspeechstart = () => { - console.log('Speech started'); - }; - - recognitionRef.current = recognition; - } else { - console.log('Speech recognition not supported'); - setSpeechSupported(false); - } - - return () => { - if (recognitionRef.current) { - recognitionRef.current.abort(); - } - }; - }, []); - - // 드래그 핸들러 - const handleMouseDown = useCallback((e: React.MouseEvent) => { - setIsDragging(true); - setDragStart({ - x: e.clientX - position.x, - y: e.clientY - position.y, - }); - }, [position]); - - const handleMouseMove = useCallback((e: MouseEvent) => { - if (!isDragging) return; - - const newX = e.clientX - dragStart.x; - const newY = e.clientY - dragStart.y; - - // 화면 경계 체크 - const maxX = window.innerWidth - dialogWidth; - const maxY = window.innerHeight - 200; // 최소 높이 확보 - - setPosition({ - x: Math.max(0, Math.min(newX, maxX)), - y: Math.max(0, Math.min(newY, maxY)), - }); - }, [isDragging, dragStart, dialogWidth]); - - const handleMouseUp = useCallback(() => { - setIsDragging(false); - }, []); - - // 드래그 이벤트 리스너 등록 - useEffect(() => { - if (isDragging) { - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - } - }, [isDragging, handleMouseMove, handleMouseUp]); - - // 예시 문장 메뉴 열기 - const handleExampleMenuOpen = useCallback((event: React.MouseEvent) => { - setExampleMenuAnchor(event.currentTarget); - }, []); - - // 예시 문장 메뉴 닫기 - const handleExampleMenuClose = useCallback(() => { - setExampleMenuAnchor(null); - }, []); - - // 예시 문장 선택 - const handleExampleSelect = useCallback((example: string) => { - setInputValue(example); - setExampleMenuAnchor(null); - // 입력란에 포커스 - setTimeout(() => inputRef.current?.focus(), 100); - }, []); - - // 음성인식 토글 - const toggleListening = useCallback(async () => { - if (!recognitionRef.current) { - console.error('Speech recognition not initialized'); - setSpeechError('음성인식이 초기화되지 않았습니다. 페이지를 새로고침해주세요.'); - return; - } - - if (isListening) { - console.log('Stopping speech recognition'); - recognitionRef.current.stop(); - } else { - // HTTPS 환경 체크 (localhost는 예외) - const isSecure = window.location.protocol === 'https:' || - window.location.hostname === 'localhost' || - window.location.hostname === '127.0.0.1'; - - if (!isSecure) { - console.error('Insecure context:', window.location.href); - setSpeechError(`음성인식은 HTTPS 환경에서만 사용 가능합니다. 현재: ${window.location.protocol}//${window.location.hostname}`); - return; - } - - // navigator.mediaDevices 지원 확인 - if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { - console.error('MediaDevices API not supported'); - setSpeechError('이 브라우저는 마이크 접근을 지원하지 않습니다.'); - return; - } - - // 먼저 마이크 권한 요청 - try { - console.log('Requesting microphone permission...'); - console.log('Current URL:', window.location.href); - - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - console.log('Microphone permission granted'); - // 권한 확인 후 스트림 해제 - stream.getTracks().forEach(track => track.stop()); - - console.log('Starting speech recognition...'); - setSpeechError(null); - recognitionRef.current.start(); - } catch (error: any) { - console.error('Microphone permission denied:', error); - - // 에러 유형에 따른 상세 메시지 - let errorMessage = '마이크 권한이 필요합니다.'; - if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { - errorMessage = '마이크 권한이 거부되었습니다. 브라우저 주소창 왼쪽의 자물쇠/사이트 설정 아이콘을 클릭하여 마이크를 허용해주세요.'; - } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { - errorMessage = '마이크를 찾을 수 없습니다. 마이크가 연결되어 있는지 확인해주세요.'; - } else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') { - errorMessage = '마이크가 다른 앱에서 사용 중입니다. 다른 앱을 종료하고 다시 시도해주세요.'; - } else if (error.name === 'OverconstrainedError') { - errorMessage = '요청한 마이크 설정을 지원하지 않습니다.'; - } else if (error.name === 'TypeError') { - errorMessage = '보안 연결(HTTPS)이 필요합니다.'; - } else { - errorMessage = `마이크 오류: ${error.name || error.message || '알 수 없는 오류'}`; - } - - setSpeechError(errorMessage); - } - } - }, [isListening]); - - const abortControllerRef = useRef(null); - - // 리사이즈 시작 - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); // 드래그 시작 방지 - setIsResizing(true); - }, []); - - // 리사이즈 중 - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!isResizing) return; - - const dialogRect = dialogRef.current?.getBoundingClientRect(); - if (!dialogRect) return; - - // 현재 Dialog 위치에서의 너비 계산 - const newWidth = dialogRect.right - e.clientX; - - // 최소/최대 범위 제한 - if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { - setDialogWidth(newWidth); - } - }; - - const handleMouseUp = () => { - setIsResizing(false); - }; - - if (isResizing) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - // 드래그 중 텍스트 선택 방지 - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'ew-resize'; - } - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - }; - }, [isResizing]); - - // 스크롤 맨 아래로 - const scrollToBottom = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, []); - - useEffect(() => { - scrollToBottom(); - }, [messages, scrollToBottom]); - - // 패널 열릴 때 입력창 포커스 - useEffect(() => { - if (open) { - setTimeout(() => inputRef.current?.focus(), 100); - } - }, [open]); - - // 메시지 전송 (AI Agent 스트리밍) - const sendMessage = useCallback(async () => { - if (!inputValue.trim() || isLoading) return; - - const userMessage: ChatMessage = { - id: Date.now().toString(), - role: 'user', - content: inputValue.trim(), - timestamp: new Date(), - }; - - setMessages(prev => [...prev, userMessage]); - setInputValue(''); - setIsLoading(true); - - // 어시스턴트 메시지 플레이스홀더 - const assistantMessageId = (Date.now() + 1).toString(); - const assistantMessage: ChatMessage = { - id: assistantMessageId, - role: 'assistant', - content: '', - timestamp: new Date(), - isStreaming: true, - }; - setMessages(prev => [...prev, assistantMessage]); - - try { - abortControllerRef.current = new AbortController(); - - // AI Agent API 호출 (스트리밍) - const response = await fetch('/api/agent/chat/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - message: userMessage.content, - symbol: symbol, - dateRangeStart: dateRangeStart, // 현재 화면 시작 날짜 - dateRangeEnd: dateRangeEnd, // 현재 화면 종료 날짜 - }), - signal: abortControllerRef.current.signal, - }); - - if (!response.ok) { - throw new Error(`서버 응답 오류: ${response.status}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('스트림을 읽을 수 없습니다.'); - } - - const decoder = new TextDecoder(); - let accumulatedContent = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') { - break; - } - if (data.startsWith('{') && data.includes('error')) { - try { - const errorObj = JSON.parse(data); - throw new Error(errorObj.error); - } catch { - // 파싱 실패시 무시 - } - } - // 이스케이프된 개행문자 복원 - accumulatedContent += data.replace(/\\n/g, '\n'); - - // 메시지 업데이트 - setMessages(prev => prev.map(msg => - msg.id === assistantMessageId - ? { ...msg, content: accumulatedContent } - : msg - )); - } - } - } - - // 스트리밍 완료 - UI 명령 파싱 및 실행 - setMessages(prev => { - const updatedMessages = prev.map(msg => { - if (msg.id === assistantMessageId) { - // UI 명령 파싱 및 실행 - parseAndExecuteUICommands(msg.content); - // 메시지에서 UI 명령 마커 제거 - const cleanContent = removeUICommandMarkers(msg.content); - return { ...msg, content: cleanContent, isStreaming: false }; - } - return msg; - }); - return updatedMessages; - }); - - } catch (error: any) { - if (error.name === 'AbortError') { - setMessages(prev => prev.map(msg => - msg.id === assistantMessageId - ? { ...msg, content: '응답이 취소되었습니다.', isStreaming: false } - : msg - )); - } else { - setMessages(prev => prev.map(msg => - msg.id === assistantMessageId - ? { ...msg, content: `오류가 발생했습니다: ${error.message}`, isStreaming: false } - : msg - )); - } - } finally { - setIsLoading(false); - abortControllerRef.current = null; - // 응답 완료 후 입력란에 포커스 - setTimeout(() => inputRef.current?.focus(), 100); - } - }, [inputValue, isLoading, symbol, dateRangeStart, dateRangeEnd]); - - // 음성 인식 완료 후 자동 전송 - useEffect(() => { - if (pendingVoiceMessage && pendingVoiceMessage.trim() && !isLoading) { - console.log('Auto-sending voice message:', pendingVoiceMessage); - // 약간의 딜레이 후 전송 (UI 업데이트 확인용) - const timer = setTimeout(() => { - sendMessage(); - setPendingVoiceMessage(null); - }, 500); - return () => clearTimeout(timer); - } - }, [pendingVoiceMessage, isLoading, sendMessage]); - - // 키 입력 핸들러 - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - }; - - // 대화 초기화 - const clearMessages = () => { - setMessages([]); - }; - - // 메시지 복사 - const copyMessage = (content: string) => { - navigator.clipboard.writeText(content); - }; - - // 현재 스트리밍 취소 - const cancelStreaming = () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }; - - // 마크다운 스타일 렌더링 (간단한 버전) - const renderMarkdown = (content: string) => { - // 도구 호출 표시 - const toolCallPattern = /🔧 \*\*(.+?)\*\* 도구 호출 중\.\.\./g; - let parts = content.split(toolCallPattern); - - return ( - - {content.split('\n').map((line, idx) => { - // 제목 (##) - if (line.startsWith('## ')) { - return ( - - {line.slice(3)} - - ); - } - // 소제목 (###) - if (line.startsWith('### ')) { - return ( - - {line.slice(4)} - - ); - } - // 도구 호출 표시 - if (line.includes('🔧') && line.includes('도구 호출')) { - return ( - } - label={line.replace('🔧 **', '').replace('** 도구 호출 중...', '')} - size="small" - color="info" - sx={{ my: 0.5 }} - /> - ); - } - // 리스트 항목 - if (line.startsWith('- ')) { - return ( - - • {line.slice(2)} - - ); - } - // 테이블 (간단히 처리) - if (line.startsWith('|')) { - return ( - - {line} - - ); - } - // 일반 텍스트 - if (line.trim()) { - return ( - - {line} - - ); - } - return null; - })} - - ); - }; - - return ( - - {/* 리사이즈 핸들 (왼쪽 가장자리) */} - - - {/* 헤더 (드래그 가능) */} - - - - - - AI 트레이딩 어시스턴트 - - - - - - - - - - - - - - - - - - {/* 안내 메시지 */} - {messages.length === 0 && ( - - - - AI 트레이딩 어시스턴트 - - - 질문하시면 AI가 업비트 전체 시장 정보를 조회하여 분석합니다. -
- 특정 종목을 언급하면 해당 종목을 분석합니다. -
- - - 🔧 사용 가능한 기능: - - - • 시장 전체 상황 및 상승 종목 조회
- • 실시간 시세/호가 조회 (업비트 API)
- • 차트 분석 (MACD, RSI, 볼린저밴드 등)
- • 과거 데이터 조회 (DB)
- • 저장된 전략/보조지표 조회
- • 백테스트 및 패턴 검색 -
-
- - - 예시 질문: - - - {[ - '지금 시장 상황이 어때?', - '상승 잠재력 높은 종목 추천해줘', - '비트코인 차트 분석해줘', - '이더리움 현재가 알려줘', - '저장된 전략 목록 보여줘', - '보조지표 종류 알려줘', - ].map((example, idx) => ( - { - setInputValue(example); - setTimeout(() => inputRef.current?.focus(), 100); - }} - sx={{ - cursor: 'pointer', - '&:hover': { bgcolor: 'action.hover' }, - }} - /> - ))} - -
- )} - - {/* 메시지 목록 */} - - {messages.map((message) => ( - - - {message.role === 'user' ? : } - - - {message.role === 'user' ? ( - - {message.content} - - ) : ( - <> - {message.content ? ( - renderMarkdown(message.content) - ) : ( - message.isStreaming && - )} - - )} - - {message.isStreaming && message.content && ( - - - - 분석 중... - - - )} - - {!message.isStreaming && message.role === 'assistant' && message.content && ( - copyMessage(message.content)} - sx={{ - position: 'absolute', - top: 4, - right: 4, - opacity: 0.5, - '&:hover': { opacity: 1 }, - }} - > - - - )} - - - ))} -
- - - {/* 입력 영역 */} - - {isLoading && ( - - - - )} - {/* 음성인식 에러 메시지 */} - {speechError && ( - - - ⚠️ {speechError} - - - )} - {/* 음성 인식 중 상태 표시 */} - {isListening && !interimTranscript && ( - - - - 🎤 마이크가 활성화되었습니다. 말씀해주세요... - - - )} - {/* 음성 인식 중 임시 텍스트 표시 */} - {isListening && interimTranscript && ( - - - 🎤 "{interimTranscript}" - - - )} - - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - disabled={isLoading} - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: 3, - ...(isListening && { - borderColor: 'warning.main', - '& fieldset': { borderColor: 'warning.main' }, - }), - }, - }} - /> - {/* 예시 문장 선택 버튼 */} - - - - - - {/* 음성인식 버튼 */} - {speechSupported && ( - - - {isListening ? : } - - - )} - - - - - - {speechSupported - ? "🎤 마이크 버튼으로 음성 질문 가능 | AI 분석은 참고용이며, 투자 결정은 본인 책임입니다." - : "AI 분석은 참고용이며, 투자 결정은 본인 책임입니다." - } - - - - {/* 예시 문장 선택 메뉴 */} - - {Object.entries(EXAMPLE_PROMPTS).map(([category, examples]) => [ - - {category} - , - ...examples.map((example, index) => ( - handleExampleSelect(example)} - sx={{ - fontSize: '0.875rem', - py: 1, - px: 2, - whiteSpace: 'normal', - '&:hover': { - bgcolor: isDark ? 'grey.800' : 'grey.100', - }, - }} - > - {example} - - )), - ])} - -
- ); -}; - -export default AIChatPanel; diff --git a/frontend_golden/src/components/BollingerSettingsDialog.tsx b/frontend_golden/src/components/BollingerSettingsDialog.tsx deleted file mode 100644 index 43a3e76..0000000 --- a/frontend_golden/src/components/BollingerSettingsDialog.tsx +++ /dev/null @@ -1,597 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogTitle, - DialogContent, - Button, - Box, - Typography, - TextField, - Switch, - Slider, - Divider, - Paper, - Grid, - IconButton, - Tooltip, - Alert, - Select, - MenuItem, - FormControl, -} from '@mui/material'; -import { Close, Refresh } from '@mui/icons-material'; -import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext'; -import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext'; -import type { MultiChartBollingerState } from '../stores/useDashboardStore'; -import { - bollingerDialogDefaultsFromStore, - bollingerDialogToStore, - bollingerStoreToDialogColors, - bollingerStoreToDialogSettings, -} from '../utils/multiChartToolbarSettings'; -import DraggablePaper from './DraggablePaper'; - -interface BollingerSettingsDialogProps { - open: boolean; - onClose: () => void; - multiChartToolbar?: { - bollinger: MultiChartBollingerState; - setBollinger: ( - value: MultiChartBollingerState | ((prev: MultiChartBollingerState) => MultiChartBollingerState) - ) => void; - }; -} - -const BollingerSettingsDialog: React.FC = ({ open, onClose, multiChartToolbar }) => { - const { settings, updateSettings } = useIndicatorVisibility(); - const { colors, updateColors } = useChartColors(); - - // 로컬 상태 (볼린저 밴드 관련 설정만 포함) - const [localSettings, setLocalSettings] = useState<{ - bollingerUpper: boolean; - bollingerMiddle: boolean; - bollingerLower: boolean; - bollingerPeriod: number; - bollingerStdDev: number; - }>({ - bollingerUpper: settings.bollingerUpper, - bollingerMiddle: settings.bollingerMiddle, - bollingerLower: settings.bollingerLower, - bollingerPeriod: settings.bollingerPeriod || 20, - bollingerStdDev: settings.bollingerStdDev || 2.0, - }); - - const [localColors, setLocalColors] = useState({ - bollingerUpperColor: colors.bollingerUpperColor, - bollingerMiddleColor: colors.bollingerMiddleColor, - bollingerLowerColor: colors.bollingerLowerColor, - bollingerUpperWidth: colors.bollingerUpperWidth, - bollingerMiddleWidth: colors.bollingerMiddleWidth, - bollingerLowerWidth: colors.bollingerLowerWidth, - bollingerUpperLineStyle: colors.bollingerUpperLineStyle, - bollingerMiddleLineStyle: colors.bollingerMiddleLineStyle, - bollingerLowerLineStyle: colors.bollingerLowerLineStyle, - bollingerBandBackgroundColor: colors.bollingerBandBackgroundColor, - }); - - // 배경 투명도 상태 - const [bandOpacity, setBandOpacity] = useState(0.15); // ✅ 기본값 증가 (더 잘 보이도록) - - // rgba에서 alpha 값 추출 함수 - const extractOpacity = (color: string): number => { - const match = color.match(/rgba?\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/); - return match ? parseFloat(match[1]) : 0.1; - }; - - // rgba에서 rgb hex 추출 함수 - const extractRgbFromRgba = (rgba: string): string => { - const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - if (match) { - const r = parseInt(match[1]).toString(16).padStart(2, '0'); - const g = parseInt(match[2]).toString(16).padStart(2, '0'); - const b = parseInt(match[3]).toString(16).padStart(2, '0'); - return `#${r}${g}${b}`; - } - return '#000000'; - }; - - // hex to rgba 변환 함수 - const hexToRgba = (hex: string, opacity: number): string => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - if (result) { - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - return `rgba(${r}, ${g}, ${b}, ${opacity})`; - } - return `rgba(0, 0, 0, ${opacity})`; - }; - - useEffect(() => { - if (!open || !multiChartToolbar) return; - const b = multiChartToolbar.bollinger; - setLocalSettings(bollingerStoreToDialogSettings(b)); - setLocalColors(bollingerStoreToDialogColors(b)); - setBandOpacity(extractOpacity(b.bandBackgroundColor)); - }, [open, multiChartToolbar]); - - useEffect(() => { - if (!open || multiChartToolbar) return; - setLocalSettings({ - bollingerUpper: settings.bollingerUpper, - bollingerMiddle: settings.bollingerMiddle, - bollingerLower: settings.bollingerLower, - bollingerPeriod: settings.bollingerPeriod || 20, - bollingerStdDev: settings.bollingerStdDev || 2.0, - }); - setLocalColors({ - bollingerUpperColor: colors.bollingerUpperColor, - bollingerMiddleColor: colors.bollingerMiddleColor, - bollingerLowerColor: colors.bollingerLowerColor, - bollingerUpperWidth: colors.bollingerUpperWidth, - bollingerMiddleWidth: colors.bollingerMiddleWidth, - bollingerLowerWidth: colors.bollingerLowerWidth, - bollingerBandBackgroundColor: colors.bollingerBandBackgroundColor, - bollingerUpperLineStyle: colors.bollingerUpperLineStyle, - bollingerMiddleLineStyle: colors.bollingerMiddleLineStyle, - bollingerLowerLineStyle: colors.bollingerLowerLineStyle, - }); - setBandOpacity(extractOpacity(colors.bollingerBandBackgroundColor)); - }, [open, multiChartToolbar, settings, colors]); - - const handleSave = () => { - if (multiChartToolbar) { - multiChartToolbar.setBollinger(bollingerDialogToStore(localSettings, localColors)); - } else { - console.log('💾 [볼린저밴드 설정] 저장 시작:', { - 이전설정: { - period: settings.bollingerPeriod, - stdDev: settings.bollingerStdDev, - upper: settings.bollingerUpper, - middle: settings.bollingerMiddle, - lower: settings.bollingerLower, - }, - 새설정: localSettings, - }); - updateSettings({ - ...settings, - ...localSettings, - }); - updateColors(localColors); - console.log('✅ [볼린저밴드 설정] updateSettings 및 updateColors 호출 완료'); - } - onClose(); - }; - - const handleReset = () => { - if (multiChartToolbar) { - const { localSettings: ls, localColors: lc } = bollingerDialogDefaultsFromStore(); - setLocalSettings(ls); - setLocalColors(lc); - setBandOpacity(extractOpacity(lc.bollingerBandBackgroundColor)); - } else { - setLocalSettings({ - bollingerUpper: false, - bollingerMiddle: false, - bollingerLower: false, - bollingerPeriod: 20, - bollingerStdDev: 2.0, - }); - setLocalColors({ - bollingerUpperColor: '#FF9800', - bollingerMiddleColor: '#2196F3', - bollingerLowerColor: '#4CAF50', - bollingerUpperWidth: 1.5, - bollingerMiddleWidth: 1.5, - bollingerLowerWidth: 1.5, - bollingerUpperLineStyle: 'solid' as const, - bollingerMiddleLineStyle: 'solid' as const, - bollingerLowerLineStyle: 'solid' as const, - bollingerBandBackgroundColor: 'rgba(33, 150, 243, 0.15)', - }); - setBandOpacity(0.15); - } - }; - - const handlePeriodChange = (value: number) => { - setLocalSettings({ ...localSettings, bollingerPeriod: value }); - }; - - const handleStdDevChange = (value: number) => { - setLocalSettings({ ...localSettings, bollingerStdDev: value }); - }; - - // 색상 테이블 컴포넌트 - const ColorTable = () => { - const configs: Array<{ - label: string; - enableKey: 'bollingerUpper' | 'bollingerMiddle' | 'bollingerLower'; - colorKey: 'bollingerUpperColor' | 'bollingerMiddleColor' | 'bollingerLowerColor'; - widthKey: 'bollingerUpperWidth' | 'bollingerMiddleWidth' | 'bollingerLowerWidth'; - lineStyleKey: 'bollingerUpperLineStyle' | 'bollingerMiddleLineStyle' | 'bollingerLowerLineStyle'; - }> = [ - { - label: '상한선', - enableKey: 'bollingerUpper', - colorKey: 'bollingerUpperColor', - widthKey: 'bollingerUpperWidth', - lineStyleKey: 'bollingerUpperLineStyle' - }, - { - label: '중간선', - enableKey: 'bollingerMiddle', - colorKey: 'bollingerMiddleColor', - widthKey: 'bollingerMiddleWidth', - lineStyleKey: 'bollingerMiddleLineStyle' - }, - { - label: '하한선', - enableKey: 'bollingerLower', - colorKey: 'bollingerLowerColor', - widthKey: 'bollingerLowerWidth', - lineStyleKey: 'bollingerLowerLineStyle' - }, - ]; - - return ( - - - 🎨 색상, 선 굵기, 선 유형 - - {/* 헤더 행 */} - theme.palette.mode === 'dark' ? '#616161' : '#9e9e9e', - borderRadius: 1, - }}> - - - ON/OFF - - - 항목 - - - 색상 - - - 굵기 - - - 선 유형 - - - 미리보기 - - - - {/* 각 색상 설정 행 */} - {configs.map((cfg) => { - const colorValue = String(localColors[cfg.colorKey] || '#666666'); - const widthValue = Number(localColors[cfg.widthKey] ?? 1.5); - const lineStyleValue = String(localColors[cfg.lineStyleKey] ?? 'solid'); - const enabledValue = Boolean(localSettings[cfg.enableKey]); - - return ( - - - {/* ON/OFF 토글 */} - - setLocalSettings({ ...localSettings, [cfg.enableKey]: e.target.checked })} - size="small" - /> - - {/* 항목 이름 */} - - - {cfg.label} - - - {/* 색상 선택 */} - - setLocalColors({ ...localColors, [cfg.colorKey]: e.target.value })} - style={{ - width: '100%', - height: '30px', - border: '1px solid rgba(0, 0, 0, 0.23)', - borderRadius: '4px', - cursor: 'pointer', - backgroundColor: colorValue, - }} - /> - - {/* 굵기 슬라이더 */} - - setLocalColors({ ...localColors, [cfg.widthKey]: value as number })} - min={1} - max={5} - step={0.5} - size="small" - sx={{ flex: 1 }} - /> - - {widthValue}px - - - {/* 선 유형 */} - - - - - - {/* 미리보기 */} - - - - - - - - ); - })} - - ); - }; - - return ( - - - - - 볼린저 밴드 설정 - - - - - - - - e.stopPropagation()} - > - - - - - - - - 볼린저 밴드는 가격의 변동성을 측정하는 지표입니다. 상한선과 하한선은 표준편차를 이용하여 계산됩니다. - - - {/* 기간 설정 */} - - - 파라미터 설정 - - - - - 기간 (Period): {localSettings.bollingerPeriod}일 - - - handlePeriodChange(value as number)} - min={5} - max={50} - step={1} - marks={[ - { value: 10, label: '10' }, - { value: 20, label: '20' }, - { value: 30, label: '30' }, - { value: 40, label: '40' }, - ]} - valueLabelDisplay="auto" - sx={{ flex: 1 }} - /> - handlePeriodChange(Math.max(5, Math.min(50, Number(e.target.value))))} - size="small" - sx={{ width: 70 }} - inputProps={{ min: 5, max: 50 }} - /> - - - - - - 표준편차 승수 (StdDev): {localSettings.bollingerStdDev} - - - handleStdDevChange(value as number)} - min={1.0} - max={3.0} - step={0.1} - marks={[ - { value: 1.5, label: '1.5' }, - { value: 2.0, label: '2.0' }, - { value: 2.5, label: '2.5' }, - ]} - valueLabelDisplay="auto" - sx={{ flex: 1 }} - /> - handleStdDevChange(Math.max(1.0, Math.min(3.0, Number(e.target.value))))} - size="small" - sx={{ width: 70 }} - inputProps={{ min: 1.0, max: 3.0, step: 0.1 }} - /> - - - - - {/* 색상 및 선 설정 테이블 */} - - - {/* 배경색 설정 */} - - - 배경색 설정 - - - - - - 밴드 배경색 - - - - - { - const rgbaColor = hexToRgba(e.target.value, bandOpacity); - setLocalColors({ ...localColors, bollingerBandBackgroundColor: rgbaColor }); - }} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - opacity: 0, - cursor: 'pointer', - }} - /> - - - {localColors.bollingerBandBackgroundColor} - - - - - - - - - 투명도: {(bandOpacity * 100).toFixed(0)}% - - { - const newOpacity = value as number; - setBandOpacity(newOpacity); - const baseColor = extractRgbFromRgba(localColors.bollingerBandBackgroundColor); - const rgbaColor = hexToRgba(baseColor, newOpacity); - setLocalColors({ ...localColors, bollingerBandBackgroundColor: rgbaColor }); - }} - min={0} - max={1} - step={0.05} - valueLabelDisplay="auto" - valueLabelFormat={(value) => `${(value * 100).toFixed(0)}%`} - sx={{ width: '100%' }} - /> - - - - 상한선과 하한선 사이 영역을 반투명하게 표시합니다 - - - - - ); -}; - -export default BollingerSettingsDialog; diff --git a/frontend_golden/src/components/CandleSettingsPanel.tsx b/frontend_golden/src/components/CandleSettingsPanel.tsx deleted file mode 100644 index 813c7b3..0000000 --- a/frontend_golden/src/components/CandleSettingsPanel.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { useState } from 'react'; -import { - Box, - Typography, - Paper, - Grid, - Button, - Tooltip, - Slider, - Switch, - FormControlLabel, - Divider, -} from '@mui/material'; -import { Refresh } from '@mui/icons-material'; -import { useChartColors } from '../contexts/ChartColorContext'; -import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext'; - -// 색상 선택 팔레트 -const colorPalette = [ - '#EF5350', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4', - '#009688', '#4CAF50', '#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800', '#FF5722', - '#c62828', '#1565c0', '#2E7D32', '#F57C00', '#5D4037', '#455A64', '#000000', '#FFFFFF', -]; - -const CandleSettingsPanel: React.FC = () => { - const { colors, updateColors, resetColors } = useChartColors(); - const { settings, updateSettings } = useIndicatorVisibility(); - const [colorPickerTarget, setColorPickerTarget] = useState(null); - - // 색상 미리보기 박스 - const ColorBox: React.FC<{ colorKey: string; label: string }> = ({ colorKey, label }) => { - const color = (colors as any)[colorKey] || '#666'; - return ( - - - {label} - - - setColorPickerTarget(colorPickerTarget === colorKey ? null : colorKey)} - sx={{ - width: 40, - height: 40, - backgroundColor: color, - borderRadius: 1, - border: '2px solid', - borderColor: 'divider', - cursor: 'pointer', - '&:hover': { - boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.5)', - }, - }} - /> - - {/* 색상 팔레트 팝업 */} - {colorPickerTarget === colorKey && ( - - {colorPalette.map((c) => ( - { - updateColors({ [colorKey]: c }); - setColorPickerTarget(null); - }} - sx={{ - width: 22, - height: 22, - backgroundColor: c, - borderRadius: 0.5, - border: color === c ? '2px solid #1976d2' : '1px solid #ccc', - cursor: 'pointer', - '&:hover': { transform: 'scale(1.15)' }, - }} - /> - ))} - - { - updateColors({ [colorKey]: e.target.value }); - setColorPickerTarget(null); - }} - style={{ width: '100%', height: 28, cursor: 'pointer', border: 'none' }} - /> - - - )} - - - {color} - - - ); - }; - - const handleReset = () => { - updateColors({ - candleRising: '#c62828', - candleFalling: '#1565c0', - }); - }; - - return ( - - {/* 헤더 */} - - - 🕯️ 캔들차트 설정 - - - - - - - {/* 캔들 색상 설정 */} - - - 🎨 캔들 색상 - - - - - - - - - - 💡 미리보기 - - - {/* 상승 캔들 미리보기 */} - - - 상승 - - - {/* 하락 캔들 미리보기 */} - - - 하락 - - - - - - {/* 차트 표시 옵션 */} - - - 📊 차트 표시 옵션 - - - - updateSettings({ showGrid: e.target.checked })} - /> - } - label="그리드 표시" - /> - updateSettings({ tooltipEnabled: e.target.checked })} - /> - } - label="툴팁 표시" - /> - - - - {/* 안내 */} - - - 💡 캔들차트 읽기
- • 상승 캔들: 종가 > 시가 (가격 상승)
- • 하락 캔들: 종가 < 시가 (가격 하락)
- • 캔들 몸통: 시가~종가 범위
- • 꼬리(심지): 고가~저가 범위 -
-
-
- ); -}; - -export default CandleSettingsPanel; - diff --git a/frontend_golden/src/components/CandleTimeRemaining.tsx b/frontend_golden/src/components/CandleTimeRemaining.tsx deleted file mode 100644 index 5423ba6..0000000 --- a/frontend_golden/src/components/CandleTimeRemaining.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Box, Typography } from '@mui/material'; -import { Schedule } from '@mui/icons-material'; - -interface CandleTimeRemainingProps { - /** 마지막 캔들 시간 (ISO string or timestamp) */ - lastCandleTime: string | number; - /** 차트 interval (예: '1m', '5m', '1h', '1d') */ - interval: string; - /** 표시 여부 */ - visible?: boolean; - /** 차트 컨테이너 너비 */ - chartWidth?: number; -} - -/** - * interval 문자열을 밀리초로 변환 - * @param interval 예: '1m', '5m', '15m', '1h', '4h', '1d', '1w' - * @returns 밀리초 - */ -function intervalToMs(interval: string): number { - const match = interval.match(/^(\d+)([mhdw])$/); - if (!match) return 60000; // 기본값 1분 - - const value = parseInt(match[1]); - const unit = match[2]; - - switch (unit) { - case 'm': return value * 60 * 1000; // 분 - case 'h': return value * 60 * 60 * 1000; // 시간 - case 'd': return value * 24 * 60 * 60 * 1000; // 일 - case 'w': return value * 7 * 24 * 60 * 60 * 1000; // 주 - default: return 60000; - } -} - -/** - * 마지막 캔들 시간을 기준으로 다음 캔들 시작 시간 계산 - * @param lastCandleTime 마지막 캔들의 시작 시간 (timestamp) - * @param intervalMs interval 밀리초 - * @returns 다음 캔들 시작 시간 (timestamp) - */ -function getNextCandleTime(lastCandleTime: number, intervalMs: number): number { - // 마지막 캔들의 시작 시간을 interval 경계로 정렬 - const alignedLastTime = Math.floor(lastCandleTime / intervalMs) * intervalMs; - // 다음 캔들 시작 시간 = 마지막 캔들 시작 + interval - return alignedLastTime + intervalMs; -} - -/** - * 남은 시간을 포맷팅 (MM:SS 형식) - */ -function formatTimeRemaining(ms: number): string { - if (ms <= 0) return '00:00'; - - const totalSeconds = Math.floor(ms / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - - // 1시간 이상인 경우 HH:MM:SS - if (minutes >= 60) { - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - - // 1시간 미만인 경우 MM:SS - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; -} - -/** - * 업비트 스타일의 봉 완성 시간 표시 컴포넌트 - * 마지막 캔들 우측에 다음 캔들까지 남은 시간을 표시 - */ -const CandleTimeRemaining: React.FC = ({ - lastCandleTime, - interval, - visible = true, - chartWidth = 0, -}) => { - const [timeRemaining, setTimeRemaining] = useState(0); - const [isRefreshing, setIsRefreshing] = useState(false); - const prevLastCandleTimeRef = useRef(0); - const isRefreshingRef = useRef(false); - - // lastCandleTime 변경 감지 - 새로운 캔들이 추가되면 갱신 완료 - useEffect(() => { - const lastTime = typeof lastCandleTime === 'string' - ? new Date(lastCandleTime).getTime() - : lastCandleTime; - - // lastCandleTime이 실제로 변경되면 갱신 완료 - if (prevLastCandleTimeRef.current !== 0 && prevLastCandleTimeRef.current !== lastTime) { - console.log(`✅ [캔들 갱신 완료] 새 캔들 추가 감지: ${new Date(lastTime).toISOString()}`); - setIsRefreshing(false); - isRefreshingRef.current = false; - } - - prevLastCandleTimeRef.current = lastTime; - }, [lastCandleTime]); - - useEffect(() => { - if (!visible) return; - - const updateTimeRemaining = () => { - const now = Date.now(); - const intervalMs = intervalToMs(interval); - - // ✅ 마지막 캔들 시간 기준으로 다음 캔들 시작 시간 계산 - const lastTime = typeof lastCandleTime === 'string' - ? new Date(lastCandleTime).getTime() - : lastCandleTime; - - const nextCandleTime = getNextCandleTime(lastTime, intervalMs); - let remaining = nextCandleTime - now; - - // 음수가 되면 갱신 시작 - if (remaining < 0) { - if (!isRefreshingRef.current) { - console.log(`🔄 [캔들 갱신 시작] remaining < 0, 다음 캔들 대기 중...`); - setIsRefreshing(true); - isRefreshingRef.current = true; - } - // 다음 주기까지의 시간 계산 (갱신 중에도 시간 표시) - remaining = intervalMs + (remaining % intervalMs); - } - - const newTimeRemaining = Math.max(0, remaining); - setTimeRemaining(newTimeRemaining); - }; - - // 초기 업데이트 - updateTimeRemaining(); - - // 1초마다 업데이트 - const timer = setInterval(updateTimeRemaining, 1000); - - return () => clearInterval(timer); - }, [lastCandleTime, interval, visible]); - - // ✅ visible이 false일 때만 숨김 (timeRemaining === 0일 때도 계속 표시) - if (!visible) { - return null; - } - - return ( - - {/* 다음 캔들 생성 시간 - 아이콘과 텍스트 */} - - - - {isRefreshing ? '갱신중...' : '다음 봉'} - - - - {/* 남은 시간 카운트다운 - 바로 옆에 배치 */} - - - {formatTimeRemaining(timeRemaining)} - - - - ); -}; - -export default CandleTimeRemaining; diff --git a/frontend_golden/src/components/CandlestickChart.tsx b/frontend_golden/src/components/CandlestickChart.tsx deleted file mode 100644 index e8ab599..0000000 --- a/frontend_golden/src/components/CandlestickChart.tsx +++ /dev/null @@ -1,12498 +0,0 @@ -/* eslint-disable @typescript-eslint/no-redeclare */ -import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo, useCallback } from 'react'; -import { createChart, ColorType, IChartApi, ISeriesApi, Time } from 'lightweight-charts'; -import { Box, Typography, IconButton, CircularProgress, Chip, Slider, Dialog, DialogTitle, DialogContent, DialogActions, Button, Table, TableBody, TableRow, TableCell, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Tooltip } from '@mui/material'; -import { DragIndicator, VisibilityOff, Settings, Delete, MoreVert, Star, StarBorder, ContentCopy, ViewList, Schedule, PushPin, ZoomOut, ZoomIn, ChevronLeft, ChevronRight, RestartAlt, Fullscreen, FullscreenExit, Add, Remove, Close } from '@mui/icons-material'; -import { DndContext, closestCenter, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { CandleData, IndicatorData } from '../services/backtestApi'; -import { useAppTheme, ThemeMode } from '../contexts/ThemeContext'; -import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext'; -import { useChartColors } from '../contexts/ChartColorContext'; -import { useIndicatorSettings } from '../contexts/IndicatorSettingsContext'; -import { useUpbitData } from '../contexts/UpbitDataContext'; - -/** - * ✅ TrendIndicatorBar는 이제 별도 파일 (TrendIndicatorBar.tsx)에 있습니다 - * - * 아래는 CandlestickChart 컴포넌트입니다 - */ - -// ❌ 기존 TrendIndicatorBar 정의 제거됨 (별도 파일로 이동) -// 이제 TrendIndicatorBar.tsx에서 import됩니다 - -/** - * 선행스팬 ON 시 과거에는 서브 봉 수가 메인보다 적어 시간(setVisibleRange) 동기화가 필요했으나, - * placeholder·투명 범위 시리즈로 논리봉이 맞춰진 경우가 많다. 메인은 handleScroll 등에서 - * setVisibleLogicalRange로 이동하므로, 서브에 setVisibleRange만 적용하면 fixLeftEdge 등에서 - * 동일 시각이 픽셀상 어긋날 수 있다 → 가시 논리 범위가 있으면 항상 그것을 우선한다. - */ -/** - * 캔들+보조지표 세로 스택에서 우측 가격축 너비를 통일해야 timeToCoordinate·전역 크로스헤어 X가 패널 간 일치한다. - * (메인 100 vs CCI 112 등이면 동일 시각이 픽셀상 좌우로 어긋남 — 일목 선행 시 더 두드러짐) - */ -function stackedPanelsRightPriceScaleMinWidth( - subPanels: Array<{ enabled?: boolean; type: string }> | undefined, - isMobile: boolean -): number { - if (isMobile) return 40; - const enabled = subPanels?.filter((p) => p.enabled) ?? []; - let w = 100; - for (const p of enabled) { - if (p.type === 'cci') w = Math.max(w, 112); - else if (p.type === 'dmi') w = Math.max(w, 128); - } - return w; -} - -function applyMainVisibleToSubCharts( - mainTimeScale: ReturnType, - subCharts: Record, - useTimeRangeSync?: boolean -): void { - const useTimeFallback = !!useTimeRangeSync; - const logicalRange = mainTimeScale.getVisibleLogicalRange(); - const visibleTime = mainTimeScale.getVisibleRange(); - - Object.values(subCharts).forEach((subChart) => { - if (!subChart) return; - try { - if (logicalRange) { - subChart.timeScale().setVisibleLogicalRange(logicalRange); - return; - } - if ( - useTimeFallback && - visibleTime && - visibleTime.from != null && - visibleTime.to != null - ) { - subChart.timeScale().setVisibleRange({ - from: visibleTime.from as Time, - to: visibleTime.to as Time, - }); - } - } catch { - // ignore - } - }); -} - -/** - * 일목균형표 판단 로직 - * - * @param currentPrice 현재 종가 - * @param tenkan 전환선 값 - * @param kijun 기준선 값 - * @param senkouA 선행스팬1 값 (현재 시점의 26일 미래) - * @param senkouB 선행스팬2 값 (현재 시점의 26일 미래) - * @param chikou 후행스팬 값 (26일 전 가격) - * @returns 시장 분석 결과 - */ -interface IchimokuAnalysis { - cloudPosition: '구름 위' | '구름 내부' | '구름 아래' | '불명'; - tkCross: '골든크로스' | '데드크로스' | '중립'; - chikouTrend: '상승 추세' | '하락 추세' | '중립'; - cloudThickness: number; - cloudType: '상승 구름' | '하락 구름' | '불명'; - signal: '강한 매수' | '매수' | '중립' | '매도' | '강한 매도'; -} - -function analyzeIchimoku( - currentPrice: number, - tenkan?: number, - kijun?: number, - senkouA?: number, - senkouB?: number, - chikou?: number, - previousPrice?: number -): IchimokuAnalysis { - const analysis: IchimokuAnalysis = { - cloudPosition: '불명', - tkCross: '중립', - chikouTrend: '중립', - cloudThickness: 0, - cloudType: '불명', - signal: '중립' - }; - - // 1. 구름 위치 판단 - if (senkouA !== undefined && senkouB !== undefined) { - const cloudTop = Math.max(senkouA, senkouB); - const cloudBottom = Math.min(senkouA, senkouB); - - analysis.cloudThickness = Math.abs(senkouA - senkouB); - analysis.cloudType = senkouA >= senkouB ? '상승 구름' : '하락 구름'; - - if (currentPrice > cloudTop) { - analysis.cloudPosition = '구름 위'; - } else if (currentPrice < cloudBottom) { - analysis.cloudPosition = '구름 아래'; - } else { - analysis.cloudPosition = '구름 내부'; - } - } - - // 2. 전환선 vs 기준선 (TK Cross) - if (tenkan !== undefined && kijun !== undefined) { - if (tenkan > kijun) { - analysis.tkCross = '골든크로스'; - } else if (tenkan < kijun) { - analysis.tkCross = '데드크로스'; - } - } - - // 3. 후행스팬 추세 (현재 가격 vs 26일 전 가격) - if (chikou !== undefined && previousPrice !== undefined) { - if (currentPrice > previousPrice) { - analysis.chikouTrend = '상승 추세'; - } else if (currentPrice < previousPrice) { - analysis.chikouTrend = '하락 추세'; - } - } - - // 4. 종합 신호 판단 - let score = 0; - - // 구름 위치 점수 - if (analysis.cloudPosition === '구름 위') score += 2; - else if (analysis.cloudPosition === '구름 아래') score -= 2; - - // TK Cross 점수 - if (analysis.tkCross === '골든크로스') score += 1; - else if (analysis.tkCross === '데드크로스') score -= 1; - - // 후행스팬 점수 - if (analysis.chikouTrend === '상승 추세') score += 1; - else if (analysis.chikouTrend === '하락 추세') score -= 1; - - // 구름 타입 점수 - if (analysis.cloudType === '상승 구름') score += 1; - else if (analysis.cloudType === '하락 구름') score -= 1; - - // 최종 신호 - if (score >= 4) analysis.signal = '강한 매수'; - else if (score >= 2) analysis.signal = '매수'; - else if (score <= -4) analysis.signal = '강한 매도'; - else if (score <= -2) analysis.signal = '매도'; - else analysis.signal = '중립'; - - return analysis; -} - -/** - * 볼린저밴드 영역 Canvas Overlay 컴포넌트 - * 상한선과 하한선 사이를 canvas polygon으로 채움 - */ -interface BollingerBandOverlayProps { - chartRef: React.RefObject; - candleSeriesRef: React.RefObject | null>; - bandData: Array<{ time: number; upper: number; lower: number }>; - priceRange: { min: number; max: number }; - containerWidth: number; - containerHeight: number; - backgroundColor?: string; - yAxisScaleFactor?: number; // ✅ Y축 스케일 팩터 (밴드 영역 재그리기 트리거용) -} - -const BollingerBandOverlay: React.FC = ({ - chartRef, - candleSeriesRef, - bandData, - priceRange, - containerWidth, - containerHeight, - backgroundColor = 'rgba(33, 150, 243, 0.15)', // ✅ 기본 투명도 증가 (더 잘 보이도록) - yAxisScaleFactor = 1.0 -}) => { - const canvasRef = useRef(null); - - console.log(`🎨 [BollingerBandOverlay] 컴포넌트 렌더링:`, { - dataLength: bandData.length, - backgroundColor, - containerWidth, - containerHeight, - hasChartRef: !!chartRef.current, - hasCandleSeriesRef: !!candleSeriesRef.current - }); - - const drawBand = useCallback(() => { - const canvas = canvasRef.current; - const chart = chartRef.current; - const candleSeries = candleSeriesRef.current; - - console.log(`🎨 [BollingerBand drawBand] 시작:`, { - hasCanvas: !!canvas, - hasChart: !!chart, - hasSeries: !!candleSeries, - dataLength: bandData.length, - backgroundColor - }); - - if (!canvas || !chart || !candleSeries || bandData.length < 2) { - console.warn('⚠️ [BollingerBand] 렌더링 조건 미충족'); - return; - } - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Canvas 크기 설정 (Retina 디스플레이 대응) - const dpr = window.devicePixelRatio || 1; - canvas.width = containerWidth * dpr; - canvas.height = containerHeight * dpr; - canvas.style.width = `${containerWidth}px`; - canvas.style.height = `${containerHeight}px`; - ctx.scale(dpr, dpr); - - // 초기화 - ctx.clearRect(0, 0, canvas.width, canvas.height); - - try { - const timeScale = chart.timeScale(); - - // ✅ Y축 라벨 영역 제외 (우측 100px는 Y축 라벨 영역) - const priceScaleWidth = 100; // Y축 라벨 영역 너비 - const chartDrawWidth = containerWidth - priceScaleWidth; // 실제 차트 그리기 영역 너비 - - // ✅ 클리핑 영역 설정: Y축 라벨 영역을 제외한 영역에만 그리기 - ctx.save(); - ctx.beginPath(); - ctx.rect(0, 0, chartDrawWidth, containerHeight); - ctx.clip(); - - const priceToY = (price: number): number => { - const coord = candleSeries.priceToCoordinate(price); - if (coord === null) { - return containerHeight / 2; - } - return coord; - }; - - // 각 세그먼트를 polygon으로 그림 - for (let i = 0; i < bandData.length - 1; i++) { - const curr = bandData[i]; - const next = bandData[i + 1]; - - // 시간 좌표 변환 - const x1 = timeScale.timeToCoordinate(curr.time as Time); - const x2 = timeScale.timeToCoordinate(next.time as Time); - - if (x1 === null || x2 === null) continue; - - // 화면 밖의 세그먼트는 스킵 - // ✅ chartDrawWidth를 기준으로 판단 - if ((x1 < -100 && x2 < -100) || (x1 > chartDrawWidth + 100 && x2 > chartDrawWidth + 100)) { - continue; - } - - // 가격을 Y좌표로 변환 - const y1Upper = priceToY(curr.upper); - const y1Lower = priceToY(curr.lower); - const y2Upper = priceToY(next.upper); - const y2Lower = priceToY(next.lower); - - ctx.fillStyle = backgroundColor; - - // Polygon 그리기 (4개 꼭지점) - 상한선과 하한선 사이를 채움 - ctx.beginPath(); - ctx.moveTo(x1, y1Upper); // 현재 시점의 상한선 - ctx.lineTo(x2, y2Upper); // 다음 시점의 상한선 - ctx.lineTo(x2, y2Lower); // 다음 시점의 하한선 - ctx.lineTo(x1, y1Lower); // 현재 시점의 하한선 - ctx.closePath(); - ctx.fill(); - } - - // ✅ 클리핑 영역 복원 - ctx.restore(); - - console.log(`✅ [BollingerBand] 배경 렌더링 완료: ${bandData.length}개 포인트, 색상: ${backgroundColor}, 그리기 영역: ${chartDrawWidth}px (Y축 제외)`); - } catch (error) { - console.error('❌ [BollingerBand] Canvas 그리기 오류:', error); - } - }, [chartRef, candleSeriesRef, bandData, priceRange, containerWidth, containerHeight, backgroundColor, yAxisScaleFactor]); - - // 초기 그리기 - useEffect(() => { - drawBand(); - }, [drawBand]); - - // 차트 변경 시 다시 그리기 - useEffect(() => { - const chart = chartRef.current; - if (!chart) return; - - const timeScale = chart.timeScale(); - const handleVisibleRangeChange = () => { - drawBand(); - }; - - timeScale.subscribeVisibleLogicalRangeChange(handleVisibleRangeChange); - - return () => { - timeScale.unsubscribeVisibleLogicalRangeChange(handleVisibleRangeChange); - }; - }, [chartRef, drawBand]); - - return ( - - ); -}; - -/** - * 일목균형표 구름 영역 Canvas Overlay 컴포넌트 - * 업비트 방식: 두 선(선행스팬1, 2) 사이를 canvas polygon으로 채움 - * - * 핵심 구현: - * 1. 미래 시점(26일 선행)까지 포함한 구름 영역 표시 - * 2. 차트의 전체 가격 범위와 동기화된 정확한 Y좌표 계산 - * 3. 조건부 색상 (상승: 녹색, 하락: 빨강) - */ -interface IchimokuCloudOverlayProps { - chartRef: React.RefObject; - candleSeriesRef: React.RefObject | null>; // ✅ 정확한 Y좌표 변환을 위해 추가 - isDisposedRef?: React.RefObject; // dispose 시 콜백 스킵용 - cloudData: Array<{ time: number; senkouA: number; senkouB: number }>; - priceRange: { min: number; max: number }; // 차트 전체 가격 범위 - containerWidth: number; - containerHeight: number; - cloudBullishColor?: string; // 상승 구름 색상 - cloudBearishColor?: string; // 하락 구름 색상 - yAxisScaleFactor?: number; // ✅ Y축 스케일 팩터 (구름 영역 재그리기 트리거용) - /** 선행스팬 선 (Canvas로 그려 lightweight-charts Line 시리즈 "Value is null" 회피) */ - senkouALineData?: Array<{ time: number; value: number }>; - senkouBLineData?: Array<{ time: number; value: number }>; - senkouAColor?: string; - senkouBColor?: string; - senkouAWidth?: number; - senkouBWidth?: number; - /** 전환선/기준선 (Canvas로 그려 addLineSeries "Value is null" 회피) */ - tenkanLineData?: Array<{ time: number; value: number }>; - kijunLineData?: Array<{ time: number; value: number }>; - tenkanColor?: string; - kijunColor?: string; - tenkanWidth?: number; - kijunWidth?: number; -} - -const IchimokuCloudOverlay: React.FC = ({ - chartRef, - candleSeriesRef, - isDisposedRef, - cloudData, - priceRange, - containerWidth, - containerHeight, - cloudBullishColor = 'rgba(239, 83, 80, 0.2)', - cloudBearishColor = 'rgba(38, 166, 154, 0.2)', - yAxisScaleFactor = 1.0, - senkouALineData = [], - senkouBLineData = [], - senkouAColor = '#26a69a', - senkouBColor = '#ef5350', - senkouAWidth = 1, - senkouBWidth = 1, - tenkanLineData = [], - kijunLineData = [], - tenkanColor = '#2196f3', - kijunColor = '#f44336', - tenkanWidth = 1, - kijunWidth = 1.5, -}) => { - const canvasRef = useRef(null); - - const drawCloud = useCallback(() => { - if (isDisposedRef?.current) return; - const canvas = canvasRef.current; - const chart = chartRef.current; - const candleSeries = candleSeriesRef.current; - - const hasCloud = cloudData.length >= 2; - const hasSenkouA = senkouALineData.length >= 2; - const hasSenkouB = senkouBLineData.length >= 2; - const hasTenkan = tenkanLineData.length >= 2; - const hasKijun = kijunLineData.length >= 2; - const hasAnythingToDraw = hasCloud || hasSenkouA || hasSenkouB || hasTenkan || hasKijun; - - if (!canvas || !chart || !candleSeries || !hasAnythingToDraw) { - if (!candleSeries) console.warn('⚠️ [IchimokuCloud] candleSeries 없음'); - return; - } - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Canvas 크기 설정 (Retina 디스플레이 대응) - const dpr = window.devicePixelRatio || 1; - canvas.width = containerWidth * dpr; - canvas.height = containerHeight * dpr; - canvas.style.width = `${containerWidth}px`; - canvas.style.height = `${containerHeight}px`; - ctx.scale(dpr, dpr); - - // 초기화 - ctx.clearRect(0, 0, canvas.width, canvas.height); - - try { - const timeScale = chart.timeScale(); - - console.log(`📊 [IchimokuCloud] priceRange prop: ${priceRange.min.toFixed(0)} ~ ${priceRange.max.toFixed(0)}`); - - // ✅ Y축 라벨 영역 제외 (우측 100px는 Y축 라벨 영역) - const priceScaleWidth = 100; // Y축 라벨 영역 너비 - const chartDrawWidth = containerWidth - priceScaleWidth; // 실제 차트 그리기 영역 너비 - - // ✅ 클리핑 영역 설정: Y축 라벨 영역을 제외한 영역에만 그리기 - ctx.save(); - ctx.beginPath(); - ctx.rect(0, 0, chartDrawWidth, containerHeight); - ctx.clip(); - - /** - * ✅ 가격을 Y픽셀 좌표로 변환 - * priceToCoordinate가 숫자를 반환하면 **항상 그 좌표만** 쓴다(클램프만 적용). - * 이전에는 0~containerHeight 밖이면 priceRange 선형 보간으로 바꿔, 같은 구름 구간 안에서 - * 차트 좌표계와 보간 좌표계가 섞여 화면 전체 높이로 번지는 삼각형/직사각형이 생길 수 있었다. - * coordinate가 null일 때만 priceRange 폴백. - */ - const priceToY = (price: number): number => { - const coord = candleSeries.priceToCoordinate(price); - if (coord !== null && isFinite(coord)) { - if (containerHeight <= 0) return 0; - return Math.max(0, Math.min(containerHeight - 1, coord)); - } - const { min, max } = priceRange; - if (max > min && isFinite(min) && isFinite(max)) { - const normalized = Math.max(0, Math.min(1, (price - min) / (max - min))); - return containerHeight * (1 - normalized); - } - return containerHeight / 2; - }; - - /** 선행 A·B가 선형 보간 구간 (0~1) 안에서 교차하는 비율 s. 없으면 null (TradingView/업비트형 채움: 자기교차 사각형 fill 방지) */ - const senkouCrossFraction = (a1: number, b1: number, a2: number, b2: number): number | null => { - const d1 = a1 - b1; - const d2 = a2 - b2; - const denom = d1 - d2; - if (!isFinite(denom) || Math.abs(denom) < 1e-14) return null; - const s = d1 / denom; - if (!isFinite(s) || s <= 1e-9 || s >= 1 - 1e-9) return null; - return s; - }; - - // 각 세그먼트를 polygon으로 그림 (업비트 방식) - // 미래 시점(26일 선행)까지 포함하여 렌더링 - for (let i = 0; i < cloudData.length - 1; i++) { - const curr = cloudData[i]; - const next = cloudData[i + 1]; - - // 시간 좌표 변환 (미래 시점 포함) - const x1 = timeScale.timeToCoordinate(curr.time as Time); - const x2 = timeScale.timeToCoordinate(next.time as Time); - - if (x1 === null || x2 === null) continue; - - if (next.time <= curr.time) continue; - if (Math.abs(x2 - x1) < 0.5) continue; - - // 화면 밖의 세그먼트는 스킵 (성능 최적화) - // ✅ chartDrawWidth를 기준으로 판단 - if ((x1 < -100 && x2 < -100) || (x1 > chartDrawWidth + 100 && x2 > chartDrawWidth + 100)) { - continue; - } - - // ✅ 유효성 검사: 0/NaN/Infinity 스킵 (수평 블록·캔들 가림 방지) - const isValid = (v: number) => v != null && !isNaN(v) && isFinite(v) && v > 1e-10; - if (!isValid(curr.senkouA) || !isValid(curr.senkouB) || !isValid(next.senkouA) || !isValid(next.senkouB)) { - continue; - } - - // ✅ 가격 범위 검증: 차트 가격 범위의 10배를 벗어나는 값은 비정상 데이터로 간주하고 스킵 - const priceRangeExtended = (priceRange.max - priceRange.min) * 10; - const minValidPrice = priceRange.min - priceRangeExtended; - const maxValidPrice = priceRange.max + priceRangeExtended; - const isPriceInRange = (v: number) => v >= minValidPrice && v <= maxValidPrice; - - if (!isPriceInRange(curr.senkouA) || !isPriceInRange(curr.senkouB) || - !isPriceInRange(next.senkouA) || !isPriceInRange(next.senkouB)) { - console.warn(`⚠️ [IchimokuCloud] 가격 범위 초과 스킵: senkou=[${curr.senkouA.toFixed(0)}, ${curr.senkouB.toFixed(0)}] → [${next.senkouA.toFixed(0)}, ${next.senkouB.toFixed(0)}], 유효범위=[${minValidPrice.toFixed(0)}, ${maxValidPrice.toFixed(0)}]`); - continue; - } - - // 가격을 Y좌표로 변환 (차트의 가격 축과 동기화) - const y1A = priceToY(curr.senkouA); - const y1B = priceToY(curr.senkouB); - const y2A = priceToY(next.senkouA); - const y2B = priceToY(next.senkouB); - - const sCross = senkouCrossFraction(curr.senkouA, curr.senkouB, next.senkouA, next.senkouB); - - if (sCross != null) { - const xc = x1 + sCross * (x2 - x1); - const pCross = - curr.senkouA + sCross * (next.senkouA - curr.senkouA); - const yc = priceToY(pCross); - const bullLeft = curr.senkouA >= curr.senkouB; - const bullRight = next.senkouA >= next.senkouB; - ctx.fillStyle = bullLeft ? cloudBullishColor : cloudBearishColor; - ctx.beginPath(); - ctx.moveTo(x1, y1A); - ctx.lineTo(xc, yc); - ctx.lineTo(x1, y1B); - ctx.closePath(); - ctx.fill(); - ctx.fillStyle = bullRight ? cloudBullishColor : cloudBearishColor; - ctx.beginPath(); - ctx.moveTo(xc, yc); - ctx.lineTo(x2, y2A); - ctx.lineTo(x2, y2B); - ctx.closePath(); - ctx.fill(); - } else { - const isBullish = curr.senkouA >= curr.senkouB; - ctx.fillStyle = isBullish ? cloudBullishColor : cloudBearishColor; - ctx.beginPath(); - ctx.moveTo(x1, y1A); - ctx.lineTo(x2, y2A); - ctx.lineTo(x2, y2B); - ctx.lineTo(x1, y1B); - ctx.closePath(); - ctx.fill(); - } - } - - // ✅ 선행스팬 선 그리기 (Canvas로 처리하여 lightweight-charts Line 시리즈 "Value is null" 회피) - const drawLineSeries = (data: Array<{ time: number; value: number }>, color: string, lineWidth: number) => { - if (data.length < 2) return; - const isValid = (v: number) => v != null && !isNaN(v) && isFinite(v) && v > 1e-10; - ctx.beginPath(); - let first = true; - for (let i = 0; i < data.length; i++) { - const curr = data[i]; - const x = timeScale.timeToCoordinate(curr.time as Time); - if (x === null || !isValid(curr.value)) { - first = true; // 갭 시 새 선분 시작 - continue; - } - const y = priceToY(curr.value); - if (first) { - ctx.moveTo(x, y); - first = false; - } else { - ctx.lineTo(x, y); - } - } - ctx.strokeStyle = color; - ctx.lineWidth = lineWidth; - ctx.stroke(); - }; - if (hasSenkouA) drawLineSeries(senkouALineData, senkouAColor, senkouAWidth); - if (hasSenkouB) drawLineSeries(senkouBLineData, senkouBColor, senkouBWidth); - if (hasTenkan) drawLineSeries(tenkanLineData, tenkanColor, tenkanWidth); - if (hasKijun) drawLineSeries(kijunLineData, kijunColor, kijunWidth); - - // ✅ 클리핑 영역 복원 - ctx.restore(); - - console.log(`✅ [IchimokuCloud] 구름 렌더링 완료: ${cloudData.length}개 포인트, 선행A=${senkouALineData.length}, 선행B=${senkouBLineData.length}, 가격범위: ${priceRange.min.toFixed(0)} ~ ${priceRange.max.toFixed(0)}, 그리기 영역: ${chartDrawWidth}px (Y축 제외)`); - console.log(`🎨 [IchimokuCloud] 구름 색상: 상승=${cloudBullishColor}, 하락=${cloudBearishColor}`); - } catch (error) { - console.error('❌ [IchimokuCloud] Canvas 그리기 오류:', error); - } - }, [chartRef, isDisposedRef, cloudData, priceRange, containerWidth, containerHeight, cloudBullishColor, cloudBearishColor, candleSeriesRef, yAxisScaleFactor, senkouALineData, senkouBLineData, senkouAColor, senkouBColor, senkouAWidth, senkouBWidth, tenkanLineData, kijunLineData, tenkanColor, kijunColor, tenkanWidth, kijunWidth]); - - // 초기 그리기 - useEffect(() => { - drawCloud(); - }, [drawCloud]); - - // 차트 변경 시 다시 그리기 - useEffect(() => { - const chart = chartRef.current; - if (!chart) return; - - const timeScale = chart.timeScale(); - const handleVisibleRangeChange = () => { - drawCloud(); - }; - - timeScale.subscribeVisibleLogicalRangeChange(handleVisibleRangeChange); - - return () => { - timeScale.unsubscribeVisibleLogicalRangeChange(handleVisibleRangeChange); - }; - }, [chartRef, drawCloud]); - - return ( - - ); -}; - -/** - * 단순 이동평균(SMA) 계산 함수 - */ -const calculateSMA = (data: { time: number; close: number }[], period: number): { time: number; value: number | null }[] => { - const result: { time: number; value: number | null }[] = []; - - for (let i = 0; i < data.length; i++) { - if (i < period - 1) { - // 초기 기간에는 null 값 설정 - result.push({ - time: data[i].time, - value: null, - }); - } else { - let sum = 0; - for (let j = 0; j < period; j++) { - sum += data[i - j].close; - } - result.push({ - time: data[i].time, - value: sum / period, - }); - } - } - - return result; -}; - -/** - * 지수 이동평균(EMA) 계산 함수 - */ -const calculateEMA = (data: { time: number; close: number }[], period: number): { time: number; value: number | null }[] => { - const result: { time: number; value: number | null }[] = []; - const multiplier = 2 / (period + 1); - - if (data.length < period) { - // 데이터가 부족하면 모두 null로 반환 - return data.map(d => ({ time: d.time, value: null })); - } - - let ema = 0; - - for (let i = 0; i < data.length; i++) { - if (i < period - 1) { - // 초기 기간에는 null 값 설정 - result.push({ time: data[i].time, value: null }); - } else if (i === period - 1) { - // 첫 EMA는 SMA로 시작 - let sum = 0; - for (let j = 0; j < period; j++) { - sum += data[i - j].close; - } - ema = sum / period; - result.push({ time: data[i].time, value: ema }); - } else { - // 이후는 EMA 공식 적용 - ema = (data[i].close - ema) * multiplier + ema; - result.push({ time: data[i].time, value: ema }); - } - } - - return result; -}; - -/** - * 차트 데이터 검증 함수 - null, undefined, NaN, Infinity 값 제거 - */ -const validateChartData = (data: { time: number; value: number | null }[]): { time: number; value: number }[] => { - return data - .filter((d): d is { time: number; value: number } => - d !== null && - d !== undefined && - d.time !== null && - d.time !== undefined && - !isNaN(d.time) && - isFinite(d.time) && - d.time > 0 && - d.value !== null && - d.value !== undefined && - !isNaN(d.value) && - isFinite(d.value) - ); -}; - -/** Histogram/Volume 시리즈용: 유효한 time·value만 남기고 시간 오름차순 정렬, 동일 time은 마지막만 유지 (LWCharts Value is null 방지) */ -const sanitizeHistogramSeriesData = ( - bars: T[] -): T[] => { - const bySec = new Map(); - for (const b of bars) { - if (!b || b.value === undefined || b.value === null) continue; - const v = Number(b.value); - if (!Number.isFinite(v)) continue; - const rawT = b.time; - const sec = typeof rawT === 'number' ? Math.floor(rawT) : Math.floor(Number(rawT)); - if (!Number.isFinite(sec) || sec <= 0) continue; - const color = typeof b.color === 'string' && b.color.length > 0 ? b.color : undefined; - bySec.set(sec, { ...b, time: sec as Time, value: v, ...(color ? { color } : {}) } as T); - } - return Array.from(bySec.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([, row]) => row); -}; - -/** - * 일목균형표 계산 함수 - */ -const calculateIchimoku = (data: { time: number; high: number; low: number; close: number }[], - tenkanPeriod: number = 9, - kijunPeriod: number = 26, - senkouBPeriod: number = 52) => { - const result: { - time: number; - tenkan?: number; - kijun?: number; - chikou?: number; - senkouA?: number; - senkouB?: number; - }[] = []; - - const getHighLow = (start: number, end: number) => { - let high = -Infinity; - let low = Infinity; - for (let i = start; i <= end; i++) { - if (data[i].high > high) high = data[i].high; - if (data[i].low < low) low = data[i].low; - } - return { high, low }; - }; - - for (let i = 0; i < data.length; i++) { - const item: any = { time: data[i].time }; - - // 전환선 (Tenkan-sen): (9일 최고가 + 9일 최저가) / 2 - if (i >= tenkanPeriod - 1) { - const { high, low } = getHighLow(i - tenkanPeriod + 1, i); - item.tenkan = (high + low) / 2; - } - - // 기준선 (Kijun-sen): (26일 최고가 + 26일 최저가) / 2 - if (i >= kijunPeriod - 1) { - const { high, low } = getHighLow(i - kijunPeriod + 1, i); - item.kijun = (high + low) / 2; - } - - // 후행스팬 (Chikou Span): 현재 종가를 26일 후행 - item.chikou = data[i].close; - - // 선행스팬1 (Senkou Span A): (전환선 + 기준선) / 2, 26일 선행 - if (i >= Math.max(tenkanPeriod, kijunPeriod) - 1) { - const { high: tHigh, low: tLow } = getHighLow(i - tenkanPeriod + 1, i); - const { high: kHigh, low: kLow } = getHighLow(i - kijunPeriod + 1, i); - const tenkan = (tHigh + tLow) / 2; - const kijun = (kHigh + kLow) / 2; - item.senkouA = (tenkan + kijun) / 2; - } - - // 선행스팬2 (Senkou Span B): (52일 최고가 + 52일 최저가) / 2, 26일 선행 - if (i >= senkouBPeriod - 1) { - const { high, low } = getHighLow(i - senkouBPeriod + 1, i); - item.senkouB = (high + low) / 2; - } - - result.push(item); - } - - return result; -}; - -export interface ChartHandle { - setCrosshairPosition: (time: Time) => void; - clearCrosshair: () => void; - setVisibleRange: (from: number, to: number) => void; - setVisibleLogicalRange: (from: number, to: number) => void; - getVisibleRange: () => { from: number; to: number } | null; - getVisibleLogicalRange: () => { from: number; to: number } | null; - resize: () => void; - fitContent: () => void; - timeScale: () => { - timeToCoordinate: (time: number) => number | null; - coordinateToTime: (x: number) => number | null; - }; - priceScale: () => { - priceToCoordinate: (price: number) => number | null; - coordinateToPrice: (y: number) => number | null; - }; - // ✅ 스크롤/줌 이벤트 구독 (외부 차트 동기화용) - subscribeVisibleLogicalRangeChange: (callback: (range: { from: number; to: number }) => void) => void; - unsubscribeVisibleLogicalRangeChange: (callback: (range: { from: number; to: number }) => void) => void; - // ✅ 알림 마커 추가 (매수/매도 신호 표시) - addAlertMarker: (time: Time, signalType: 'BUY' | 'SELL') => void; - clearAlertMarkers: () => void; - // ✅ 실시간 캔들 업데이트 (WebSocket에서 사용) - updateLastCandle: (candle: { open: number; high: number; low: number; close: number; volume?: number; timestamp?: number | string; time?: string | number }, isNewCandle?: boolean) => void; - // ✅ 실시간 지표 업데이트 (WebSocket에서 사용 - 이동평균선, 볼린저밴드, 일목균형표 등) - updateLastIndicators: (candle: CandleData, allCandles: CandleData[], allIndicatorData: IndicatorData[]) => void; -} - -// 차트 툴 타입 정의 (ChartToolbar와 동일) -export type ChartTool = - | 'crosshair' - | 'magnifier' - | 'trendLine' - | 'horizontalLine' - | 'verticalLine' - | 'rectangle' - | 'fibonacci' - | 'fibonacciTimezone' - | 'fibonacciFan' - | 'fibonacciCircle' - | 'fibonacciWedge' - | 'text' - | 'brush' - | 'measure' - | 'diagonalLine' - | 'angleLine' - | 'parallelChannel'; - -export interface AlertMarker { - time: Time; - position: 'aboveBar' | 'belowBar'; - color: string; - shape: 'arrowUp' | 'arrowDown' | 'circle'; - text: string; -} - -export interface CandlestickChartProps { - data: CandleData[]; - indicatorData?: IndicatorData[]; - height?: number; - onTimeRangeChange?: (from: number, to: number) => void; - onCrosshairMove?: (time: Time | null) => void; - onVisibleRangeChange?: (from: number, to: number) => void; - // 네비게이션 기능 - onNavigate?: (direction: 'past' | 'future') => void; - showLeftButton?: boolean; - showRightButton?: boolean; - isLoading?: boolean; - // 알림 마커 - alertMarkers?: AlertMarker[]; - // 보조지표 오버레이 표시 여부 (기본값: false) - showIndicatorOverlay?: boolean; - // 보조지표 토글 버튼 표시 여부 (기본값: showIndicatorOverlay와 동일) - showIndicatorToggle?: boolean; - // 외부 크로스헤어 동기화 (보조지표 차트에서 호버 시) - externalHoverTime?: Time | null; - // 외부 크로스헤어 활성화 여부 (보조지표 호버 시 자체 크로스헤어 숨김) - hideNativeCrosshair?: boolean; - // X축 타임스케일 숨김 여부 (기본값: false - 보조지표가 있을 때 캔들차트의 X축 숨김) - hideTimeScale?: boolean; - // ✅ 보조지표(서브패널) 하단에 날짜 라벨 표시 여부 (기본값: true - 투자분석에서 마지막 보조지표에 날짜 표시, 멀티차트에서는 false로 비표시) - showSubPanelTimeScale?: boolean; - // 툴팁 표시 여부 (기본값: true) - showTooltip?: boolean; - // ✅ 매수/매도 마커 클릭 핸들러 - onMarkerClick?: (timestamp: string, tradeType: 'BUY' | 'SELL') => void; - // ✅ 활성 툴 (트레이딩뷰 모드) - activeTool?: ChartTool | null; - // ✅ 차트 클릭 핸들러 (피보나치 타임존 등) - onChartClick?: (time: number) => void; - // ✅ 추세 판단 MA 설정 (차트 하단 추세 표시바용) - trendShortMA?: number; // 단기 MA (기본값: 10) - trendMediumMA?: number; // 중기 MA (기본값: 20) - trendLongMA?: number; // 장기 MA (기본값: 60) - // ✅ 차트 마우스 이벤트 핸들러 (드래그 방식 드로잉) - onChartMouseDown?: (time: number) => void; - onChartMouseMove?: (time: number) => void; - onChartMouseUp?: (time: number) => void; - // ✅ 줌 레벨 변경 핸들러 (보조지표 차트 동기화용) - onZoomChange?: (fromTime: number, toTime: number) => void; - // ✅ 차트 뷰 모드 ('basic' | 'tradingview') - chartViewMode?: 'basic' | 'tradingview'; - // ✅ MA/Ichimoku 계산용 추가 데이터 (화면에는 표시하지 않고 계산에만 사용) - maCalculationData?: CandleData[]; - // ✅ 전체 400개 지표 데이터 (MA 렌더링용 maValues 포함) - allIndicatorData?: IndicatorData[]; - // ✅ 이동평균선 설정 - maLines?: Array<{ - id: string; - enabled: boolean; - period: number; - color: string; - width: number; - type: 'SMA' | 'EMA'; - }>; - // ✅ 일목균형표 설정 - ichimokuSettings?: { - tenkan: boolean; - kijun: boolean; - chikou: boolean; - senkouA: boolean; - senkouB: boolean; - tenkanPeriod: number; - kijunPeriod: number; - senkouBPeriod: number; - }; - // ✅ 일목균형표 색상 및 선 굵기 - ichimokuColors?: { - tenkan: string; - kijun: string; - chikou: string; - senkouA: string; - senkouB: string; - cloudBullish?: string; // 상승 구름 색상 - cloudBearish?: string; // 하락 구름 색상 - tenkanWidth?: number; - kijunWidth?: number; - chikouWidth?: number; - senkouAWidth?: number; - senkouBWidth?: number; - }; - // ✅ 서브차트 패널 설정 (TradingView 스타일) - subPanels?: Array<{ - type: 'volume' | 'macd' | 'rsi' | 'stochastic' | 'cci' | 'adx' | 'obv' | 'newPsychological' | 'investPsychological' | 'bwi' | 'dmi' | 'williamsR' | 'momentum' | 'trix' | 'volumeOsc' | 'vr' | 'disparity'; - height?: number; // 서브패널 높이 (기본값: 120px) - enabled: boolean; - }>; - // ✅ 서브패널 타이틀 클릭 핸들러 (설정 화면 열기) - onSubPanelSettingsClick?: (panelType: string, title: string) => void; - // ✅ 보조지표 삭제 핸들러 (보조지표 off 설정) - onIndicatorDelete?: (panelType: string) => void; - // ✅ 보조지표 설정 (기준선 표시용) - indicatorSettings?: any; - // ✅ 차트 색상 설정 (기준선 색상용) - colorSettings?: any; - // ✅ 서브패널 순서 변경 핸들러 - onSubPanelReorder?: (fromIndex: number, toIndex: number) => void; - // ✅ 모바일 모드 플래그 (모바일 최적화 터치 설정 적용) - isMobileMode?: boolean; - // ✅ 외부에서 제어하는 시간 범위 (실시간 동기화) - sharedTimeRange?: { from: number; to: number } | null; - // ✅ 색상 버전 (변경 시 서브패널 강제 업데이트용) - colorVersion?: number; - // ✅ 설정 버전 (변경 시 서브패널 강제 업데이트용) - settingsVersion?: number; - // ✅ 리사이즈 중 플래그 (차트 갱신 방지) - isResizing?: boolean; - // ✅ 외부 강제 업데이트 카운터 (보조지표 실시간 갱신용) - externalForceUpdate?: number; - // ✅ 컴팩트 툴바 (멀티차트 등 작은 차트에서 버튼 크기 축소) - compactToolbar?: boolean; - // ✅ 보조지표 하단 줌/스크롤/리셋 툴바 숨김 (멀티차트 전용 - 캔들 아래에 이미 동일 툴바 있음) - hideSubPanelToolbar?: boolean; - // ✅ 캔들 위·보조지표 하단 줌/좌우이동/리셋 툴바 숨김 (멀티차트·알림목록 — 투자분석은 미전달 시 표시) - hideNavigationToolbar?: boolean; - // ✅ 캔들 하단 정배열/역배열 추세 바 숨김 (멀티차트·알림목록) - hideTrendAlignmentBar?: boolean; - // ✅ 차트 내 범례 숨김 (멀티차트 등) - hideLegends?: boolean; - // ✅ 멀티차트 등에서 context 대신 사용할 오버레이 설정 (볼린저 등) - overlaySettingsOverride?: { - bollingerUpper?: boolean; - bollingerMiddle?: boolean; - bollingerLower?: boolean; - bollingerPeriod?: number; - bollingerStdDev?: number; - }; - // ✅ 캔들 영역 높이 리사이즈 핸들 (캔들/보조지표 경계에 드래그 버튼) - onCandleHeightResize?: (newHeight: number) => void; - showCandleResizeHandle?: boolean; -} - -// ✅ 드래그 가능한 서브패널 컴포넌트 -interface SortableSubPanelProps { - panel: { - type: 'volume' | 'macd' | 'rsi' | 'stochastic' | 'cci' | 'adx' | 'obv' | 'newPsychological' | 'investPsychological' | 'bwi' | 'dmi' | 'williamsR' | 'momentum' | 'trix' | 'volumeOsc' | 'vr' | 'disparity'; - height?: number; - enabled: boolean; - }; - index: number; - isEven: boolean; - chartColors: any; - themeMode: ThemeMode; - subChartContainersRef: React.MutableRefObject<{ [key: string]: HTMLDivElement | null }>; - onSubPanelSettingsClick?: (panelType: string, title: string) => void; - onIndicatorDelete?: (panelType: string) => void; // ✅ 보조지표 삭제(off) 핸들러 - showDragHandle: boolean; - isMaximized?: boolean; - onMaximize?: () => void; - onRestore?: () => void; - colorVersion?: number; // ✅ 색상 버전 - settingsVersion?: number; // ✅ 설정 버전 -} - -const SortableSubPanel: React.FC = ({ - panel, - index, - isEven, - chartColors, - themeMode, - subChartContainersRef, - onSubPanelSettingsClick, - onIndicatorDelete, - showDragHandle, - isMaximized = false, - onMaximize, - onRestore, - colorVersion, - settingsVersion, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: panel.type }); - - // ✅ 그래프 선 선택 상태 관리 - const [isSelected, setIsSelected] = useState(false); - // ✅ 툴박스 메뉴 앵커 - const [toolboxAnchor, setToolboxAnchor] = useState(null); - // ✅ 더보기 서브메뉴 앵커 - const [moreMenuAnchor, setMoreMenuAnchor] = useState(null); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - const alternatingBgColor = themeMode === 'dark' - ? (isEven ? 'rgba(255, 255, 255, 0.02)' : chartColors.background) - : (isEven ? 'rgba(0, 0, 0, 0.02)' : chartColors.background); - - const titleMap: Record = { - volume: '거래량', - macd: 'MACD', - rsi: 'RSI', - stochastic: 'Stochastic Slow', - cci: 'CCI', - adx: 'ADX', - obv: 'OBV', - williamsR: 'Williams %R', - trix: 'TRIX', - disparity: '이격도', - bwi: 'BWI', - volumeOsc: 'VolumeOSC', - vr: 'VR', - newPsychological: '신심리도', - investPsychological: '투자심리도', - dmi: 'DMI', - }; - - // ✅ 보조지표 설정값 가져오기 (프론트엔드) - const { indicatorSettings, updateIndicatorSettings } = useIndicatorSettings(); - // ✅ 백엔드 설정값 가져오기 - const { backendSettings } = useUpbitData(); - // ✅ 차트 색상 설정 가져오기 - const { colors: colorSettings } = useChartColors(); - - // ✅ 디버그: 서브패널 렌더링 및 버전 변경 감지 - console.log(`🎬 [SortableSubPanel:${panel.type}] 렌더링됨 - colorVersion: ${colorVersion}, settingsVersion: ${settingsVersion} [BUILD_V6]`); - - useEffect(() => { - console.log(`🔔 [SortableSubPanel:${panel.type}] 버전 변경 감지 - colorVersion: ${colorVersion}, settingsVersion: ${settingsVersion} [BUILD_V6]`); - }, [colorVersion, settingsVersion, panel.type]); - - // ✅ 보조지표별 설정값 텍스트 생성 (프론트엔드) - const getSettingsText = (panelType: string): React.ReactNode => { - if (!indicatorSettings || !colorSettings) return ''; - - switch (panelType) { - case 'macd': { - return ( - <> - {'('} - {indicatorSettings.macdFastPeriod} - {','} - {indicatorSettings.macdSlowPeriod} - {','} - {indicatorSettings.macdSignalPeriod} - {')'} - - ); - } - case 'stochastic': { - return ( - <> - {'('} - {indicatorSettings.stochasticKPeriod} - {','} - {indicatorSettings.stochasticDPeriod} - {','} - {indicatorSettings.stochasticSlowing} - {')'} - - ); - } - case 'rsi': { - return ( - <> - {'('} - {indicatorSettings.rsiPeriod} - {')'} - - ); - } - case 'cci': { - const values: Array<{ value: number; color: string }> = []; - values.push({ value: indicatorSettings.cciPeriod, color: colorSettings.cciColor }); - if (indicatorSettings.cciSignalEnabled) { - values.push({ value: indicatorSettings.cciSignalPeriod, color: colorSettings.cciSignalColor }); - } - return ( - <> - {values.map((v, idx) => ( - - {v.value}{idx < values.length - 1 ? ',' : ''} - - ))} - - ); - } - case 'adx': { - return ( - <> - {'('} - {indicatorSettings.adxPeriod} - {')'} - - ); - } - case 'dmi': { - return ( - <> - {'('} - {indicatorSettings.dmiPeriod} - {')'} - - ); - } - case 'williamsR': { - return ( - <> - {'('} - {indicatorSettings.williamsRPeriod} - {')'} - - ); - } - case 'obv': { - const values: Array<{ value: number; color: string }> = []; - if (indicatorSettings.obvEnabled) { - values.push({ value: indicatorSettings.obvPeriod, color: colorSettings.obvColor }); - } - if (indicatorSettings.obvSignalEnabled) { - values.push({ value: indicatorSettings.obvSignalPeriod, color: colorSettings.obvSignalColor }); - } - if (values.length === 0) return ''; - return ( - <> - {values.map((v, idx) => ( - - {v.value}{idx < values.length - 1 ? ',' : ''} - - ))} - - ); - } - case 'bwi': { - return ( - <> - {'('} - {indicatorSettings.bwiPeriod} - {')'} - - ); - } - case 'trix': { - return ( - <> - {'('} - {indicatorSettings.trixPeriod} - {','} - {indicatorSettings.trixSignalPeriod} - {')'} - - ); - } - case 'volumeOsc': { - return ( - <> - {'('} - {indicatorSettings.volumeOscShortPeriod} - {','} - {indicatorSettings.volumeOscLongPeriod} - {')'} - - ); - } - case 'vr': { - const values: Array<{ value: number; color: string }> = []; - values.push({ value: indicatorSettings.vrPeriod, color: colorSettings.vrColor }); - if (indicatorSettings.vr2Enabled) { - values.push({ value: indicatorSettings.vr2Period, color: colorSettings.vr2Color }); - } - return ( - <> - {values.map((v, idx) => ( - - {v.value}{idx < values.length - 1 ? ',' : ''} - - ))} - - ); - } - case 'newPsychological': { - return ( - <> - {'('} - {indicatorSettings.newPsychologicalPeriod} - {')'} - - ); - } - case 'investPsychological': { - return ( - <> - {'('} - {indicatorSettings.investPsychologicalPeriod} - {')'} - - ); - } - case 'disparity': { - const values: Array<{ value: number; color: string }> = []; - if (indicatorSettings.disparityUltraShortEnabled) { - values.push({ value: indicatorSettings.disparityUltraShortPeriod, color: colorSettings.disparityUltraShortColor }); - } - if (indicatorSettings.disparityShortEnabled) { - values.push({ value: indicatorSettings.disparityShortPeriod, color: colorSettings.disparityShortColor }); - } - if (indicatorSettings.disparityMidEnabled) { - values.push({ value: indicatorSettings.disparityMidPeriod, color: colorSettings.disparityMidColor }); - } - if (indicatorSettings.disparityLongEnabled) { - values.push({ value: indicatorSettings.disparityLongPeriod, color: colorSettings.disparityLongColor }); - } - if (values.length === 0) return ''; - return ( - <> - {values.map((v, idx) => ( - - {v.value}{idx < values.length - 1 ? ',' : ''} - - ))} - - ); - } - default: - return ''; - } - }; - - - // ✅ 툴박스 메뉴 핸들러 - const handleToolboxClose = () => { - setToolboxAnchor(null); - setMoreMenuAnchor(null); - }; - - const handleHideGraph = () => { - setIsSelected(false); - handleToolboxClose(); - }; - - const handleSettings = () => { - if (onSubPanelSettingsClick) { - onSubPanelSettingsClick(panel.type, titleMap[panel.type] || panel.type.toUpperCase()); - } - handleToolboxClose(); - }; - - const handleDelete = () => { - // ✅ 보조지표 삭제(off 설정) - if (onIndicatorDelete) { - onIndicatorDelete(panel.type); - } - handleToolboxClose(); - }; - - const handleMoreClick = (event: React.MouseEvent) => { - setMoreMenuAnchor(event.currentTarget); - }; - - const handleMoreClose = () => { - setMoreMenuAnchor(null); - }; - - // ✅ 최대화/복원 핸들러 - const handleMaximizeToggle = (e: React.MouseEvent) => { - e.stopPropagation(); - if (isMaximized && onRestore) { - onRestore(); - } else if (!isMaximized && onMaximize) { - onMaximize(); - } - }; - - return ( - -
{ - subChartContainersRef.current[panel.type] = el; - }} - onClick={() => { - // ✅ 차트 영역 클릭 시 선택 상태 토글 - setIsSelected(!isSelected); - }} - onDoubleClick={(e) => { - // ✅ 차트 영역 더블클릭 시 설정 팝업 열기 - e.stopPropagation(); - if (onSubPanelSettingsClick) { - onSubPanelSettingsClick(panel.type, titleMap[panel.type] || panel.type.toUpperCase()); - } - }} - style={{ - width: '100%', - height: '100%', - position: 'relative', - cursor: 'pointer', - boxSizing: 'border-box', - }} - /> - - {/* 서브차트 타이틀 및 백엔드 설정값 - flex로 나란히 배치 */} - - {/* 타이틀 버튼 - 파란색 칩 스타일 (업비트 스타일) */} - { - e.stopPropagation(); - if (onSubPanelSettingsClick) { - onSubPanelSettingsClick(panel.type, titleMap[panel.type] || panel.type.toUpperCase()); - } - }} - sx={{ - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - gap: 1, - pl: 1.5, - pr: 1.5, - py: 0.3, - borderRadius: 0.8, - bgcolor: themeMode === 'dark' - ? 'rgba(100, 100, 100, 0.5)' - : 'rgba(200, 200, 200, 0.6)', - '&:hover': { - bgcolor: themeMode === 'dark' - ? 'rgba(120, 120, 120, 0.6)' - : 'rgba(180, 180, 180, 0.7)', - }, - transition: 'background-color 0.2s', - border: '1px solid', - borderColor: themeMode === 'dark' - ? 'rgba(150, 150, 150, 0.3)' - : 'rgba(160, 160, 160, 0.4)', - boxShadow: '0 2px 4px rgba(0,0,0,0.2)', - }} - > - - {titleMap[panel.type] || panel.type.toUpperCase()} - {typeof getSettingsText(panel.type) === 'string' ? ( - - {getSettingsText(panel.type)} - - ) : ( - - {getSettingsText(panel.type)} - - )} - - - - {/* 백엔드 설정값 표시 제거됨 */} - - - {/* ✅ 우측 상단 삭제(X) 버튼 - 항상 표시 (멀티차트 등). 우측 격자 셀에서 짤림 방지용 패딩 확보 */} - {onIndicatorDelete && ( - - { - e.stopPropagation(); - handleDelete(); - }} - sx={{ - position: 'absolute', - top: 4, - right: 4, - zIndex: 11, - p: 0.5, - bgcolor: themeMode === 'dark' ? 'rgba(60, 60, 60, 0.8)' : 'rgba(220, 220, 220, 0.8)', - '&:hover': { - bgcolor: themeMode === 'dark' ? 'rgba(100, 100, 100, 0.9)' : 'rgba(200, 200, 200, 0.95)', - }, - }} - > - - - - )} - - {/* ✅ 툴박스 - 타이틀 우측에 표시 (선택 시에만) */} - {isSelected && ( - - { - e.stopPropagation(); - handleHideGraph(); - }} - sx={{ p: 0.5 }} - title="감추기" - > - - - { - e.stopPropagation(); - handleSettings(); - }} - sx={{ p: 0.5 }} - title="설정" - > - - - { - e.stopPropagation(); - handleDelete(); - }} - sx={{ p: 0.5 }} - title="삭제" - > - - - { - e.stopPropagation(); - handleMoreClick(e); - }} - sx={{ p: 0.5 }} - title="더보기" - > - - - - )} - - {/* ✅ 더보기 서브메뉴 */} - - { console.log('즐겨찾기 추가'); handleMoreClose(); }}> - - - - - - - { console.log('보는차례'); handleMoreClose(); }}> - - - - - - { console.log('인터벌 가시성'); handleMoreClose(); }}> - - - - - - { console.log('스케일 고정'); handleMoreClose(); }}> - - - - - - - { console.log('커피'); handleMoreClose(); }}> - - - - { handleHideGraph(); handleMoreClose(); }}> - - - - - - { handleDelete(); handleMoreClose(); }}> - - - - - - - { console.log('오브젝트 트리'); handleMoreClose(); }}> - - - { handleSettings(); handleMoreClose(); }}> - - - - - - - - {/* 우측 컨트롤 버튼 그룹 */} - {showDragHandle && ( - <> - {isMaximized ? ( - // 최대화 상태: 복원 버튼을 좌측 상단 타이틀 아래에 표시 - - - - - - ) : ( - // 일반 상태: 최대화 버튼과 드래그 핸들을 좌측 상단 타이틀 아래에 표시 - - {/* 최대화 버튼 */} - - - - - {/* 드래그 핸들 */} - - - - - )} - - )} - - ); -}; - -/** - * 캔들스틱 차트 컴포넌트 - * lightweight-charts v4를 사용 - */ -const CandlestickChart = forwardRef( - ({ data, indicatorData, height = 400, onTimeRangeChange, onCrosshairMove, onVisibleRangeChange, onNavigate, showLeftButton = true, showRightButton = false, isLoading = false, showIndicatorOverlay = false, showIndicatorToggle, externalHoverTime, hideNativeCrosshair = false, hideTimeScale = false, showSubPanelTimeScale = true, showTooltip = true, onMarkerClick, activeTool = 'crosshair', onChartClick, trendShortMA = 10, trendMediumMA = 20, trendLongMA = 60, onChartMouseDown, onChartMouseMove, onChartMouseUp, onZoomChange, chartViewMode = 'basic', maCalculationData, allIndicatorData, maLines, ichimokuSettings, ichimokuColors, subPanels = [], onSubPanelSettingsClick, onIndicatorDelete, indicatorSettings, colorSettings, onSubPanelReorder, isMobileMode = false, sharedTimeRange, colorVersion, settingsVersion, alertMarkers = [], isResizing = false, externalForceUpdate = 0, compactToolbar = false, hideSubPanelToolbar = false, hideNavigationToolbar = false, hideTrendAlignmentBar = false, overlaySettingsOverride, hideLegends = false, onCandleHeightResize, showCandleResizeHandle = false }, ref) => { - - // ✅ 디버그: CandlestickChart가 받은 props 확인 - console.log(`🎬 [CandlestickChart] 렌더링됨 - colorVersion: ${colorVersion}, settingsVersion: ${settingsVersion} [BUILD_V6]`); - - // ✅ 멀티차트 등에서 indicatorSettings prop 미전달 시 Context 사용 (가로 기준선 표시) - const { indicatorSettings: contextIndicatorSettings } = useIndicatorSettings(); - const effectiveIndicatorSettings = indicatorSettings ?? contextIndicatorSettings; - - // ✅ maLines, ichimokuSettings, ichimokuColors를 직렬화하여 안정적인 참조 생성 - const maLinesKey = useMemo(() => JSON.stringify(maLines), [maLines]); - const ichimokuSettingsKey = useMemo(() => JSON.stringify(ichimokuSettings), [ichimokuSettings]); - const ichimokuColorsKey = useMemo(() => JSON.stringify(ichimokuColors), [ichimokuColors]); - // ✅ effectiveIndicatorSettings 직렬화 - object 참조 변경 시 과도한 서브차트 effect 재실행 방지 - const effectiveIndicatorSettingsKey = useMemo( - () => (effectiveIndicatorSettings ? JSON.stringify(effectiveIndicatorSettings) : ''), - [effectiveIndicatorSettings] - ); - const overlaySettingsOverrideKey = useMemo( - () => JSON.stringify(overlaySettingsOverride ?? {}), - [overlaySettingsOverride] - ); - /** 알림·내비 마커만 바뀔 때 메인 차트 effect가 재실행되지 않아 setMarkers가 스킵되는 것을 방지 */ - const alertMarkersKey = useMemo(() => JSON.stringify(alertMarkers ?? []), [alertMarkers]); - const dataForMarkersRef = useRef(data); - dataForMarkersRef.current = data; - - /** - * 투자분석: 백테스트/투자실행 후 isBuyPoint·isSellPoint만 바뀌고 length·첫봉 time은 그대로인 경우가 많음. - * 메인 차트 effect가 재실행되지 않아 setMarkers가 스킵되던 문제를 이 키로 감지한다 (실시간 OHLC만 바뀌면 키 불변). - */ - const dataTradeMarkerSyncKey = useMemo(() => { - const parts: string[] = []; - for (let i = 0; i < data.length; i++) { - const d = data[i] as CandleData; - if (d.isBuyPoint) parts.push(`B:${String(d.time)}`); - if (d.isSellPoint) parts.push(`S:${String(d.time)}`); - } - return parts.join('|'); - }, [data]); - - /** 줌/스크롤 시 updateTradeMarkerPositions가 최신 매수·매도 시점을 읽도록 ref 유지 */ - const tradeMarkerOverlaySpecsRef = useRef>([]); - const uniqueDataForTradeOverlayRef = useRef([]); - - // showIndicatorToggle이 명시적으로 설정되지 않으면 showIndicatorOverlay 값을 따름 - const shouldShowToggle = showIndicatorToggle !== undefined ? showIndicatorToggle : showIndicatorOverlay; - const chartContainerRef = useRef(null); - const chartRef = useRef(null); - const candleSeriesRef = useRef | null>(null); - // ✅ 서브차트 refs (TradingView 스타일) - const subChartContainersRef = useRef<{ [key: string]: HTMLDivElement | null }>({}); - const subChartsRef = useRef<{ [key: string]: IChartApi | null }>({}); - const subSeriesRef = useRef<{ [key: string]: any }>({}); - /** 선행스팬 ON일 때는 메인(플레이스홀더 봉)·서브(실봉만) 논리봉 수가 달라 “시간 기준” 동기화가 필수. 구독 핸들러·setTimeout은 effect 클로저보다 매 렌더 갱신 ref로 최신 모드를 읽는다 */ - const senkouSpanActiveForSubSyncRef = useRef(false); - senkouSpanActiveForSubSyncRef.current = !!(ichimokuSettings?.senkouA || ichimokuSettings?.senkouB); - const isSettingRangeRef = useRef(false); - const isSettingExternalCrosshairRef = useRef(false); // 외부 크로스헤어 설정 중 플래그 - - // ✅ 실시간 업데이트용: 이동평균선, 볼린저밴드 시리즈 저장 - const maSeriesMapRef = useRef>>(new Map()); - const bollingerSeriesRef = useRef<{ - upper?: ISeriesApi<'Line'>; - middle?: ISeriesApi<'Line'>; - lower?: ISeriesApi<'Line'>; - }>({}); - const mainChartTimeRangeRef = useRef<{ from: Time; to: Time } | null>(null); // ✅ 메인 차트 시간 범위 저장 - // ✅ 마우스 휠 줌 기능 제거 (하단 슬라이더로 대체) - const markersRef = useRef([]); // ✅ 마커 데이터를 ref로 저장 (줌인/줌아웃 시 유지) - const activeToolRef = useRef(activeTool); - - // ✅ 업비트 스타일 크로스헤어: 전역 수직선 + 개별 차트 수평선 - const [globalCrosshairX, setGlobalCrosshairX] = useState(null); - const [activeCrosshairChart, setActiveCrosshairChart] = useState(null); // 'main' or panel.type - const chartMainContainerRef = useRef(null); // 전체 차트 영역 ref - - // ✅ 차트 내비게이션 툴바 표시 여부 (모바일에서는 숨김) - const [showNavigationToolbar, setShowNavigationToolbar] = useState(!isMobileMode); - - // ✅ 보조지표 최대화 상태 관리 - const [maximizedPanel, setMaximizedPanel] = useState(null); - // ✅ 캔들 영역 리사이즈 중 상태 (드래그 핸들 시각 피드백) - const [isCandleResizing, setIsCandleResizing] = useState(false); - - // ✅ 드래그 중 상태 관리 (차트 갱신 방지용) - ref로 관리하여 useEffect dependency 문제 방지 - const isDraggingRef = useRef(false); - const isResizingRef = useRef(false); - - // ✅ @dnd-kit 센서 설정 (드래그 앤 드롭) - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, // 8px 이상 움직여야 드래그 시작 (클릭과 구분) - }, - }) - ); // ✅ 활성 툴 ref - const lastClickTimeRef = useRef<{ time: number; timestamp: string; tradeType: 'BUY' | 'SELL' } | null>(null); // ✅ 더블클릭 감지용 - const isChartReadyRef = useRef(false); // ✅ 차트 준비 완료 플래그 - const pendingCrosshairRef = useRef