앱 수정
This commit is contained in:
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.goldenchart.app"
|
applicationId "com.goldenchart.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName "1.0"
|
versionName "1.1"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+63
-36
@@ -1,14 +1,16 @@
|
|||||||
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
|
import React, { lazy, Suspense, useEffect, useState } from 'react';
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
import { SplashScreen } from '@capacitor/splash-screen';
|
import { SplashScreen as CapSplashScreen } from '@capacitor/splash-screen';
|
||||||
import { initStorage } from './lib/shared';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
|
import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
|
||||||
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
|
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
|
||||||
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
|
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
|
||||||
import TabBar, { type TabId } from './components/TabBar';
|
import TabBar, { type TabId } from './components/TabBar';
|
||||||
import { initFcmPush, type FcmPayload } from './services/fcm';
|
import { initFcmPush, type FcmPayload } from './services/fcm';
|
||||||
import LiveSignalBridge from './components/LiveSignalBridge';
|
import LiveSignalBridge from './components/LiveSignalBridge';
|
||||||
|
import LoginScreen from './screens/LoginScreen';
|
||||||
|
import '@frontend/styles/splashScreen.css';
|
||||||
import './theme/global.css';
|
import './theme/global.css';
|
||||||
|
|
||||||
const VirtualTradingScreen = lazy(() => import('./screens/virtual/VirtualTradingScreen'));
|
const VirtualTradingScreen = lazy(() => import('./screens/virtual/VirtualTradingScreen'));
|
||||||
@@ -33,35 +35,21 @@ function ToastStack() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppInner() {
|
function MainApp() {
|
||||||
const { tab, setTab, openVirtualFocus } = useNavigation();
|
const { tab, setTab, openVirtualFocus, goVirtualList, goNotifyList } = useNavigation();
|
||||||
const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
|
const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
|
||||||
const { settings, isLoaded } = useAppSettings();
|
const { sessionKey } = useAuth();
|
||||||
|
const { settings, isLoaded } = useAppSettings(sessionKey);
|
||||||
const defaults = resolveAppDefaults(settings);
|
const defaults = resolveAppDefaults(settings);
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void (async () => {
|
|
||||||
await initStorage();
|
|
||||||
if (Capacitor.isNativePlatform()) {
|
|
||||||
try {
|
|
||||||
await StatusBar.setStyle({ style: Style.Dark });
|
|
||||||
await StatusBar.setBackgroundColor({ color: '#0f0f23' });
|
|
||||||
await SplashScreen.hide();
|
|
||||||
} catch { /* web */ }
|
|
||||||
}
|
|
||||||
setReady(true);
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoaded || !defaults.fcmPushEnabled) return;
|
if (!isLoaded || !defaults.fcmPushEnabled) return;
|
||||||
void initFcmPush({
|
void initFcmPush({
|
||||||
onForeground: (title, body, data) => {
|
onForeground: (_title, _body, data) => {
|
||||||
if (data?.market && data?.signalType) {
|
if (data?.market && data?.signalType) {
|
||||||
addNotification({
|
addNotification({
|
||||||
market: data.market,
|
market: data.market,
|
||||||
signalType: data.signalType,
|
signalType: data.signalType === 'SELL' ? 'SELL' : 'BUY',
|
||||||
price: Number(data.price) || 0,
|
price: Number(data.price) || 0,
|
||||||
candleTime: Math.floor(Date.now() / 1000),
|
candleTime: Math.floor(Date.now() / 1000),
|
||||||
dbId: data.signalId ? Number(data.signalId) : undefined,
|
dbId: data.signalId ? Number(data.signalId) : undefined,
|
||||||
@@ -85,9 +73,10 @@ function AppInner() {
|
|||||||
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
||||||
}, [defaults.theme]);
|
}, [defaults.theme]);
|
||||||
|
|
||||||
if (!ready) {
|
useEffect(() => {
|
||||||
return <div className="loading-center">GoldenChart</div>;
|
if (tab === 'virtual') goNotifyList();
|
||||||
}
|
else if (tab === 'notifications') goVirtualList();
|
||||||
|
}, [tab, goVirtualList, goNotifyList]);
|
||||||
|
|
||||||
const screens: Record<TabId, React.ReactNode> = {
|
const screens: Record<TabId, React.ReactNode> = {
|
||||||
virtual: <VirtualTradingScreen />,
|
virtual: <VirtualTradingScreen />,
|
||||||
@@ -105,21 +94,59 @@ function AppInner() {
|
|||||||
{screens[tab]}
|
{screens[tab]}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
<TabBar
|
<TabBar active={tab} onChange={setTab} unreadCount={unreadCount} />
|
||||||
active={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppRoot() {
|
||||||
|
const {
|
||||||
|
authReady,
|
||||||
|
isAppEntered,
|
||||||
|
handleLoginSuccess,
|
||||||
|
handleGuestEnter,
|
||||||
|
} = useAuth();
|
||||||
|
const [nativeReady, setNativeReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
try {
|
||||||
|
await StatusBar.setStyle({ style: Style.Dark });
|
||||||
|
await StatusBar.setBackgroundColor({ color: '#0f0f23' });
|
||||||
|
await CapSplashScreen.hide();
|
||||||
|
} catch { /* web dev */ }
|
||||||
|
}
|
||||||
|
setNativeReady(true);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!authReady || !nativeReady) {
|
||||||
|
return <div className="loading-center">GoldenChart</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAppEntered) {
|
||||||
|
return (
|
||||||
|
<LoginScreen
|
||||||
|
onLoginSuccess={handleLoginSuccess}
|
||||||
|
onGuest={handleGuestEnter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationProvider>
|
||||||
|
<TradeNotificationProvider soundEnabled popupEnabled={false}>
|
||||||
|
<MainApp />
|
||||||
|
</TradeNotificationProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<NavigationProvider>
|
<AuthProvider>
|
||||||
<TradeNotificationProvider soundEnabled={true}>
|
<AppRoot />
|
||||||
<AppInner />
|
</AuthProvider>
|
||||||
</TradeNotificationProvider>
|
|
||||||
</NavigationProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onBack: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileStackHeader({ title, subtitle, onBack, right }: Props) {
|
||||||
|
return (
|
||||||
|
<header className="screen-header mobile-stack-header">
|
||||||
|
<button type="button" className="mobile-back-btn" onClick={onBack} aria-label="목록으로">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div className="mobile-stack-header-titles">
|
||||||
|
<h1 className="screen-title mobile-stack-title">{title}</h1>
|
||||||
|
{subtitle && <p className="text-muted mobile-stack-subtitle">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{right && <div className="mobile-stack-header-right">{right}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
clearAuthSession,
|
||||||
|
fetchAuthMe,
|
||||||
|
getAuthSession,
|
||||||
|
initStorage,
|
||||||
|
setAuthSession,
|
||||||
|
type AuthSession,
|
||||||
|
type LoginResponse,
|
||||||
|
} from '../lib/shared';
|
||||||
|
import { invalidateAppSettingsCache } from '../hooks/useAppSettings';
|
||||||
|
|
||||||
|
function normalizeRole(role: string): AuthSession['role'] {
|
||||||
|
return role === 'ADMIN' ? 'ADMIN' : 'USER';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginToSession(res: LoginResponse): AuthSession {
|
||||||
|
return {
|
||||||
|
userId: res.userId,
|
||||||
|
username: res.username,
|
||||||
|
displayName: res.displayName ?? res.username,
|
||||||
|
role: normalizeRole(res.role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
authReady: boolean;
|
||||||
|
authUser: AuthSession | null;
|
||||||
|
guestMode: boolean;
|
||||||
|
sessionKey: number;
|
||||||
|
isAppEntered: boolean;
|
||||||
|
handleLoginSuccess: (res: LoginResponse) => void;
|
||||||
|
handleGuestEnter: () => void;
|
||||||
|
handleLogout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
const [authUser, setAuthUser] = useState<AuthSession | null>(null);
|
||||||
|
const [guestMode, setGuestMode] = useState(false);
|
||||||
|
const [sessionKey, setSessionKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
await initStorage();
|
||||||
|
const stored = getAuthSession();
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const me = await fetchAuthMe();
|
||||||
|
if (me && !cancelled) {
|
||||||
|
const session = loginToSession(me);
|
||||||
|
setAuthSession(session);
|
||||||
|
setAuthUser(session);
|
||||||
|
setGuestMode(false);
|
||||||
|
setSessionKey(k => k + 1);
|
||||||
|
} else if (!cancelled) {
|
||||||
|
await clearAuthSession();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAuthUser(stored);
|
||||||
|
setGuestMode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) setAuthReady(true);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSuccess = useCallback((res: LoginResponse) => {
|
||||||
|
const session = loginToSession(res);
|
||||||
|
setAuthSession(session);
|
||||||
|
setAuthUser(session);
|
||||||
|
setGuestMode(false);
|
||||||
|
invalidateAppSettingsCache();
|
||||||
|
setSessionKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGuestEnter = useCallback(() => {
|
||||||
|
void clearAuthSession();
|
||||||
|
setAuthUser(null);
|
||||||
|
setGuestMode(true);
|
||||||
|
invalidateAppSettingsCache();
|
||||||
|
setSessionKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
await clearAuthSession();
|
||||||
|
setAuthUser(null);
|
||||||
|
setGuestMode(false);
|
||||||
|
invalidateAppSettingsCache();
|
||||||
|
setSessionKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(() => ({
|
||||||
|
authReady,
|
||||||
|
authUser,
|
||||||
|
guestMode,
|
||||||
|
sessionKey,
|
||||||
|
isAppEntered: authUser != null || guestMode,
|
||||||
|
handleLoginSuccess,
|
||||||
|
handleGuestEnter,
|
||||||
|
handleLogout,
|
||||||
|
}), [
|
||||||
|
authReady,
|
||||||
|
authUser,
|
||||||
|
guestMode,
|
||||||
|
sessionKey,
|
||||||
|
handleLoginSuccess,
|
||||||
|
handleGuestEnter,
|
||||||
|
handleLogout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,30 +1,116 @@
|
|||||||
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
||||||
import type { TabId } from '../components/TabBar';
|
import type { TabId } from '../components/TabBar';
|
||||||
|
|
||||||
|
export type VirtualView = 'list' | 'detail' | 'trade' | 'history';
|
||||||
|
export type NotifyView = 'list' | 'detail' | 'trade';
|
||||||
|
export type TradeSide = 'BUY' | 'SELL';
|
||||||
|
|
||||||
|
export interface VirtualNavState {
|
||||||
|
view: VirtualView;
|
||||||
|
market: string | null;
|
||||||
|
tradeSide: TradeSide | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyNavState {
|
||||||
|
view: NotifyView;
|
||||||
|
notifyId: string | null;
|
||||||
|
market: string | null;
|
||||||
|
tradeSide: TradeSide | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface NavContextValue {
|
interface NavContextValue {
|
||||||
tab: TabId;
|
tab: TabId;
|
||||||
setTab: (t: TabId) => void;
|
setTab: (t: TabId) => void;
|
||||||
focusMarket: string | null;
|
virtualNav: VirtualNavState;
|
||||||
|
goVirtualList: () => void;
|
||||||
|
goVirtualDetail: (market: string) => void;
|
||||||
|
goVirtualTrade: (market: string, side: TradeSide) => void;
|
||||||
|
goVirtualHistory: (market: string) => void;
|
||||||
|
/** @deprecated use goVirtualDetail */
|
||||||
openVirtualFocus: (market: string) => void;
|
openVirtualFocus: (market: string) => void;
|
||||||
clearVirtualFocus: () => void;
|
clearVirtualFocus: () => void;
|
||||||
|
notifyNav: NotifyNavState;
|
||||||
|
goNotifyList: () => void;
|
||||||
|
goNotifyDetail: (notifyId: string, market: string) => void;
|
||||||
|
goNotifyTrade: (market: string, side: TradeSide) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultVirtual: VirtualNavState = { view: 'list', market: null, tradeSide: null };
|
||||||
|
const defaultNotify: NotifyNavState = { view: 'list', notifyId: null, market: null, tradeSide: null };
|
||||||
|
|
||||||
const NavContext = createContext<NavContextValue | null>(null);
|
const NavContext = createContext<NavContextValue | null>(null);
|
||||||
|
|
||||||
export function NavigationProvider({ children }: { children: React.ReactNode }) {
|
export function NavigationProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [tab, setTab] = useState<TabId>('virtual');
|
const [tab, setTab] = useState<TabId>('virtual');
|
||||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
const [virtualNav, setVirtualNav] = useState<VirtualNavState>(defaultVirtual);
|
||||||
|
const [notifyNav, setNotifyNav] = useState<NotifyNavState>(defaultNotify);
|
||||||
|
|
||||||
const openVirtualFocus = useCallback((market: string) => {
|
const goVirtualList = useCallback(() => {
|
||||||
setFocusMarket(market);
|
setVirtualNav(defaultVirtual);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goVirtualDetail = useCallback((market: string) => {
|
||||||
|
setVirtualNav({ view: 'detail', market, tradeSide: null });
|
||||||
setTab('virtual');
|
setTab('virtual');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearVirtualFocus = useCallback(() => setFocusMarket(null), []);
|
const goVirtualTrade = useCallback((market: string, side: TradeSide) => {
|
||||||
|
setVirtualNav({ view: 'trade', market, tradeSide: side });
|
||||||
|
setTab('virtual');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goVirtualHistory = useCallback((market: string) => {
|
||||||
|
setVirtualNav({ view: 'history', market, tradeSide: null });
|
||||||
|
setTab('virtual');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openVirtualFocus = goVirtualDetail;
|
||||||
|
const clearVirtualFocus = goVirtualList;
|
||||||
|
|
||||||
|
const goNotifyList = useCallback(() => {
|
||||||
|
setNotifyNav(defaultNotify);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goNotifyDetail = useCallback((notifyId: string, market: string) => {
|
||||||
|
setNotifyNav({ view: 'detail', notifyId, market, tradeSide: null });
|
||||||
|
setTab('notifications');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goNotifyTrade = useCallback((market: string, side: TradeSide) => {
|
||||||
|
setNotifyNav({ view: 'trade', notifyId: null, market, tradeSide: side });
|
||||||
|
setTab('notifications');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ tab, setTab, focusMarket, openVirtualFocus, clearVirtualFocus }),
|
() => ({
|
||||||
[tab, focusMarket, openVirtualFocus, clearVirtualFocus],
|
tab,
|
||||||
|
setTab,
|
||||||
|
virtualNav,
|
||||||
|
goVirtualList,
|
||||||
|
goVirtualDetail,
|
||||||
|
goVirtualTrade,
|
||||||
|
goVirtualHistory,
|
||||||
|
openVirtualFocus,
|
||||||
|
clearVirtualFocus,
|
||||||
|
notifyNav,
|
||||||
|
goNotifyList,
|
||||||
|
goNotifyDetail,
|
||||||
|
goNotifyTrade,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
tab,
|
||||||
|
virtualNav,
|
||||||
|
notifyNav,
|
||||||
|
goVirtualList,
|
||||||
|
goVirtualDetail,
|
||||||
|
goVirtualTrade,
|
||||||
|
goVirtualHistory,
|
||||||
|
openVirtualFocus,
|
||||||
|
clearVirtualFocus,
|
||||||
|
goNotifyList,
|
||||||
|
goNotifyDetail,
|
||||||
|
goNotifyTrade,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
||||||
|
|||||||
@@ -1,216 +1,6 @@
|
|||||||
import {
|
/** 웹 frontend와 동일한 알림 Context (STOMP·trade-signals·uiPreferences) */
|
||||||
deleteAllTradeSignals,
|
export {
|
||||||
deleteTradeSignal,
|
TradeNotificationProvider,
|
||||||
deleteTradeSignalsBatch,
|
useTradeNotification,
|
||||||
loadTradeSignals,
|
type TradeNotificationItem,
|
||||||
type TradeSignalDto,
|
} from '@frontend/contexts/TradeNotificationContext';
|
||||||
} from '../lib/shared';
|
|
||||||
import { getUiPreferences, patchUiPreferences } from '@frontend/utils/uiPreferencesDb';
|
|
||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export interface TradeSignalInfo {
|
|
||||||
market: string;
|
|
||||||
signalType: string;
|
|
||||||
price: number;
|
|
||||||
candleTime: number;
|
|
||||||
strategyName?: string;
|
|
||||||
strategyId?: number;
|
|
||||||
executionType?: string;
|
|
||||||
candleType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TradeNotificationItem extends TradeSignalInfo {
|
|
||||||
id: string;
|
|
||||||
dbId?: number;
|
|
||||||
isRead: boolean;
|
|
||||||
receivedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TradeNotificationContextValue {
|
|
||||||
toastNotifications: TradeNotificationItem[];
|
|
||||||
allNotifications: TradeNotificationItem[];
|
|
||||||
unreadCount: number;
|
|
||||||
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
|
|
||||||
dismissToast: (id: string) => void;
|
|
||||||
dismissAllToasts: () => void;
|
|
||||||
markAsRead: (id: string) => void;
|
|
||||||
markAllAsRead: () => void;
|
|
||||||
deleteNotification: (id: string) => Promise<void>;
|
|
||||||
deleteAllNotifications: () => Promise<void>;
|
|
||||||
refreshHistory: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
|
|
||||||
|
|
||||||
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
|
|
||||||
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReadIds(): Set<string> {
|
|
||||||
return new Set(getUiPreferences().tradeNotifications?.readIds ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveReadIds(ids: Set<string>) {
|
|
||||||
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadHiddenIds(): Set<string> {
|
|
||||||
return new Set(getUiPreferences().tradeNotifications?.hiddenIds ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveHiddenIds(ids: Set<string>) {
|
|
||||||
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotificationItem {
|
|
||||||
const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
dbId: dto.id,
|
|
||||||
market: dto.market,
|
|
||||||
signalType: dto.signalType,
|
|
||||||
price: dto.price,
|
|
||||||
candleTime: dto.candleTime,
|
|
||||||
strategyName: dto.strategyName,
|
|
||||||
strategyId: dto.strategyId,
|
|
||||||
executionType: dto.executionType,
|
|
||||||
candleType: dto.candleType,
|
|
||||||
isRead: readIds.has(id),
|
|
||||||
receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTradeNotification() {
|
|
||||||
const ctx = useContext(TradeNotificationContext);
|
|
||||||
if (!ctx) throw new Error('useTradeNotification requires provider');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TradeNotificationProvider({
|
|
||||||
children,
|
|
||||||
soundEnabled = true,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
soundEnabled?: boolean;
|
|
||||||
}) {
|
|
||||||
const [toasts, setToasts] = useState<TradeNotificationItem[]>([]);
|
|
||||||
const [all, setAll] = useState<TradeNotificationItem[]>([]);
|
|
||||||
const readRef = useRef(loadReadIds());
|
|
||||||
const hiddenRef = useRef(loadHiddenIds());
|
|
||||||
|
|
||||||
const refreshHistory = useCallback(async () => {
|
|
||||||
readRef.current = loadReadIds();
|
|
||||||
hiddenRef.current = loadHiddenIds();
|
|
||||||
const dtos = await loadTradeSignals();
|
|
||||||
const items = (dtos ?? [])
|
|
||||||
.map(d => dtoToItem(d, readRef.current))
|
|
||||||
.filter(i => !hiddenRef.current.has(i.id))
|
|
||||||
.sort((a, b) => b.receivedAt - a.receivedAt);
|
|
||||||
setAll(items);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refreshHistory();
|
|
||||||
const t = setInterval(() => void refreshHistory(), 20000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, [refreshHistory]);
|
|
||||||
|
|
||||||
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
|
|
||||||
const id = makeId(signal);
|
|
||||||
if (hiddenRef.current.has(id)) return;
|
|
||||||
const item: TradeNotificationItem = {
|
|
||||||
...signal,
|
|
||||||
id,
|
|
||||||
isRead: readRef.current.has(id),
|
|
||||||
receivedAt: Date.now(),
|
|
||||||
};
|
|
||||||
setAll(prev => {
|
|
||||||
const filtered = prev.filter(p => p.id !== id);
|
|
||||||
return [item, ...filtered].slice(0, 300);
|
|
||||||
});
|
|
||||||
if (!item.isRead) {
|
|
||||||
setToasts(prev => [item, ...prev.filter(p => p.id !== id)].slice(0, 50));
|
|
||||||
if (soundEnabled && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
|
||||||
navigator.vibrate(80);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [soundEnabled]);
|
|
||||||
|
|
||||||
const dismissToast = useCallback((id: string) => {
|
|
||||||
setToasts(prev => prev.filter(t => t.id !== id));
|
|
||||||
readRef.current.add(id);
|
|
||||||
saveReadIds(readRef.current);
|
|
||||||
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dismissAllToasts = useCallback(() => {
|
|
||||||
setToasts(prev => {
|
|
||||||
prev.forEach(t => readRef.current.add(t.id));
|
|
||||||
saveReadIds(readRef.current);
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markAsRead = useCallback((id: string) => {
|
|
||||||
readRef.current.add(id);
|
|
||||||
saveReadIds(readRef.current);
|
|
||||||
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
|
|
||||||
setToasts(prev => prev.filter(t => t.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markAllAsRead = useCallback(() => {
|
|
||||||
all.forEach(n => readRef.current.add(n.id));
|
|
||||||
saveReadIds(readRef.current);
|
|
||||||
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
|
|
||||||
setToasts([]);
|
|
||||||
}, [all]);
|
|
||||||
|
|
||||||
const deleteNotification = useCallback(async (id: string) => {
|
|
||||||
const item = all.find(n => n.id === id);
|
|
||||||
hiddenRef.current.add(id);
|
|
||||||
saveHiddenIds(hiddenRef.current);
|
|
||||||
if (item?.dbId) await deleteTradeSignal(item.dbId);
|
|
||||||
setAll(prev => prev.filter(n => n.id !== id));
|
|
||||||
setToasts(prev => prev.filter(t => t.id !== id));
|
|
||||||
}, [all]);
|
|
||||||
|
|
||||||
const deleteAllNotifications = useCallback(async () => {
|
|
||||||
await deleteAllTradeSignals();
|
|
||||||
all.forEach(n => hiddenRef.current.add(n.id));
|
|
||||||
saveHiddenIds(hiddenRef.current);
|
|
||||||
setAll([]);
|
|
||||||
setToasts([]);
|
|
||||||
}, [all]);
|
|
||||||
|
|
||||||
const unreadCount = useMemo(() => all.filter(n => !n.isRead).length, [all]);
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
toastNotifications: toasts,
|
|
||||||
allNotifications: all,
|
|
||||||
unreadCount,
|
|
||||||
addNotification,
|
|
||||||
dismissToast,
|
|
||||||
dismissAllToasts,
|
|
||||||
markAsRead,
|
|
||||||
markAllAsRead,
|
|
||||||
deleteNotification,
|
|
||||||
deleteAllNotifications,
|
|
||||||
refreshHistory,
|
|
||||||
}),
|
|
||||||
[toasts, all, unreadCount, addNotification, dismissToast, dismissAllToasts, markAsRead, markAllAsRead, deleteNotification, deleteAllNotifications, refreshHistory],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TradeNotificationContext.Provider value={value}>{children}</TradeNotificationContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** 웹 SplashScreen과 동일한 로그인 진입 화면 */
|
||||||
|
import SplashScreen from '@frontend/components/SplashScreen';
|
||||||
|
import type { LoginResponse } from '../lib/shared';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLoginSuccess: (res: LoginResponse) => void;
|
||||||
|
onGuest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||||
|
return <SplashScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
||||||
|
import type { TradeSide } from '../../contexts/NavigationContext';
|
||||||
|
import {
|
||||||
|
buildSignalDetailRows,
|
||||||
|
formatSignalPrice,
|
||||||
|
getSignalHeadline,
|
||||||
|
} from '@frontend/utils/tradeSignalDisplay';
|
||||||
|
import MobileStackHeader from '../../components/MobileStackHeader';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: TradeNotificationItem;
|
||||||
|
onBack: () => void;
|
||||||
|
onTrade: (side: TradeSide) => void;
|
||||||
|
onGoVirtual: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationDetailScreen({ item, onBack, onTrade, onGoVirtual }: Props) {
|
||||||
|
const rows = buildSignalDetailRows(item);
|
||||||
|
const side = item.signalType === 'SELL' ? 'SELL' : 'BUY';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen stack-screen">
|
||||||
|
<MobileStackHeader title="알림 상세" subtitle={getSignalHeadline(item)} onBack={onBack} />
|
||||||
|
|
||||||
|
<div className="stack-screen-body">
|
||||||
|
<div className="mobile-list-row-actions" style={{ marginBottom: 16 }}>
|
||||||
|
<button type="button" className="btn-secondary mobile-action-btn" onClick={onGoVirtual}>
|
||||||
|
가상매매
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
|
||||||
|
매수
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 16, textAlign: 'center', padding: 20 }}>
|
||||||
|
<div className={`${side === 'BUY' ? 'text-green' : 'text-red'}`} style={{ fontSize: 28, fontWeight: 800 }}>
|
||||||
|
{side === 'BUY' ? '매수' : '매도'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, marginTop: 8 }}>{formatSignalPrice(item.price)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stack-list">
|
||||||
|
{rows.map(row => (
|
||||||
|
<div key={row.label} className="card mobile-detail-row">
|
||||||
|
<span className="text-muted" style={{ fontSize: 12 }}>{row.label}</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 500, marginTop: 4, display: 'block' }}>{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
||||||
|
import type { TradeSide } from '../../contexts/NavigationContext';
|
||||||
|
import { getMarketDisplayLine } from '@frontend/utils/tradeSignalDisplay';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: TradeNotificationItem;
|
||||||
|
onDetail: () => void;
|
||||||
|
onTrade: (side: TradeSide) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationListRow({ item, onDetail, onTrade, onDelete }: Props) {
|
||||||
|
const { primary, secondary } = getMarketDisplayLine(item.market);
|
||||||
|
const isBuy = item.signalType === 'BUY';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`mobile-list-row card${item.isRead ? '' : ' mobile-list-row--unread'}`}
|
||||||
|
style={{ borderLeft: `3px solid ${isBuy ? 'var(--gc-green)' : 'var(--gc-red)'}` }}
|
||||||
|
>
|
||||||
|
<div className="mobile-list-row-main">
|
||||||
|
<div className="mobile-list-row-info">
|
||||||
|
<div className="mobile-list-row-title">
|
||||||
|
<span className={isBuy ? 'text-green' : 'text-red'} style={{ fontWeight: 700 }}>
|
||||||
|
{isBuy ? '매수' : '매도'}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 6 }}>{primary}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted mobile-list-row-meta">
|
||||||
|
{secondary} · ₩{item.price?.toLocaleString()} · {new Date(item.receivedAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
{item.strategyName && (
|
||||||
|
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>{item.strategyName}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="icon-btn icon-btn--danger" onClick={onDelete} aria-label="삭제">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-list-row-actions">
|
||||||
|
<button type="button" className="btn-secondary mobile-action-btn" onClick={onDetail}>
|
||||||
|
상세보기
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
|
||||||
|
매수
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
||||||
import { useNavigation } from '../../contexts/NavigationContext';
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
||||||
|
import NotificationListRow from './NotificationListRow';
|
||||||
|
import NotificationDetailScreen from './NotificationDetailScreen';
|
||||||
|
import VirtualTradeScreen from '../virtual/VirtualTradeScreen';
|
||||||
|
|
||||||
export default function NotificationsScreen() {
|
export default function NotificationsScreen() {
|
||||||
|
const {
|
||||||
|
notifyNav,
|
||||||
|
goNotifyList,
|
||||||
|
goNotifyDetail,
|
||||||
|
goNotifyTrade,
|
||||||
|
goVirtualDetail,
|
||||||
|
} = useNavigation();
|
||||||
|
const { sessionKey } = useAuth();
|
||||||
const {
|
const {
|
||||||
allNotifications,
|
allNotifications,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
@@ -12,8 +25,14 @@ export default function NotificationsScreen() {
|
|||||||
deleteAllNotifications,
|
deleteAllNotifications,
|
||||||
refreshHistory,
|
refreshHistory,
|
||||||
} = useTradeNotification();
|
} = useTradeNotification();
|
||||||
const { openVirtualFocus } = useNavigation();
|
const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey });
|
||||||
const [refreshing, setRefreshing] = React.useState(false);
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null,
|
||||||
|
[allNotifications, notifyNav.notifyId],
|
||||||
|
);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
@@ -21,13 +40,42 @@ export default function NotificationsScreen() {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (notifyNav.view === 'detail' && selectedItem) {
|
||||||
|
return (
|
||||||
|
<NotificationDetailScreen
|
||||||
|
item={selectedItem}
|
||||||
|
onBack={() => {
|
||||||
|
markAsRead(selectedItem.id);
|
||||||
|
goNotifyList();
|
||||||
|
}}
|
||||||
|
onTrade={side => goNotifyTrade(selectedItem.market, side)}
|
||||||
|
onGoVirtual={() => {
|
||||||
|
markAsRead(selectedItem.id);
|
||||||
|
goVirtualDetail(selectedItem.market);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifyNav.view === 'trade' && notifyNav.market && notifyNav.tradeSide) {
|
||||||
|
return (
|
||||||
|
<VirtualTradeScreen
|
||||||
|
market={notifyNav.market}
|
||||||
|
side={notifyNav.tradeSide}
|
||||||
|
summary={summary}
|
||||||
|
onBack={goNotifyList}
|
||||||
|
onOrderDone={refreshPaperData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen">
|
<div className="screen">
|
||||||
<header className="screen-header">
|
<header className="screen-header">
|
||||||
<h1 className="screen-title">알림 {unreadCount > 0 && `(${unreadCount})`}</h1>
|
<h1 className="screen-title">알림 {unreadCount > 0 && `(${unreadCount})`}</h1>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '새로고침'}</button>
|
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '↻'}</button>
|
||||||
<button type="button" className="chip" onClick={markAllAsRead}>모두 읽음</button>
|
<button type="button" className="chip" onClick={markAllAsRead}>읽음</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -37,47 +85,21 @@ export default function NotificationsScreen() {
|
|||||||
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div className="stack-list" style={{ padding: '0 16px' }}>
|
||||||
{allNotifications.map(item => (
|
{allNotifications.map(item => (
|
||||||
<div
|
<NotificationListRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="card"
|
item={item}
|
||||||
style={{
|
onDetail={() => {
|
||||||
padding: 14,
|
|
||||||
opacity: item.isRead ? 0.65 : 1,
|
|
||||||
borderLeft: `3px solid ${item.signalType === 'BUY' ? 'var(--gc-green)' : 'var(--gc-red)'}`,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
markAsRead(item.id);
|
markAsRead(item.id);
|
||||||
openVirtualFocus(item.market);
|
goNotifyDetail(item.id, item.market);
|
||||||
}}
|
}}
|
||||||
role="button"
|
onTrade={side => {
|
||||||
tabIndex={0}
|
markAsRead(item.id);
|
||||||
>
|
goNotifyTrade(item.market, side);
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
}}
|
||||||
<div>
|
onDelete={() => void deleteNotification(item.id)}
|
||||||
<span className={item.signalType === 'BUY' ? 'text-green' : 'text-red'} style={{ fontWeight: 700 }}>
|
/>
|
||||||
{item.signalType === 'BUY' ? '매수' : '매도'}
|
|
||||||
</span>
|
|
||||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>{item.market}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="chip"
|
|
||||||
style={{ minHeight: 28, padding: '4px 8px', color: 'var(--gc-red)' }}
|
|
||||||
onClick={e => { e.stopPropagation(); void deleteNotification(item.id); }}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 14, marginTop: 4 }}>₩{item.price?.toLocaleString()}</div>
|
|
||||||
{item.strategyName && (
|
|
||||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>{item.strategyName}</div>
|
|
||||||
)}
|
|
||||||
<div className="text-muted" style={{ fontSize: 10, marginTop: 4 }}>
|
|
||||||
{new Date(item.receivedAt).toLocaleString('ko-KR')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
loginUser,
|
|
||||||
resetPaperAccount,
|
resetPaperAccount,
|
||||||
sendFcmTest,
|
sendFcmTest,
|
||||||
loadFcmStatus,
|
loadFcmStatus,
|
||||||
@@ -8,23 +7,19 @@ import {
|
|||||||
getStoredDeviceId,
|
getStoredDeviceId,
|
||||||
type AppSettingsDto,
|
type AppSettingsDto,
|
||||||
} from '../../lib/shared';
|
} from '../../lib/shared';
|
||||||
import { getAuthSession, setAuthSession, clearAuthSession } from '../../lib/shared';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||||
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
||||||
import { initFcmPush, checkPushPermission } from '../../services/fcm';
|
import { initFcmPush, checkPushPermission } from '../../services/fcm';
|
||||||
import { API_BASE } from '../../lib/shared';
|
import { API_BASE } from '../../lib/shared';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { settings, save, isLoaded } = useAppSettings();
|
const { authUser, guestMode, sessionKey, handleLogout } = useAuth();
|
||||||
|
const { settings, save, isLoaded } = useAppSettings(sessionKey);
|
||||||
const defaults = resolveAppDefaults(settings);
|
const defaults = resolveAppDefaults(settings);
|
||||||
const session = getAuthSession();
|
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [apiUrl, setApiUrl] = useState(API_BASE);
|
const [apiUrl, setApiUrl] = useState(API_BASE);
|
||||||
const [fcmAvailable, setFcmAvailable] = useState(false);
|
const [fcmAvailable, setFcmAvailable] = useState(false);
|
||||||
const [pushPerm, setPushPerm] = useState<string>('prompt');
|
const [pushPerm, setPushPerm] = useState<string>('prompt');
|
||||||
const [loginError, setLoginError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
||||||
@@ -33,34 +28,8 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
|
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const onLogout = () => {
|
||||||
setLoginError('');
|
void handleLogout();
|
||||||
try {
|
|
||||||
const res = await loginUser(username, password);
|
|
||||||
setAuthSession({
|
|
||||||
userId: res.userId,
|
|
||||||
username: res.username,
|
|
||||||
displayName: res.displayName ?? res.username,
|
|
||||||
role: res.role === 'ADMIN' ? 'ADMIN' : 'USER',
|
|
||||||
});
|
|
||||||
setUsername('');
|
|
||||||
setPassword('');
|
|
||||||
} catch (e) {
|
|
||||||
setLoginError(e instanceof Error ? e.message : '로그인 실패');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await clearAuthSession();
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFcmToggle = async (enabled: boolean) => {
|
|
||||||
patch({ fcmPushEnabled: enabled });
|
|
||||||
if (enabled) {
|
|
||||||
await initFcmPush();
|
|
||||||
setPushPerm(await checkPushPermission());
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isLoaded) return <div className="loading-center">설정 로딩…</div>;
|
if (!isLoaded) return <div className="loading-center">설정 로딩…</div>;
|
||||||
@@ -71,6 +40,33 @@ export default function SettingsScreen() {
|
|||||||
<h1 className="screen-title">설정</h1>
|
<h1 className="screen-title">설정</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SettingGroup title="계정">
|
||||||
|
{authUser ? (
|
||||||
|
<>
|
||||||
|
<SettingRow label="로그인">
|
||||||
|
<span>{authUser.displayName} ({authUser.username})</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="역할">
|
||||||
|
<span className="text-muted">{authUser.role}</span>
|
||||||
|
</SettingRow>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SettingRow label="모드">
|
||||||
|
<span className="text-muted">게스트 (기기별 데이터)</span>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
<SettingRow label="로그아웃">
|
||||||
|
<button type="button" className="btn-secondary" onClick={onLogout}>
|
||||||
|
{authUser ? '로그아웃' : '로그인 화면으로'}
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
{!guestMode && authUser && (
|
||||||
|
<p className="text-muted" style={{ padding: '0 16px 8px', fontSize: 12, lineHeight: 1.5 }}>
|
||||||
|
가상매매·알림·설정은 웹(exdev)과 동일한 계정 DB를 사용합니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
<SettingGroup title="일반">
|
<SettingGroup title="일반">
|
||||||
<SettingRow label="테마" description="앱 색상">
|
<SettingRow label="테마" description="앱 색상">
|
||||||
<select
|
<select
|
||||||
@@ -138,7 +134,14 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
<SettingGroup title="FCM 푸시">
|
<SettingGroup title="FCM 푸시">
|
||||||
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
|
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
|
||||||
<Toggle checked={!!defaults.fcmPushEnabled} onChange={v => void handleFcmToggle(v)} label="FCM 푸시" />
|
<Toggle
|
||||||
|
checked={!!defaults.fcmPushEnabled}
|
||||||
|
onChange={v => {
|
||||||
|
patch({ fcmPushEnabled: v });
|
||||||
|
if (v) void initFcmPush().then(() => checkPushPermission().then(setPushPerm));
|
||||||
|
}}
|
||||||
|
label="FCM 푸시"
|
||||||
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="권한 상태">
|
<SettingRow label="권한 상태">
|
||||||
<span className="text-muted">{pushPerm}</span>
|
<span className="text-muted">{pushPerm}</span>
|
||||||
@@ -148,7 +151,7 @@ export default function SettingsScreen() {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
|
|
||||||
<SettingGroup title="네트워크 · 계정">
|
<SettingGroup title="네트워크">
|
||||||
<SettingRow label="API URL">
|
<SettingRow label="API URL">
|
||||||
<input
|
<input
|
||||||
value={apiUrl}
|
value={apiUrl}
|
||||||
@@ -162,24 +165,6 @@ export default function SettingsScreen() {
|
|||||||
<SettingRow label="Device ID">
|
<SettingRow label="Device ID">
|
||||||
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{session ? (
|
|
||||||
<SettingRow label={`${session.displayName} (${session.username})`}>
|
|
||||||
<button type="button" className="btn-secondary" onClick={() => void handleLogout()}>로그아웃</button>
|
|
||||||
</SettingRow>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SettingRow label="아이디">
|
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="비밀번호">
|
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="로그인">
|
|
||||||
<button type="button" className="btn-primary" style={{ padding: '8px 16px' }} onClick={() => void handleLogin()}>로그인</button>
|
|
||||||
</SettingRow>
|
|
||||||
{loginError && <div className="text-red" style={{ padding: '8px 16px', fontSize: 12 }}>{loginError}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React from 'react';
|
|||||||
import type { StrategyDto } from '../../lib/shared';
|
import type { StrategyDto } from '../../lib/shared';
|
||||||
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
||||||
import type { VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
|
import type { VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
|
||||||
|
import type { TradeSide } from '../../contexts/NavigationContext';
|
||||||
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
|
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
|
||||||
|
import MobileStackHeader from '../../components/MobileStackHeader';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
target: VirtualTargetItem;
|
target: VirtualTargetItem;
|
||||||
@@ -11,7 +13,8 @@ interface Props {
|
|||||||
snapshot?: VirtualIndicatorSnapshot;
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
liveConnected: boolean;
|
liveConnected: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onOpenTrade: () => void;
|
onTrade: (side: TradeSide) => void;
|
||||||
|
onHistory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VirtualFocusScreen({
|
export default function VirtualFocusScreen({
|
||||||
@@ -19,21 +22,34 @@ export default function VirtualFocusScreen({
|
|||||||
snapshot,
|
snapshot,
|
||||||
liveConnected,
|
liveConnected,
|
||||||
onBack,
|
onBack,
|
||||||
onOpenTrade,
|
onTrade,
|
||||||
|
onHistory,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
|
const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
|
||||||
const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
|
const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
|
||||||
const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
|
const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen focus-screen">
|
<div className="screen stack-screen">
|
||||||
<header className="screen-header">
|
<MobileStackHeader
|
||||||
<button type="button" onClick={onBack} style={{ fontSize: 24, minWidth: 44 }}>←</button>
|
title={target.koreanName ?? target.market}
|
||||||
<h1 className="screen-title" style={{ flex: 1 }}>{target.market}</h1>
|
subtitle={target.market}
|
||||||
<button type="button" className="btn-primary" onClick={onOpenTrade}>매매</button>
|
onBack={onBack}
|
||||||
</header>
|
/>
|
||||||
|
|
||||||
|
<div className="stack-screen-body">
|
||||||
|
<div className="mobile-list-row-actions" style={{ marginBottom: 16 }}>
|
||||||
|
<button type="button" className="btn-secondary mobile-action-btn" onClick={onHistory}>
|
||||||
|
거래내역
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
|
||||||
|
매수
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: 16 }}>
|
|
||||||
<div className="card" style={{ marginBottom: 16, textAlign: 'center' }}>
|
<div className="card" style={{ marginBottom: 16, textAlign: 'center' }}>
|
||||||
<div className="text-muted" style={{ fontSize: 12 }}>일치율</div>
|
<div className="text-muted" style={{ fontSize: 12 }}>일치율</div>
|
||||||
<div style={{ fontSize: 48, fontWeight: 700, color: 'var(--gc-accent)' }}>
|
<div style={{ fontSize: 48, fontWeight: 700, color: 'var(--gc-accent)' }}>
|
||||||
@@ -56,9 +72,10 @@ export default function VirtualFocusScreen({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>조건 목록</h3>
|
<h3 style={{ fontSize: 14, marginBottom: 8 }}>조건 목록</h3>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="stack-list">
|
||||||
{(snapshot?.rows ?? []).map(row => (
|
{(snapshot?.rows ?? []).map(row => (
|
||||||
<div key={row.id} className="card" style={{ padding: 10, display: 'flex', justifyContent: 'space-between' }}>
|
<div key={row.id} className="card mobile-history-row">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{row.displayName}</div>
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{row.displayName}</div>
|
||||||
<div className="text-muted" style={{ fontSize: 11 }}>{row.side} · {row.timeframe}</div>
|
<div className="text-muted" style={{ fontSize: 11 }}>{row.side} · {row.timeframe}</div>
|
||||||
@@ -67,6 +84,7 @@ export default function VirtualFocusScreen({
|
|||||||
{row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
|
{row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { PaperTradeDto } from '../../lib/shared';
|
||||||
|
import MobileStackHeader from '../../components/MobileStackHeader';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
market: string;
|
||||||
|
trades: PaperTradeDto[];
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualHistoryScreen({ market, trades, onBack }: Props) {
|
||||||
|
const filtered = trades.filter(t => t.symbol === market).slice(0, 50);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen stack-screen">
|
||||||
|
<MobileStackHeader title="거래 내역" subtitle={market} onBack={onBack} />
|
||||||
|
<div className="stack-screen-body">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>내역 없음</h3>
|
||||||
|
<p>이 종목의 모의 거래 내역이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="stack-list">
|
||||||
|
{filtered.map(t => (
|
||||||
|
<div key={t.id} className="card mobile-history-row">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span className={t.side === 'BUY' ? 'text-green' : 'text-red'} style={{ fontWeight: 700 }}>
|
||||||
|
{t.side === 'BUY' ? '매수' : '매도'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{t.quantity} @ ₩{t.price?.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11, marginTop: 6 }}>{t.createdAt ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import type { StrategyDto } from '../../lib/shared';
|
|
||||||
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
|
||||||
import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
|
|
||||||
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
|
|
||||||
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
|
||||||
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
target: VirtualTargetItem;
|
|
||||||
session: VirtualSessionConfig;
|
|
||||||
strategies: StrategyDto[];
|
|
||||||
snapshot?: VirtualIndicatorSnapshot;
|
|
||||||
viewMode: VirtualCardViewMode;
|
|
||||||
liveFlash?: boolean;
|
|
||||||
liveStatus?: VirtualLiveStatus;
|
|
||||||
onFocus: () => void;
|
|
||||||
onTrade: () => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
onTogglePin: () => void;
|
|
||||||
onStrategyChange: (id: number | null) => void;
|
|
||||||
onCandleTypeChange: (ct: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VirtualTargetCardMobile({
|
|
||||||
target,
|
|
||||||
session,
|
|
||||||
strategies,
|
|
||||||
snapshot,
|
|
||||||
viewMode,
|
|
||||||
liveFlash,
|
|
||||||
liveStatus,
|
|
||||||
onFocus,
|
|
||||||
onTrade,
|
|
||||||
onRemove,
|
|
||||||
onTogglePin,
|
|
||||||
onStrategyChange,
|
|
||||||
}: Props) {
|
|
||||||
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
|
||||||
const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
|
|
||||||
const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
|
|
||||||
const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
|
|
||||||
const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className={`v-card card${liveFlash ? ' flash' : ''}`}
|
|
||||||
onClick={onFocus}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div className="v-card-top">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{target.koreanName ?? target.market}</div>
|
|
||||||
<div className="text-muted" style={{ fontSize: 11 }}>{target.market}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<span className={`live-dot ${liveStatus ?? 'idle'}`} />
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--gc-accent)' }}>{(snapshot?.matchRate ?? buyPct).toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="v-card-eq">
|
|
||||||
<div className="eq-bar">
|
|
||||||
<div className="eq-fill buy" style={{ width: `${buyPct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="eq-bar">
|
|
||||||
<div className="eq-fill sell" style={{ width: `${sellPct}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="v-card-meta text-muted" style={{ fontSize: 11 }}>
|
|
||||||
{strategyName} · {snapshot?.timeframe ?? '—'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{viewMode === 'detail' && snapshot?.rows && (
|
|
||||||
<div className="v-card-conditions">
|
|
||||||
{snapshot.rows.slice(0, 6).map(row => (
|
|
||||||
<div key={row.id} className="cond-row">
|
|
||||||
<span>{row.displayName}</span>
|
|
||||||
<span className={row.satisfied ? 'text-green' : row.satisfied === false ? 'text-red' : ''}>
|
|
||||||
{row.satisfied === true ? '✓' : row.satisfied === false ? '✗' : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="v-card-actions" onClick={e => e.stopPropagation()}>
|
|
||||||
<select
|
|
||||||
className="chip"
|
|
||||||
value={strategyId ?? ''}
|
|
||||||
onChange={e => onStrategyChange(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
style={{ flex: 1, fontSize: 11 }}
|
|
||||||
>
|
|
||||||
<option value="">기본 전략</option>
|
|
||||||
{strategies.map(s => (
|
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={onTrade}>매매</button>
|
|
||||||
<button type="button" className="btn-secondary" style={{ padding: '8px 10px', minHeight: 36 }} onClick={onTogglePin}>{target.pinned ? '📌' : '○'}</button>
|
|
||||||
{!target.pinned && (
|
|
||||||
<button type="button" className="btn-danger" style={{ padding: '8px 10px', minHeight: 36 }} onClick={onRemove}>✕</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.v-card { cursor: pointer; transition: box-shadow 0.2s; }
|
|
||||||
.v-card.flash { box-shadow: 0 0 0 2px var(--gc-accent); }
|
|
||||||
.v-card-top { display: flex; justify-content: space-between; margin-bottom: 10px; }
|
|
||||||
.live-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px; height: 8px; border-radius: 50%;
|
|
||||||
background: var(--gc-text-dim); margin-right: 4px;
|
|
||||||
}
|
|
||||||
.live-dot.live { background: var(--gc-green); }
|
|
||||||
.live-dot.connecting { background: var(--gc-orange); }
|
|
||||||
.live-dot.disconnected { background: var(--gc-red); }
|
|
||||||
.v-card-eq { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
|
|
||||||
.eq-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; }
|
|
||||||
.eq-fill.buy { height: 100%; background: var(--gc-green); }
|
|
||||||
.eq-fill.sell { height: 100%; background: var(--gc-red); }
|
|
||||||
.v-card-conditions { margin: 8px 0; font-size: 11px; }
|
|
||||||
.cond-row { display: flex; justify-content: space-between; padding: 2px 0; }
|
|
||||||
.v-card-actions { display: flex; gap: 6px; margin-top: 10px; align-items: center; }
|
|
||||||
`}</style>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { StrategyDto } from '../../lib/shared';
|
||||||
|
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
|
||||||
|
import type { VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
|
||||||
|
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||||
|
import type { TradeSide } from '../../contexts/NavigationContext';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
target: VirtualTargetItem;
|
||||||
|
globalStrategyId: number | null;
|
||||||
|
strategies: StrategyDto[];
|
||||||
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
|
liveStatus?: VirtualLiveStatus;
|
||||||
|
onDetail: () => void;
|
||||||
|
onTrade: (side: TradeSide) => void;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualTargetListRow({
|
||||||
|
target,
|
||||||
|
globalStrategyId,
|
||||||
|
strategies,
|
||||||
|
snapshot,
|
||||||
|
liveStatus,
|
||||||
|
onDetail,
|
||||||
|
onTrade,
|
||||||
|
onTogglePin,
|
||||||
|
onRemove,
|
||||||
|
}: Props) {
|
||||||
|
const strategyId = resolveVirtualTargetStrategyId(target, globalStrategyId);
|
||||||
|
const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
|
||||||
|
const matchPct = snapshot?.matchRate ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mobile-list-row card">
|
||||||
|
<div className="mobile-list-row-main">
|
||||||
|
<div className="mobile-list-row-info">
|
||||||
|
<div className="mobile-list-row-title">
|
||||||
|
<span className={`live-dot ${liveStatus ?? 'idle'}`} aria-hidden />
|
||||||
|
<span>{target.koreanName ?? target.market}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted mobile-list-row-meta">
|
||||||
|
{target.market} · {strategyName} · {matchPct.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-list-row-pin">
|
||||||
|
<button type="button" className="icon-btn" onClick={onTogglePin} aria-label="고정">
|
||||||
|
{target.pinned ? '📌' : '○'}
|
||||||
|
</button>
|
||||||
|
{!target.pinned && (
|
||||||
|
<button type="button" className="icon-btn icon-btn--danger" onClick={onRemove} aria-label="삭제">✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-list-row-actions">
|
||||||
|
<button type="button" className="btn-secondary mobile-action-btn" onClick={onDetail}>
|
||||||
|
상세보기
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
|
||||||
|
매수
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,29 +5,34 @@ import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
|||||||
interface Props {
|
interface Props {
|
||||||
market: string;
|
market: string;
|
||||||
summary: PaperSummaryDto | null;
|
summary: PaperSummaryDto | null;
|
||||||
|
/** 단일 매수/매도 화면 또는 둘 다 */
|
||||||
|
side?: 'BUY' | 'SELL' | 'both';
|
||||||
onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise<void>;
|
onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
|
export default function VirtualTradePanel({ market, summary, side = 'both', onOrder }: Props) {
|
||||||
const [qty, setQty] = useState('0.001');
|
const [qty, setQty] = useState('0.001');
|
||||||
const [price, setPrice] = useState('');
|
const [price, setPrice] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const position = summary?.positions?.find(p => p.symbol === market);
|
const position = summary?.positions?.find(p => p.symbol === market);
|
||||||
|
|
||||||
const submit = async (side: 'BUY' | 'SELL') => {
|
const submit = async (orderSide: 'BUY' | 'SELL') => {
|
||||||
const q = parseFloat(qty);
|
const q = parseFloat(qty);
|
||||||
const p = parseFloat(price) || 0;
|
const p = parseFloat(price) || 0;
|
||||||
if (!Number.isFinite(q) || q <= 0) return;
|
if (!Number.isFinite(q) || q <= 0) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await onOrder(side, q, p);
|
await onOrder(orderSide, q, p);
|
||||||
try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ }
|
try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ }
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showBuy = side === 'both' || side === 'BUY';
|
||||||
|
const showSell = side === 'both' || side === 'SELL';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="trade-panel">
|
<div className="trade-panel">
|
||||||
{position && (
|
{position && (
|
||||||
@@ -37,12 +42,7 @@ export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>수량</label>
|
<label style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>수량</label>
|
||||||
<input
|
<input type="number" value={qty} onChange={e => setQty(e.target.value)} style={inputStyle} />
|
||||||
type="number"
|
|
||||||
value={qty}
|
|
||||||
onChange={e => setQty(e.target.value)}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
<label style={{ display: 'block', margin: '12px 0 8px', fontSize: 13 }}>가격 (0=시장가)</label>
|
<label style={{ display: 'block', margin: '12px 0 8px', fontSize: 13 }}>가격 (0=시장가)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -51,13 +51,29 @@ export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
|
|||||||
placeholder="시장가"
|
placeholder="시장가"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
|
<div className="trade-panel-actions" style={{ display: 'flex', gap: 10, marginTop: 16 }}>
|
||||||
<button type="button" className="btn-primary" style={{ flex: 1, background: 'var(--gc-green)' }} disabled={busy} onClick={() => void submit('BUY')}>
|
{showBuy && (
|
||||||
매수
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary mobile-action-btn--buy"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void submit('BUY')}
|
||||||
|
>
|
||||||
|
매수 체결
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-danger" style={{ flex: 1 }} disabled={busy} onClick={() => void submit('SELL')}>
|
)}
|
||||||
매도
|
{showSell && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void submit('SELL')}
|
||||||
|
>
|
||||||
|
매도 체결
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { placePaperOrder, type PaperSummaryDto } from '../../lib/shared';
|
||||||
|
import type { TradeSide } from '../../contexts/NavigationContext';
|
||||||
|
import MobileStackHeader from '../../components/MobileStackHeader';
|
||||||
|
import VirtualTradePanel from './VirtualTradePanel';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
market: string;
|
||||||
|
side: TradeSide;
|
||||||
|
summary: PaperSummaryDto | null;
|
||||||
|
onBack: () => void;
|
||||||
|
onOrderDone: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualTradeScreen({ market, side, summary, onBack, onOrderDone }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="screen stack-screen">
|
||||||
|
<MobileStackHeader
|
||||||
|
title={side === 'BUY' ? '매수' : '매도'}
|
||||||
|
subtitle={market}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
<div className="stack-screen-body">
|
||||||
|
<VirtualTradePanel
|
||||||
|
market={market}
|
||||||
|
summary={summary}
|
||||||
|
side={side}
|
||||||
|
onOrder={async (orderSide, qty, price) => {
|
||||||
|
await placePaperOrder({ market, side: orderSide, price, quantity: qty });
|
||||||
|
await onOrderDone();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,183 +1,103 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
loadPaperSummary,
|
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
||||||
loadPaperTrades,
|
|
||||||
loadStrategies,
|
|
||||||
placePaperOrder,
|
|
||||||
resetPaperAccount,
|
|
||||||
type PaperSummaryDto,
|
|
||||||
type PaperTradeDto,
|
|
||||||
type StrategyDto,
|
|
||||||
} from '../../lib/shared';
|
|
||||||
import { useVirtualIndicatorSnapshots } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
|
||||||
import { useVirtualAutoTrade } from '@frontend/hooks/useVirtualAutoTrade';
|
|
||||||
import { useVirtualTargetLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
|
|
||||||
import {
|
|
||||||
loadVirtualSession,
|
|
||||||
loadVirtualTargets,
|
|
||||||
saveVirtualSession,
|
|
||||||
saveVirtualTargets,
|
|
||||||
loadVirtualCardViewMode,
|
|
||||||
saveVirtualCardViewMode,
|
|
||||||
type VirtualSessionConfig,
|
|
||||||
type VirtualTargetItem,
|
|
||||||
type VirtualCardViewMode,
|
|
||||||
resolveTargetCandleType,
|
|
||||||
} from '@frontend/utils/virtualTradingStorage';
|
|
||||||
import {
|
|
||||||
syncVirtualTargetsToBackend,
|
|
||||||
stopVirtualLiveOnBackend,
|
|
||||||
} from '@frontend/utils/virtualLiveStrategySync';
|
|
||||||
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||||
import { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits';
|
|
||||||
import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations';
|
|
||||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
|
||||||
import { useNavigation } from '../../contexts/NavigationContext';
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
import BottomSheet from '../../components/BottomSheet';
|
import BottomSheet from '../../components/BottomSheet';
|
||||||
import SegmentedControl from '../../components/SegmentedControl';
|
import SegmentedControl from '../../components/SegmentedControl';
|
||||||
import VirtualTargetCardMobile from './VirtualTargetCardMobile';
|
import VirtualTargetListRow from './VirtualTargetListRow';
|
||||||
import VirtualFocusScreen from './VirtualFocusScreen';
|
import VirtualFocusScreen from './VirtualFocusScreen';
|
||||||
import VirtualTradePanel from './VirtualTradePanel';
|
import VirtualTradeScreen from './VirtualTradeScreen';
|
||||||
|
import VirtualHistoryScreen from './VirtualHistoryScreen';
|
||||||
type RightTab = 'trade' | 'history';
|
import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel';
|
||||||
|
|
||||||
export default function VirtualTradingScreen() {
|
export default function VirtualTradingScreen() {
|
||||||
const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation();
|
const {
|
||||||
const { settings } = useAppSettings();
|
virtualNav,
|
||||||
const defaults = resolveAppDefaults(settings);
|
goVirtualList,
|
||||||
|
goVirtualDetail,
|
||||||
|
goVirtualTrade,
|
||||||
|
goVirtualHistory,
|
||||||
|
} = useNavigation();
|
||||||
|
const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
|
||||||
|
|
||||||
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
|
const core = useVirtualTradingCore({ settingsSessionKey });
|
||||||
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
|
const {
|
||||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
|
||||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
|
||||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedMarket, setSelectedMarket] = useState('KRW-BTC');
|
|
||||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
|
||||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
|
||||||
const [newMarket, setNewMarket] = useState('');
|
|
||||||
|
|
||||||
const refreshSummary = useCallback(async () => {
|
|
||||||
const [s, t] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
|
||||||
setSummary(s);
|
|
||||||
setTrades(t ?? []);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void Promise.all([
|
|
||||||
loadStrategies().then(setStrategies).catch(() => []),
|
|
||||||
refreshSummary(),
|
|
||||||
]).finally(() => setLoading(false));
|
|
||||||
}, [refreshSummary]);
|
|
||||||
|
|
||||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
|
||||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
|
||||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
|
||||||
|
|
||||||
const targetRefs = useMemo(
|
|
||||||
() => targets.map(t => ({
|
|
||||||
market: t.market,
|
|
||||||
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
|
||||||
})),
|
|
||||||
[targets, session.globalStrategyId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const snapshots = useVirtualIndicatorSnapshots(
|
|
||||||
targetRefs,
|
|
||||||
strategies as Parameters<typeof useVirtualIndicatorSnapshots>[1],
|
|
||||||
session.running,
|
|
||||||
);
|
|
||||||
|
|
||||||
const liveStatus = useVirtualTargetLiveStatus(targetRefs, session.running);
|
|
||||||
|
|
||||||
const liveConnected = useMemo(
|
|
||||||
() => Object.values(liveStatus.statusByMarket).some(s => s === 'live' || s === 'connecting'),
|
|
||||||
[liveStatus.statusByMarket],
|
|
||||||
);
|
|
||||||
|
|
||||||
useVirtualAutoTrade({
|
|
||||||
targets,
|
targets,
|
||||||
session,
|
session,
|
||||||
|
strategies,
|
||||||
|
summary,
|
||||||
|
trades,
|
||||||
|
loading,
|
||||||
snapshots,
|
snapshots,
|
||||||
enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled,
|
liveStatusByMarket,
|
||||||
paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95,
|
handleStart,
|
||||||
positions: summary?.positions,
|
handleStop,
|
||||||
onFilled: refreshSummary,
|
handleRemoveTarget,
|
||||||
});
|
handleTogglePin,
|
||||||
|
handleGlobalStrategyChange,
|
||||||
|
handleExecutionTypeChange,
|
||||||
|
handlePositionModeChange,
|
||||||
|
addTarget,
|
||||||
|
reloadTargetsFromStorage,
|
||||||
|
} = core;
|
||||||
|
|
||||||
const toggleRunning = useCallback(async () => {
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||||
const next = { ...session, running: !session.running };
|
|
||||||
setSession(next);
|
|
||||||
if (next.running) {
|
|
||||||
await syncVirtualTargetsToBackend(targets, next, true);
|
|
||||||
} else {
|
|
||||||
await stopVirtualLiveOnBackend(targets, next);
|
|
||||||
}
|
|
||||||
}, [session, targets]);
|
|
||||||
|
|
||||||
const handleAddTarget = useCallback(() => {
|
|
||||||
const market = newMarket.trim().toUpperCase();
|
|
||||||
if (!market) return;
|
|
||||||
if (targets.some(t => t.market === market)) return;
|
|
||||||
if (!isVirtualTargetAddAllowed(targets.length, defaults.virtualTargetMaxCount)) {
|
|
||||||
alert(virtualTargetLimitMessage(defaults.virtualTargetMaxCount));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTargets(prev => [...prev, { market, strategyId: null }]);
|
|
||||||
setNewMarket('');
|
|
||||||
setAddSheetOpen(false);
|
|
||||||
}, [newMarket, targets, defaults.virtualTargetMaxCount]);
|
|
||||||
|
|
||||||
const handleRemoveTarget = useCallback((market: string) => {
|
|
||||||
const t = targets.find(x => x.market === market);
|
|
||||||
if (t?.pinned) return;
|
|
||||||
setTargets(prev => prev.filter(x => x.market !== market));
|
|
||||||
}, [targets]);
|
|
||||||
|
|
||||||
const handleTogglePin = useCallback(async (market: string) => {
|
|
||||||
const t = targets.find(x => x.market === market);
|
|
||||||
if (!t) return;
|
|
||||||
const pinned = !t.pinned;
|
|
||||||
setTargets(prev => prev.map(x => (x.market === market ? { ...x, pinned } : x)));
|
|
||||||
await persistVirtualTargetPinned(market, pinned);
|
|
||||||
}, [targets]);
|
|
||||||
|
|
||||||
const pnlPct = summary?.totalReturnPct ?? 0;
|
const pnlPct = summary?.totalReturnPct ?? 0;
|
||||||
|
const liveConnected = Object.values(liveStatusByMarket).some(s => s === 'live' || s === 'connecting');
|
||||||
|
|
||||||
if (focusMarket) {
|
const market = virtualNav.market;
|
||||||
const target = targets.find(t => t.market === focusMarket);
|
const target = market ? targets.find(t => t.market === market) : undefined;
|
||||||
if (target) {
|
|
||||||
|
if (virtualNav.view === 'detail' && market && target) {
|
||||||
|
const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||||
|
const snap = snapshots[`${market}:${sid ?? ''}`] ?? snapshots[market];
|
||||||
return (
|
return (
|
||||||
<VirtualFocusScreen
|
<VirtualFocusScreen
|
||||||
target={target}
|
target={target}
|
||||||
session={session}
|
session={session}
|
||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
snapshot={(() => {
|
snapshot={snap}
|
||||||
const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
liveConnected={liveConnected}
|
||||||
return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market];
|
onBack={goVirtualList}
|
||||||
})()}
|
onTrade={side => goVirtualTrade(market, side)}
|
||||||
liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')}
|
onHistory={() => goVirtualHistory(market)}
|
||||||
onBack={clearVirtualFocus}
|
|
||||||
onOpenTrade={() => {
|
|
||||||
setSelectedMarket(focusMarket);
|
|
||||||
setSheetOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
clearVirtualFocus();
|
|
||||||
|
if (virtualNav.view === 'trade' && market && virtualNav.tradeSide) {
|
||||||
|
return (
|
||||||
|
<VirtualTradeScreen
|
||||||
|
market={market}
|
||||||
|
side={virtualNav.tradeSide}
|
||||||
|
summary={summary}
|
||||||
|
onBack={goVirtualList}
|
||||||
|
onOrderDone={core.refreshPaperData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (virtualNav.view === 'history' && market) {
|
||||||
|
return (
|
||||||
|
<VirtualHistoryScreen
|
||||||
|
market={market}
|
||||||
|
trades={trades}
|
||||||
|
onBack={goVirtualList}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen virtual-screen">
|
<div className="screen virtual-screen">
|
||||||
<header className="screen-header">
|
<header className="screen-header">
|
||||||
<h1 className="screen-title">가상매매</h1>
|
<h1 className="screen-title">가상매매</h1>
|
||||||
|
<button type="button" className="chip" onClick={() => reloadTargetsFromStorage()} title="동기화">↻</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn-primary session-btn${session.running ? ' running' : ''}`}
|
className={`btn-primary session-btn${session.running ? ' running' : ''}`}
|
||||||
onClick={() => void toggleRunning()}
|
onClick={() => void (session.running ? handleStop() : handleStart())}
|
||||||
>
|
>
|
||||||
{session.running ? '■ 중지' : '▶ 시작'}
|
{session.running ? '■ 중지' : '▶ 시작'}
|
||||||
</button>
|
</button>
|
||||||
@@ -187,9 +107,7 @@ export default function VirtualTradingScreen() {
|
|||||||
<div className="virtual-summary-row">
|
<div className="virtual-summary-row">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted" style={{ fontSize: 12 }}>총 자산</div>
|
<div className="text-muted" style={{ fontSize: 12 }}>총 자산</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 700 }}>
|
<div style={{ fontSize: 22, fontWeight: 700 }}>₩{(summary?.totalAsset ?? 0).toLocaleString()}</div>
|
||||||
₩{(summary?.totalAsset ?? 0).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div className="text-muted" style={{ fontSize: 12 }}>수익률</div>
|
<div className="text-muted" style={{ fontSize: 12 }}>수익률</div>
|
||||||
@@ -207,7 +125,7 @@ export default function VirtualTradingScreen() {
|
|||||||
<select
|
<select
|
||||||
className="chip"
|
className="chip"
|
||||||
value={session.globalStrategyId ?? ''}
|
value={session.globalStrategyId ?? ''}
|
||||||
onChange={e => setSession(s => ({ ...s, globalStrategyId: e.target.value ? Number(e.target.value) : null }))}
|
onChange={e => handleGlobalStrategyChange(e.target.value ? Number(e.target.value) : null)}
|
||||||
style={{ maxWidth: 160 }}
|
style={{ maxWidth: 160 }}
|
||||||
>
|
>
|
||||||
<option value="">전략 선택</option>
|
<option value="">전략 선택</option>
|
||||||
@@ -222,7 +140,7 @@ export default function VirtualTradingScreen() {
|
|||||||
{ value: 'REALTIME_TICK' as const, label: '실시간' },
|
{ value: 'REALTIME_TICK' as const, label: '실시간' },
|
||||||
]}
|
]}
|
||||||
value={session.executionType}
|
value={session.executionType}
|
||||||
onChange={v => setSession(s => ({ ...s, executionType: v }))}
|
onChange={handleExecutionTypeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,16 +152,7 @@ export default function VirtualTradingScreen() {
|
|||||||
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
|
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
|
||||||
]}
|
]}
|
||||||
value={session.positionMode}
|
value={session.positionMode}
|
||||||
onChange={v => setSession(s => ({ ...s, positionMode: v }))}
|
onChange={handlePositionModeChange}
|
||||||
/>
|
|
||||||
<SegmentedControl
|
|
||||||
size="sm"
|
|
||||||
options={[
|
|
||||||
{ value: 'summary' as const, label: '요약' },
|
|
||||||
{ value: 'detail' as const, label: '상세' },
|
|
||||||
]}
|
|
||||||
value={viewMode}
|
|
||||||
onChange={(v: VirtualCardViewMode) => setViewMode(v)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -252,30 +161,30 @@ export default function VirtualTradingScreen() {
|
|||||||
) : targets.length === 0 ? (
|
) : targets.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>투자 대상 없음</h3>
|
<h3>투자 대상 없음</h3>
|
||||||
<p>+ 버튼으로 종목을 추가하세요</p>
|
{guestMode ? (
|
||||||
|
<p>게스트 모드입니다. 웹과 같은 목록을 보려면 동일 계정으로 로그인하세요.</p>
|
||||||
|
) : (
|
||||||
|
<p>웹과 동일 계정({authUser?.username}) · + 버튼으로 종목 추가</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="virtual-cards" style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="stack-list" style={{ padding: '0 16px' }}>
|
||||||
{targets.map(target => {
|
{targets.map(t => {
|
||||||
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
|
||||||
const snapKey = `${target.market}:${strategyId ?? ''}`;
|
const snapKey = `${t.market}:${strategyId ?? ''}`;
|
||||||
const snap = snapshots[snapKey] ?? snapshots[target.market];
|
const snap = snapshots[snapKey] ?? snapshots[t.market];
|
||||||
return (
|
return (
|
||||||
<VirtualTargetCardMobile
|
<VirtualTargetListRow
|
||||||
key={target.market}
|
key={t.market}
|
||||||
target={target}
|
target={t}
|
||||||
session={session}
|
globalStrategyId={session.globalStrategyId}
|
||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
snapshot={snap}
|
snapshot={snap}
|
||||||
viewMode={viewMode}
|
liveStatus={liveStatusByMarket[t.market]}
|
||||||
liveFlash={!!liveStatus.lastTickAtByMarket[target.market]}
|
onDetail={() => goVirtualDetail(t.market)}
|
||||||
liveStatus={liveStatus.statusByMarket[target.market]}
|
onTrade={side => goVirtualTrade(t.market, side)}
|
||||||
onFocus={() => openVirtualFocus(target.market)}
|
onTogglePin={() => void handleTogglePin(t.market)}
|
||||||
onTrade={() => { setSelectedMarket(target.market); setSheetOpen(true); }}
|
onRemove={() => handleRemoveTarget(t.market)}
|
||||||
onRemove={() => handleRemoveTarget(target.market)}
|
|
||||||
onTogglePin={() => void handleTogglePin(target.market)}
|
|
||||||
onStrategyChange={sid => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, strategyId: sid } : t))}
|
|
||||||
onCandleTypeChange={ct => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, candleType: ct } : t))}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -284,51 +193,18 @@ export default function VirtualTradingScreen() {
|
|||||||
|
|
||||||
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
|
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
|
||||||
|
|
||||||
<BottomSheet open={addSheetOpen} title="종목 추가" onClose={() => setAddSheetOpen(false)}>
|
<BottomSheet open={addSheetOpen} title="종목 검색" onClose={() => setAddSheetOpen(false)} height="full">
|
||||||
<input
|
<div style={{ height: 'min(70vh, 520px)', overflow: 'hidden' }}>
|
||||||
type="text"
|
<MarketSearchPanel
|
||||||
placeholder="KRW-BTC"
|
variant="embedded"
|
||||||
value={newMarket}
|
currentMarket={targets[0]?.market ?? 'KRW-BTC'}
|
||||||
onChange={e => setNewMarket(e.target.value)}
|
onSelect={m => {
|
||||||
style={{
|
addTarget(m);
|
||||||
width: '100%',
|
setAddSheetOpen(false);
|
||||||
padding: '12px 14px',
|
|
||||||
borderRadius: 10,
|
|
||||||
border: '1px solid var(--gc-border)',
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
}}
|
||||||
|
onClose={() => setAddSheetOpen(false)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn-primary" style={{ width: '100%' }} onClick={handleAddTarget}>추가</button>
|
|
||||||
</BottomSheet>
|
|
||||||
|
|
||||||
<BottomSheet open={sheetOpen} title={selectedMarket} onClose={() => setSheetOpen(false)} height="half">
|
|
||||||
<div className="chip-row" style={{ padding: '0 0 12px' }}>
|
|
||||||
<button type="button" className={`chip${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
|
||||||
<button type="button" className={`chip${rightTab === 'history' ? ' active' : ''}`} onClick={() => setRightTab('history')}>내역</button>
|
|
||||||
</div>
|
</div>
|
||||||
{rightTab === 'trade' ? (
|
|
||||||
<VirtualTradePanel
|
|
||||||
market={selectedMarket}
|
|
||||||
summary={summary}
|
|
||||||
onOrder={async (side, qty, price) => {
|
|
||||||
await placePaperOrder({ market: selectedMarket, side, price, quantity: qty });
|
|
||||||
await refreshSummary();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => (
|
|
||||||
<div key={t.id} className="card" style={{ padding: 12 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<span className={t.side === 'BUY' ? 'text-green' : 'text-red'}>{t.side}</span>
|
|
||||||
<span>{t.quantity} @ ₩{t.price?.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>{t.createdAt ?? ''}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|||||||
@@ -222,3 +222,144 @@ a {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--gc-text-muted);
|
color: var(--gc-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 모바일 목록 → 상세/매매 스택 ── */
|
||||||
|
.stack-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-screen-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stack-header {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-back-btn {
|
||||||
|
font-size: 22px;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--gc-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stack-header-titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stack-title {
|
||||||
|
font-size: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stack-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row--unread {
|
||||||
|
background: rgba(139, 92, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row-pin {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list-row-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-btn {
|
||||||
|
padding: 10px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-btn--buy {
|
||||||
|
background: var(--gc-green);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--danger {
|
||||||
|
color: var(--gc-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gc-text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot.live { background: var(--gc-green); }
|
||||||
|
.live-dot.connecting { background: var(--gc-orange); }
|
||||||
|
.live-dot.disconnected { background: var(--gc-red); }
|
||||||
|
|
||||||
|
.mobile-history-row,
|
||||||
|
.mobile-detail-row {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* 가상매매 공통 상태·동기화 (웹 VirtualTradingPage · 모바일 앱 공유)
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { TickerData } from './useMarketTicker';
|
||||||
|
import {
|
||||||
|
loadLiveStrategySettings,
|
||||||
|
loadPaperSummary,
|
||||||
|
loadPaperTrades,
|
||||||
|
loadStrategies,
|
||||||
|
saveLiveStrategySettings,
|
||||||
|
type PaperSummaryDto,
|
||||||
|
type PaperTradeDto,
|
||||||
|
type StrategyDto,
|
||||||
|
} from '../utils/backendApi';
|
||||||
|
import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots';
|
||||||
|
import { useVirtualAutoTrade } from './useVirtualAutoTrade';
|
||||||
|
import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus';
|
||||||
|
import {
|
||||||
|
loadVirtualSession,
|
||||||
|
loadVirtualTargets,
|
||||||
|
saveVirtualSession,
|
||||||
|
saveVirtualTargets,
|
||||||
|
loadVirtualCardViewMode,
|
||||||
|
saveVirtualCardViewMode,
|
||||||
|
type VirtualSessionConfig,
|
||||||
|
type VirtualTargetItem,
|
||||||
|
type VirtualCardViewMode,
|
||||||
|
} from '../utils/virtualTradingStorage';
|
||||||
|
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
|
||||||
|
import {
|
||||||
|
syncVirtualTargetsToBackend,
|
||||||
|
stopVirtualLiveOnBackend,
|
||||||
|
} from '../utils/virtualLiveStrategySync';
|
||||||
|
import {
|
||||||
|
syncStrategyTimeframesFromLayoutIfNeeded,
|
||||||
|
warnStrategyTimeframeMismatch,
|
||||||
|
} from '../utils/strategyTimeframeSync';
|
||||||
|
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
||||||
|
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
||||||
|
import {
|
||||||
|
coalesceTargetsToDefaultStrategy,
|
||||||
|
resolveVirtualTargetStrategyId,
|
||||||
|
} from '../utils/virtualTargetStrategy';
|
||||||
|
import {
|
||||||
|
isVirtualTargetAddAllowed,
|
||||||
|
virtualTargetLimitMessage,
|
||||||
|
} from '../utils/virtualTargetLimits';
|
||||||
|
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
|
||||||
|
import { useAppSettings, resolveAppDefaults } from './useAppSettings';
|
||||||
|
|
||||||
|
export interface UseVirtualTradingCoreOptions {
|
||||||
|
defaultMarket?: string;
|
||||||
|
tickers?: Map<string, TickerData>;
|
||||||
|
paperTradingEnabled?: boolean;
|
||||||
|
paperAutoTradeEnabled?: boolean;
|
||||||
|
paperAutoTradeBudgetPct?: number;
|
||||||
|
onPaperOrderFilled?: () => void;
|
||||||
|
/** useAppSettings sessionKey (로그인 userId 등) */
|
||||||
|
settingsSessionKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}) {
|
||||||
|
const {
|
||||||
|
defaultMarket = 'KRW-BTC',
|
||||||
|
tickers,
|
||||||
|
paperTradingEnabled: paperTradingEnabledProp,
|
||||||
|
paperAutoTradeEnabled: paperAutoTradeEnabledProp,
|
||||||
|
paperAutoTradeBudgetPct: paperAutoTradeBudgetPctProp,
|
||||||
|
onPaperOrderFilled,
|
||||||
|
settingsSessionKey = 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const { settings: appSettings } = useAppSettings(settingsSessionKey);
|
||||||
|
const appChartDefaults = resolveAppDefaults(appSettings);
|
||||||
|
const paperTradingEnabled = paperTradingEnabledProp ?? appChartDefaults.paperTradingEnabled;
|
||||||
|
const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled;
|
||||||
|
const paperAutoTradeBudgetPct = paperAutoTradeBudgetPctProp ?? appChartDefaults.paperAutoTradeBudgetPct;
|
||||||
|
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
|
||||||
|
|
||||||
|
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
|
||||||
|
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
|
||||||
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
|
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||||
|
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||||
|
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setTargets(loadVirtualTargets());
|
||||||
|
setSession(loadVirtualSession());
|
||||||
|
void Promise.all([
|
||||||
|
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
|
||||||
|
loadPaperSummary().then(setSummary),
|
||||||
|
loadPaperTrades().then(setTrades),
|
||||||
|
]).finally(() => setLoading(false));
|
||||||
|
}, [settingsSessionKey]);
|
||||||
|
|
||||||
|
/** DB is_pinned → uiPreferences.virtual.targets 동기화 */
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const initial = loadVirtualTargets();
|
||||||
|
if (initial.length === 0) return;
|
||||||
|
void (async () => {
|
||||||
|
const merged = await Promise.all(
|
||||||
|
initial.map(async t => {
|
||||||
|
try {
|
||||||
|
const s = await loadLiveStrategySettings(t.market);
|
||||||
|
const pinned = !!s.isPinned;
|
||||||
|
if (!!t.pinned === pinned) return t;
|
||||||
|
return { ...t, pinned };
|
||||||
|
} catch {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!cancelled) setTargets(merged);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||||
|
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||||
|
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session.globalStrategyId == null) return;
|
||||||
|
setTargets(prev => coalesceTargetsToDefaultStrategy(prev, session.globalStrategyId));
|
||||||
|
}, [session.globalStrategyId]);
|
||||||
|
|
||||||
|
const strategyNames = useMemo(
|
||||||
|
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
||||||
|
[strategies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshPaperData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||||
|
setSummary(sum);
|
||||||
|
setTrades(tr);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
onPaperOrderFilled?.();
|
||||||
|
}, [onPaperOrderFilled]);
|
||||||
|
|
||||||
|
const targetRefs = useMemo(
|
||||||
|
() => targets.map(t => ({
|
||||||
|
market: t.market,
|
||||||
|
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||||
|
})),
|
||||||
|
[targets, session.globalStrategyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
|
||||||
|
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
||||||
|
targetRefs,
|
||||||
|
session.running,
|
||||||
|
);
|
||||||
|
|
||||||
|
useVirtualAutoTrade({
|
||||||
|
enabled: paperTradingEnabled && paperAutoTradeEnabled,
|
||||||
|
session,
|
||||||
|
targets,
|
||||||
|
snapshots,
|
||||||
|
tickers,
|
||||||
|
paperAutoTradeBudgetPct,
|
||||||
|
positions: summary?.positions,
|
||||||
|
onFilled: () => void refreshPaperData(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedLiveStatus = useMemo(() => {
|
||||||
|
if (!session.running) return liveStatusByMarket;
|
||||||
|
const merged = { ...liveStatusByMarket };
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [market, snap] of Object.entries(snapshots)) {
|
||||||
|
if (snap?.updatedAt && now - snap.updatedAt < 8000) {
|
||||||
|
merged[market] = 'live';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}, [liveStatusByMarket, snapshots, session.running]);
|
||||||
|
|
||||||
|
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
||||||
|
if (items.length > virtualTargetMaxCount) {
|
||||||
|
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTargets(items);
|
||||||
|
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
|
||||||
|
setSelectedMarket(items[0].market);
|
||||||
|
}
|
||||||
|
}, [selectedMarket, virtualTargetMaxCount]);
|
||||||
|
|
||||||
|
const addTarget = useCallback((
|
||||||
|
market: string,
|
||||||
|
meta?: { koreanName?: string; englishName?: string },
|
||||||
|
) => {
|
||||||
|
if (targets.some(t => t.market === market)) return false;
|
||||||
|
if (!isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount)) {
|
||||||
|
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const names = resolveVirtualTargetNames(market, meta?.koreanName, meta?.englishName);
|
||||||
|
setTargets(prev => [...prev, {
|
||||||
|
market,
|
||||||
|
strategyId: null,
|
||||||
|
koreanName: names.koreanName,
|
||||||
|
englishName: names.englishName,
|
||||||
|
}]);
|
||||||
|
setSelectedMarket(market);
|
||||||
|
return true;
|
||||||
|
}, [targets, virtualTargetMaxCount]);
|
||||||
|
|
||||||
|
const handleRemoveTarget = useCallback((market: string) => {
|
||||||
|
const t = targets.find(x => x.market === market);
|
||||||
|
if (t?.pinned) return;
|
||||||
|
setTargets(prev => prev.filter(x => x.market !== market));
|
||||||
|
}, [targets]);
|
||||||
|
|
||||||
|
const handleTogglePin = useCallback((market: string) => {
|
||||||
|
setTargets(prev => {
|
||||||
|
const item = prev.find(t => t.market === market);
|
||||||
|
if (!item) return prev;
|
||||||
|
const pinned = !item.pinned;
|
||||||
|
void persistVirtualTargetPinned(market, pinned).catch(() => {
|
||||||
|
window.alert('고정 설정 저장에 실패했습니다.');
|
||||||
|
});
|
||||||
|
return prev.map(t => (t.market === market ? { ...t, pinned } : t));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
||||||
|
const next = { ...session, globalStrategyId: strategyId };
|
||||||
|
setSession(next);
|
||||||
|
if (session.running && strategyId) {
|
||||||
|
void syncVirtualTargetsToBackend(targets, next, true);
|
||||||
|
}
|
||||||
|
}, [session, targets]);
|
||||||
|
|
||||||
|
const handleStart = useCallback(async () => {
|
||||||
|
if (targets.length === 0) {
|
||||||
|
window.alert('투자대상 종목을 먼저 추가하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!session.globalStrategyId) {
|
||||||
|
window.alert('투자전략을 선택하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const globalStrat = strategies.find(s => s.id === session.globalStrategyId);
|
||||||
|
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(
|
||||||
|
session.globalStrategyId,
|
||||||
|
globalStrat,
|
||||||
|
);
|
||||||
|
if (!synced) return;
|
||||||
|
const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await syncVirtualTargetsToBackend(targets, session, true);
|
||||||
|
setSession(s => ({ ...s, running: true }));
|
||||||
|
} catch {
|
||||||
|
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, [targets, session, strategies]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await stopVirtualLiveOnBackend(targets, session);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setSession(s => ({ ...s, running: false }));
|
||||||
|
}, [targets, session]);
|
||||||
|
|
||||||
|
const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => {
|
||||||
|
setTargets(prev => prev.map(t =>
|
||||||
|
t.market === market ? { ...t, strategyId } : t,
|
||||||
|
));
|
||||||
|
if (!session.running) return;
|
||||||
|
const effectiveId = resolveVirtualTargetStrategyId({ strategyId }, session.globalStrategyId);
|
||||||
|
if (effectiveId == null) return;
|
||||||
|
try {
|
||||||
|
await saveLiveStrategySettings({
|
||||||
|
market,
|
||||||
|
strategyId: effectiveId,
|
||||||
|
isLiveCheck: true,
|
||||||
|
executionType: session.executionType,
|
||||||
|
positionMode: session.positionMode,
|
||||||
|
skipWatchlistSync: true,
|
||||||
|
skipGlobalTemplate: true,
|
||||||
|
});
|
||||||
|
await pinStrategyEvaluationTimeframes(market, effectiveId);
|
||||||
|
} catch {
|
||||||
|
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => {
|
||||||
|
const normalized = normalizeStartCandleType(candleType);
|
||||||
|
setTargets(prev => prev.map(t =>
|
||||||
|
t.market === market ? { ...t, candleType: normalized } : t,
|
||||||
|
));
|
||||||
|
if (!session.running) return;
|
||||||
|
const target = targets.find(t => t.market === market);
|
||||||
|
const strategyId = target
|
||||||
|
? resolveVirtualTargetStrategyId(target, session.globalStrategyId)
|
||||||
|
: session.globalStrategyId;
|
||||||
|
if (!strategyId) return;
|
||||||
|
try {
|
||||||
|
await saveLiveStrategySettings({
|
||||||
|
market,
|
||||||
|
strategyId,
|
||||||
|
isLiveCheck: true,
|
||||||
|
executionType: session.executionType,
|
||||||
|
positionMode: session.positionMode,
|
||||||
|
skipWatchlistSync: true,
|
||||||
|
skipGlobalTemplate: true,
|
||||||
|
});
|
||||||
|
await pinStrategyEvaluationTimeframes(market, strategyId);
|
||||||
|
} catch {
|
||||||
|
window.alert('종목 평가 분봉 변경 저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, [session, targets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const missing = targets.filter(t => !t.candleType);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
void Promise.all(
|
||||||
|
missing.map(async t => {
|
||||||
|
try {
|
||||||
|
const s = await loadLiveStrategySettings(t.market);
|
||||||
|
return { market: t.market, candleType: s.candleType };
|
||||||
|
} catch {
|
||||||
|
return { market: t.market, candleType: undefined };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then(rows => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTargets(prev => prev.map(t => {
|
||||||
|
const row = rows.find(r => r.market === t.market);
|
||||||
|
if (t.candleType || !row?.candleType) return t;
|
||||||
|
return { ...t, candleType: normalizeStartCandleType(row.candleType) };
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]);
|
||||||
|
|
||||||
|
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
||||||
|
if (!session.running || targets.length === 0) return;
|
||||||
|
try {
|
||||||
|
await syncVirtualTargetsToBackend(targets, nextSession, true);
|
||||||
|
} catch {
|
||||||
|
window.alert('가상투자 설정 동기화에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, [session.running, targets]);
|
||||||
|
|
||||||
|
const handleExecutionTypeChange = useCallback((executionType: VirtualSessionConfig['executionType']) => {
|
||||||
|
const next = { ...session, executionType };
|
||||||
|
setSession(next);
|
||||||
|
void resyncRunningSession(next);
|
||||||
|
}, [session, resyncRunningSession]);
|
||||||
|
|
||||||
|
const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => {
|
||||||
|
const next = { ...session, positionMode };
|
||||||
|
setSession(next);
|
||||||
|
void resyncRunningSession(next);
|
||||||
|
}, [session, resyncRunningSession]);
|
||||||
|
|
||||||
|
/** 서버·DB에서 targets 다시 로드 (탭 포커스 등) */
|
||||||
|
const reloadTargetsFromStorage = useCallback(() => {
|
||||||
|
setTargets(loadVirtualTargets());
|
||||||
|
setSession(loadVirtualSession());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
targets,
|
||||||
|
setTargets,
|
||||||
|
handleTargetsChange,
|
||||||
|
addTarget,
|
||||||
|
session,
|
||||||
|
setSession,
|
||||||
|
strategies,
|
||||||
|
summary,
|
||||||
|
trades,
|
||||||
|
loading,
|
||||||
|
selectedMarket,
|
||||||
|
setSelectedMarket,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
refreshPaperData,
|
||||||
|
snapshots,
|
||||||
|
liveStatusByMarket: mergedLiveStatus,
|
||||||
|
lastTickAtByMarket,
|
||||||
|
strategyNames,
|
||||||
|
virtualTargetMaxCount,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleRemoveTarget,
|
||||||
|
handleTogglePin,
|
||||||
|
handleGlobalStrategyChange,
|
||||||
|
handleExecutionTypeChange,
|
||||||
|
handlePositionModeChange,
|
||||||
|
handleTargetStrategyChange,
|
||||||
|
handleTargetCandleTypeChange,
|
||||||
|
reloadTargetsFromStorage,
|
||||||
|
appChartDefaults,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -192,6 +192,20 @@ if [[ ! -f "$LOCAL_APK" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 업로드 대상이 최신 Gradle 빌드보다 오래됐으면 구 UI APK일 수 있음
|
||||||
|
unsigned_apk="$ROOT/app/android/app/build/outputs/apk/release/app-release-unsigned.apk"
|
||||||
|
signed_apk="$ROOT/app/android/app/build/outputs/apk/release/app-release.apk"
|
||||||
|
if [[ -f "$unsigned_apk" ]] && [[ "$LOCAL_APK" -ot "$unsigned_apk" ]]; then
|
||||||
|
echo -e "${RED}업로드 APK가 Gradle 빌드보다 오래되었습니다 (구 UI가 서버에 올라갈 수 있음).${NC}"
|
||||||
|
echo -e " 로컬: $LOCAL_APK"
|
||||||
|
echo -e " 최신: $unsigned_apk"
|
||||||
|
echo -e " 해결: $0 --build 또는 ./scripts/copy-android-apk.sh && $0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -f "$signed_apk" ]] && [[ "$LOCAL_APK" -ot "$signed_apk" ]]; then
|
||||||
|
echo -e "${YELLOW}참고: 서명 APK가 더 최신입니다. copy-android-apk.sh 후 업로드하세요.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "${CYAN}[연결] SSH 테스트...${NC}"
|
echo -e "${CYAN}[연결] SSH 테스트...${NC}"
|
||||||
if ! "${SSH_CMD[@]}" "$SSH_TARGET" "exit" >/dev/null 2>&1; then
|
if ! "${SSH_CMD[@]}" "$SSH_TARGET" "exit" >/dev/null 2>&1; then
|
||||||
echo -e "${RED}SSH 연결 실패: ${SSH_TARGET}${NC}"
|
echo -e "${RED}SSH 연결 실패: ${SSH_TARGET}${NC}"
|
||||||
|
|||||||
Reference in New Issue
Block a user