mobile download
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
const {
|
||||
allNotifications,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
deleteAllNotifications,
|
||||
refreshHistory,
|
||||
} = useTradeNotification();
|
||||
const { openVirtualFocus } = useNavigation();
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refreshHistory();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<header className="screen-header">
|
||||
<h1 className="screen-title">알림 {unreadCount > 0 && `(${unreadCount})`}</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '새로고침'}</button>
|
||||
<button type="button" className="chip" onClick={markAllAsRead}>모두 읽음</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{allNotifications.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>알림 없음</h3>
|
||||
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allNotifications.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 14,
|
||||
opacity: item.isRead ? 0.65 : 1,
|
||||
borderLeft: `3px solid ${item.signalType === 'BUY' ? 'var(--gc-green)' : 'var(--gc-red)'}`,
|
||||
}}
|
||||
onClick={() => {
|
||||
markAsRead(item.id);
|
||||
openVirtualFocus(item.market);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{allNotifications.length > 0 && (
|
||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||
<button type="button" className="btn-danger" onClick={() => void deleteAllNotifications()}>전체 삭제</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
loginUser,
|
||||
resetPaperAccount,
|
||||
sendFcmTest,
|
||||
loadFcmStatus,
|
||||
setApiBase,
|
||||
getStoredDeviceId,
|
||||
type AppSettingsDto,
|
||||
} from '../../lib/shared';
|
||||
import { getAuthSession, setAuthSession, clearAuthSession } from '../../lib/shared';
|
||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
||||
import { initFcmPush, checkPushPermission } from '../../services/fcm';
|
||||
import { API_BASE } from '../../lib/shared';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { settings, save, isLoaded } = useAppSettings();
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
const session = getAuthSession();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [apiUrl, setApiUrl] = useState(API_BASE);
|
||||
const [fcmAvailable, setFcmAvailable] = useState(false);
|
||||
const [pushPerm, setPushPerm] = useState<string>('prompt');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
||||
void checkPushPermission().then(setPushPerm);
|
||||
}, []);
|
||||
|
||||
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoginError('');
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div className="screen settings-screen">
|
||||
<header className="screen-header">
|
||||
<h1 className="screen-title">설정</h1>
|
||||
</header>
|
||||
|
||||
<SettingGroup title="일반">
|
||||
<SettingRow label="테마" description="앱 색상">
|
||||
<select
|
||||
value={defaults.theme ?? 'dark'}
|
||||
onChange={e => patch({ defaultTheme: e.target.value })}
|
||||
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }}
|
||||
>
|
||||
<option value="dark">다크</option>
|
||||
<option value="light">라이트</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="타임존">
|
||||
<input
|
||||
value={defaults.displayTimezone ?? 'Asia/Seoul'}
|
||||
onChange={e => patch({ displayTimezone: e.target.value })}
|
||||
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent', width: 140 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="전략 · 실시간">
|
||||
<SettingRow label="실시간 전략 체크">
|
||||
<Toggle checked={!!defaults.liveStrategyCheck} onChange={v => patch({ liveStrategyCheck: v })} label="실시간 전략 체크" />
|
||||
</SettingRow>
|
||||
<SettingRow label="실행 방식">
|
||||
<select
|
||||
value={defaults.liveExecutionType ?? 'CANDLE_CLOSE'}
|
||||
onChange={e => patch({ liveExecutionType: e.target.value })}
|
||||
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }}
|
||||
>
|
||||
<option value="CANDLE_CLOSE">봉 마감</option>
|
||||
<option value="REALTIME_TICK">실시간 틱</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="가상매매 · 모의">
|
||||
<SettingRow label="모의투자">
|
||||
<Toggle checked={!!defaults.paperTradingEnabled} onChange={v => patch({ paperTradingEnabled: v })} label="모의투자" />
|
||||
</SettingRow>
|
||||
<SettingRow label="자동 모의 체결">
|
||||
<Toggle checked={!!defaults.paperAutoTradeEnabled} onChange={v => patch({ paperAutoTradeEnabled: v })} label="자동 모의 체결" />
|
||||
</SettingRow>
|
||||
<SettingRow label="최대 종목 수">
|
||||
<input
|
||||
type="number"
|
||||
value={defaults.virtualTargetMaxCount ?? 20}
|
||||
onChange={e => patch({ virtualTargetMaxCount: Number(e.target.value) })}
|
||||
style={{ width: 64, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="모의 계좌 초기화">
|
||||
<button type="button" className="btn-danger" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void resetPaperAccount()}>리셋</button>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="알림">
|
||||
<SettingRow label="매매 알림">
|
||||
<Toggle checked={!!defaults.tradeAlertPopup} onChange={v => patch({ tradeAlertPopup: v })} label="매매 알림" />
|
||||
</SettingRow>
|
||||
<SettingRow label="알림 사운드">
|
||||
<Toggle checked={!!defaults.tradeAlertSoundEnabled} onChange={v => patch({ tradeAlertSoundEnabled: v })} label="알림 사운드" />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="FCM 푸시">
|
||||
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
|
||||
<Toggle checked={!!defaults.fcmPushEnabled} onChange={v => void handleFcmToggle(v)} label="FCM 푸시" />
|
||||
</SettingRow>
|
||||
<SettingRow label="권한 상태">
|
||||
<span className="text-muted">{pushPerm}</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="테스트 발송">
|
||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void sendFcmTest()}>테스트</button>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="네트워크 · 계정">
|
||||
<SettingRow label="API URL">
|
||||
<input
|
||||
value={apiUrl}
|
||||
onChange={e => setApiUrl(e.target.value)}
|
||||
style={{ width: 160, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent', fontSize: 11 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="적용">
|
||||
<button type="button" className="btn-secondary" style={{ padding: '8px 12px' }} onClick={() => { setApiBase(apiUrl); window.location.reload(); }}>저장</button>
|
||||
</SettingRow>
|
||||
<SettingRow label="Device ID">
|
||||
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
loadStrategies,
|
||||
loadStrategy,
|
||||
saveStrategy,
|
||||
deleteStrategy,
|
||||
type StrategyDto,
|
||||
} from '../../lib/shared';
|
||||
import type { LogicNode } from '@frontend/utils/strategyTypes';
|
||||
import { genId } from '@frontend/utils/strategyEditorShared';
|
||||
|
||||
const emptyAnd = (): LogicNode => ({ id: genId(), type: 'AND', children: [] });
|
||||
import {
|
||||
decodeConditionForEditor,
|
||||
encodeConditionForSave,
|
||||
} from '@frontend/utils/strategyConditionSerde';
|
||||
import {
|
||||
downloadStrategyJson,
|
||||
parseStrategyImportFile,
|
||||
pickJsonFile,
|
||||
buildStrategyExportPayload,
|
||||
} from '@frontend/utils/strategyImportExport';
|
||||
import { loadEditorMode, saveEditorMode, type StrategyEditorMode } from '@frontend/utils/strategyEditorModeStorage';
|
||||
import SegmentedControl from '../../components/SegmentedControl';
|
||||
import BottomSheet from '../../components/BottomSheet';
|
||||
import StrategyFlowMobile from './StrategyFlowMobile';
|
||||
|
||||
const emptyEditorState = {
|
||||
root: null as LogicNode | null,
|
||||
startMeta: {},
|
||||
extraStartIds: [] as string[],
|
||||
extraRoots: {} as Record<string, LogicNode | null>,
|
||||
startCombineOp: 'AND' as const,
|
||||
};
|
||||
|
||||
export default function StrategyEditorScreen() {
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [editorMode, setEditorMode] = useState<StrategyEditorMode>(() => loadEditorMode());
|
||||
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||
const [name, setName] = useState('새 전략');
|
||||
const [description, setDescription] = useState('');
|
||||
const [buyRoot, setBuyRoot] = useState<LogicNode | null>(emptyAnd());
|
||||
const [sellRoot, setSellRoot] = useState<LogicNode | null>(emptyAnd());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setStrategies((await loadStrategies()) ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshList().finally(() => setLoading(false));
|
||||
}, [refreshList]);
|
||||
|
||||
const loadOne = async (id: number) => {
|
||||
const s = await loadStrategy(id);
|
||||
if (!s) return;
|
||||
setSelectedId(id);
|
||||
setName(s.name);
|
||||
setDescription(s.description ?? '');
|
||||
setBuyRoot(decodeConditionForEditor(s.buyCondition as LogicNode).root);
|
||||
setSellRoot(decodeConditionForEditor(s.sellCondition as LogicNode).root);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveStrategy({
|
||||
id: selectedId ?? undefined,
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodeConditionForSave({ ...emptyEditorState, root: buyRoot }),
|
||||
sellCondition: encodeConditionForSave({ ...emptyEditorState, root: sellRoot }),
|
||||
enabled: true,
|
||||
});
|
||||
if (saved?.id) {
|
||||
setSelectedId(saved.id);
|
||||
await refreshList();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedId == null || !confirm('삭제할까요?')) return;
|
||||
await deleteStrategy(selectedId);
|
||||
setSelectedId(null);
|
||||
setName('새 전략');
|
||||
setBuyRoot(emptyAnd());
|
||||
setSellRoot(emptyAnd());
|
||||
await refreshList();
|
||||
};
|
||||
|
||||
const currentRoot = signalTab === 'buy' ? buyRoot : sellRoot;
|
||||
const setCurrentRoot = signalTab === 'buy' ? setBuyRoot : setSellRoot;
|
||||
|
||||
const handleExport = () => {
|
||||
const payload = buildStrategyExportPayload({
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodeConditionForSave({ ...emptyEditorState, root: buyRoot }),
|
||||
sellCondition: encodeConditionForSave({ ...emptyEditorState, root: sellRoot }),
|
||||
});
|
||||
downloadStrategyJson(name, payload);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const file = await pickJsonFile();
|
||||
if (!file) return;
|
||||
const parsed = await parseStrategyImportFile(file);
|
||||
if (!parsed) return;
|
||||
const data = parsed.kind === 'single' ? parsed.data : parsed.data.strategies[0];
|
||||
if (!data) return;
|
||||
setName(data.name ?? '가져온 전략');
|
||||
setDescription(data.description ?? '');
|
||||
setBuyRoot(decodeConditionForEditor(data.buyCondition as LogicNode).root);
|
||||
setSellRoot(decodeConditionForEditor(data.sellCondition as LogicNode).root);
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-center">로딩 중…</div>;
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<header className="screen-header">
|
||||
<h1 className="screen-title">전략편집기</h1>
|
||||
<button type="button" className="btn-primary" disabled={saving} onClick={() => void handleSave()}>저장</button>
|
||||
</header>
|
||||
|
||||
<div className="chip-row">
|
||||
<button type="button" className="chip" onClick={() => { setSelectedId(null); setName('새 전략'); }}>+ 새 전략</button>
|
||||
<button type="button" className="chip" onClick={handleExport}>내보내기</button>
|
||||
<button type="button" className="chip" onClick={() => void handleImport()}>가져오기</button>
|
||||
{selectedId != null && <button type="button" className="chip" style={{ color: 'var(--gc-red)' }} onClick={() => void handleDelete()}>삭제</button>}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px 12px' }}>
|
||||
<select
|
||||
className="chip"
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
value={selectedId ?? ''}
|
||||
onChange={e => { const id = Number(e.target.value); if (id) void loadOne(id); }}
|
||||
>
|
||||
<option value="">전략 선택…</option>
|
||||
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="전략 이름" style={{ width: '100%', padding: 12, borderRadius: 10, border: '1px solid var(--gc-border)', background: 'rgba(255,255,255,0.05)', marginBottom: 8 }} />
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="설명" rows={2} style={{ width: '100%', padding: 12, borderRadius: 10, border: '1px solid var(--gc-border)', background: 'rgba(255,255,255,0.05)' }} />
|
||||
</div>
|
||||
|
||||
<div className="chip-row">
|
||||
<SegmentedControl options={[{ value: 'buy' as const, label: '매수' }, { value: 'sell' as const, label: '매도' }]} value={signalTab} onChange={(v: 'buy' | 'sell') => setSignalTab(v)} />
|
||||
<SegmentedControl options={[{ value: 'list', label: '목록' }, { value: 'graph', label: '그래프' }]} value={editorMode} onChange={m => { setEditorMode(m); saveEditorMode(m); }} />
|
||||
</div>
|
||||
|
||||
{editorMode === 'graph' ? (
|
||||
<StrategyFlowMobile root={currentRoot} signalTab={signalTab} />
|
||||
) : (
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<ConditionSummary root={currentRoot} />
|
||||
<p className="text-muted" style={{ fontSize: 12, marginTop: 12 }}>
|
||||
상세 조건 편집은 데스크톱 전략편집기와 동기화됩니다. Import/Export JSON으로 조건을 가져올 수 있습니다.
|
||||
</p>
|
||||
<button type="button" className="btn-secondary" style={{ width: '100%', marginTop: 12 }} onClick={() => setCurrentRoot(emptyAnd())}>조건 초기화</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionSummary({ root }: { root: LogicNode | null }) {
|
||||
const count = countConditions(root);
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600 }}>논리 트리</div>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginTop: 4 }}>루트: {root?.type ?? '없음'} · 조건 {count}개</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countConditions(node: LogicNode | null): number {
|
||||
if (!node) return 0;
|
||||
if (node.type === 'CONDITION') return 1;
|
||||
return (node.children ?? []).reduce((s, c) => s + countConditions(c), 0);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { LogicNode } from '@frontend/utils/strategyTypes';
|
||||
|
||||
interface Props {
|
||||
root: LogicNode | null;
|
||||
signalTab: 'buy' | 'sell';
|
||||
}
|
||||
|
||||
function countNodes(node: LogicNode | null): number {
|
||||
if (!node) return 0;
|
||||
return 1 + (node.children ?? []).reduce((s, c) => s + countNodes(c), 0);
|
||||
}
|
||||
|
||||
export default function StrategyFlowMobile({ root, signalTab }: Props) {
|
||||
const count = useMemo(() => countNodes(root), [root]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div className="card" style={{ textAlign: 'center', padding: 32 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 8 }}>🔀</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{signalTab === 'buy' ? '매수' : '매도'} 그래프</div>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 16 }}>
|
||||
{count}개 노드 · 모바일에서는 목록 모드 편집을 권장합니다
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: 12, lineHeight: 1.5 }}>
|
||||
데스크톱 전략편집기에서 저장한 flowLayout 그래프는 그대로 유지됩니다.
|
||||
조건 추가·편집은 목록 탭을 사용하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import type { StrategyDto } from '../../lib/shared';
|
||||
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
|
||||
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
|
||||
|
||||
interface Props {
|
||||
target: VirtualTargetItem;
|
||||
session: VirtualSessionConfig;
|
||||
strategies: StrategyDto[];
|
||||
snapshot?: VirtualIndicatorSnapshot;
|
||||
liveConnected: boolean;
|
||||
onBack: () => void;
|
||||
onOpenTrade: () => void;
|
||||
}
|
||||
|
||||
export default function VirtualFocusScreen({
|
||||
target,
|
||||
snapshot,
|
||||
liveConnected,
|
||||
onBack,
|
||||
onOpenTrade,
|
||||
}: Props) {
|
||||
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 (
|
||||
<div className="screen focus-screen">
|
||||
<header className="screen-header">
|
||||
<button type="button" onClick={onBack} style={{ fontSize: 24, minWidth: 44 }}>←</button>
|
||||
<h1 className="screen-title" style={{ flex: 1 }}>{target.market}</h1>
|
||||
<button type="button" className="btn-primary" onClick={onOpenTrade}>매매</button>
|
||||
</header>
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
<div className="card" style={{ marginBottom: 16, textAlign: 'center' }}>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>일치율</div>
|
||||
<div style={{ fontSize: 48, fontWeight: 700, color: 'var(--gc-accent)' }}>
|
||||
{(snapshot?.matchRate ?? 0).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
{liveConnected ? '실시간 연결됨' : '연결 대기'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
<div className="card" style={{ textAlign: 'center', padding: 14 }}>
|
||||
<div className="text-green" style={{ fontSize: 22, fontWeight: 700 }}>{buyPct.toFixed(0)}%</div>
|
||||
<div className="text-muted" style={{ fontSize: 11 }}>매수 조건</div>
|
||||
</div>
|
||||
<div className="card" style={{ textAlign: 'center', padding: 14 }}>
|
||||
<div className="text-red" style={{ fontSize: 22, fontWeight: 700 }}>{sellPct.toFixed(0)}%</div>
|
||||
<div className="text-muted" style={{ fontSize: 11 }}>매도 조건</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>조건 목록</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(snapshot?.rows ?? []).map(row => (
|
||||
<div key={row.id} className="card" style={{ padding: 10, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{row.displayName}</div>
|
||||
<div className="text-muted" style={{ fontSize: 11 }}>{row.side} · {row.timeframe}</div>
|
||||
</div>
|
||||
<span className={row.satisfied ? 'text-green' : row.satisfied === false ? 'text-red' : 'text-muted'}>
|
||||
{row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { PaperSummaryDto } from '../../lib/shared';
|
||||
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
summary: PaperSummaryDto | null;
|
||||
onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
|
||||
const [qty, setQty] = useState('0.001');
|
||||
const [price, setPrice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const position = summary?.positions?.find(p => p.symbol === market);
|
||||
|
||||
const submit = async (side: 'BUY' | 'SELL') => {
|
||||
const q = parseFloat(qty);
|
||||
const p = parseFloat(price) || 0;
|
||||
if (!Number.isFinite(q) || q <= 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onOrder(side, q, p);
|
||||
try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ }
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="trade-panel">
|
||||
{position && (
|
||||
<div className="card" style={{ marginBottom: 12, padding: 12 }}>
|
||||
<div className="text-muted" style={{ fontSize: 11 }}>보유</div>
|
||||
<div>{position.quantity} @ ₩{position.avgPrice?.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>수량</label>
|
||||
<input
|
||||
type="number"
|
||||
value={qty}
|
||||
onChange={e => setQty(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<label style={{ display: 'block', margin: '12px 0 8px', fontSize: 13 }}>가격 (0=시장가)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={price}
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
placeholder="시장가"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div 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')}>
|
||||
매수
|
||||
</button>
|
||||
<button type="button" className="btn-danger" style={{ flex: 1 }} disabled={busy} onClick={() => void submit('SELL')}>
|
||||
매도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--gc-border)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
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 { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits';
|
||||
import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations';
|
||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import BottomSheet from '../../components/BottomSheet';
|
||||
import SegmentedControl from '../../components/SegmentedControl';
|
||||
import VirtualTargetCardMobile from './VirtualTargetCardMobile';
|
||||
import VirtualFocusScreen from './VirtualFocusScreen';
|
||||
import VirtualTradePanel from './VirtualTradePanel';
|
||||
|
||||
type RightTab = 'trade' | 'history';
|
||||
|
||||
export default function VirtualTradingScreen() {
|
||||
const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation();
|
||||
const { settings } = useAppSettings();
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
|
||||
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('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,
|
||||
session,
|
||||
snapshots,
|
||||
enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled,
|
||||
paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95,
|
||||
positions: summary?.positions,
|
||||
onFilled: refreshSummary,
|
||||
});
|
||||
|
||||
const toggleRunning = useCallback(async () => {
|
||||
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;
|
||||
|
||||
if (focusMarket) {
|
||||
const target = targets.find(t => t.market === focusMarket);
|
||||
if (target) {
|
||||
return (
|
||||
<VirtualFocusScreen
|
||||
target={target}
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
snapshot={(() => {
|
||||
const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||
return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market];
|
||||
})()}
|
||||
liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')}
|
||||
onBack={clearVirtualFocus}
|
||||
onOpenTrade={() => {
|
||||
setSelectedMarket(focusMarket);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
clearVirtualFocus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="screen virtual-screen">
|
||||
<header className="screen-header">
|
||||
<h1 className="screen-title">가상매매</h1>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-primary session-btn${session.running ? ' running' : ''}`}
|
||||
onClick={() => void toggleRunning()}
|
||||
>
|
||||
{session.running ? '■ 중지' : '▶ 시작'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="virtual-summary card" style={{ margin: '0 16px 12px' }}>
|
||||
<div className="virtual-summary-row">
|
||||
<div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>총 자산</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
₩{(summary?.totalAsset ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>수익률</div>
|
||||
<div className={`${pnlPct >= 0 ? 'text-green' : 'text-red'}`} style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!liveConnected && session.running && (
|
||||
<div className="connection-banner" style={{ marginTop: 8, borderRadius: 8 }}>실시간 연결 끊김</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chip-row">
|
||||
<select
|
||||
className="chip"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => setSession(s => ({ ...s, globalStrategyId: e.target.value ? Number(e.target.value) : null }))}
|
||||
style={{ maxWidth: 160 }}
|
||||
>
|
||||
<option value="">전략 선택</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'CANDLE_CLOSE' as const, label: '봉마감' },
|
||||
{ value: 'REALTIME_TICK' as const, label: '실시간' },
|
||||
]}
|
||||
value={session.executionType}
|
||||
onChange={v => setSession(s => ({ ...s, executionType: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="chip-row">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'LONG_ONLY' as const, label: '롱온리' },
|
||||
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
|
||||
]}
|
||||
value={session.positionMode}
|
||||
onChange={v => setSession(s => ({ ...s, positionMode: v }))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'summary' as const, label: '요약' },
|
||||
{ value: 'detail' as const, label: '상세' },
|
||||
]}
|
||||
value={viewMode}
|
||||
onChange={(v: VirtualCardViewMode) => setViewMode(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center">로딩 중…</div>
|
||||
) : targets.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>투자 대상 없음</h3>
|
||||
<p>+ 버튼으로 종목을 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="virtual-cards" style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{targets.map(target => {
|
||||
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||
const snapKey = `${target.market}:${strategyId ?? ''}`;
|
||||
const snap = snapshots[snapKey] ?? snapshots[target.market];
|
||||
return (
|
||||
<VirtualTargetCardMobile
|
||||
key={target.market}
|
||||
target={target}
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
snapshot={snap}
|
||||
viewMode={viewMode}
|
||||
liveFlash={!!liveStatus.lastTickAtByMarket[target.market]}
|
||||
liveStatus={liveStatus.statusByMarket[target.market]}
|
||||
onFocus={() => openVirtualFocus(target.market)}
|
||||
onTrade={() => { setSelectedMarket(target.market); setSheetOpen(true); }}
|
||||
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))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
|
||||
|
||||
<BottomSheet open={addSheetOpen} title="종목 추가" onClose={() => setAddSheetOpen(false)}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="KRW-BTC"
|
||||
value={newMarket}
|
||||
onChange={e => setNewMarket(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--gc-border)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
<style>{`
|
||||
.session-btn.running { background: var(--gc-red); }
|
||||
.virtual-summary-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user