mobile download
This commit is contained in:
@@ -11627,6 +11627,25 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
}
|
||||
.tnl-row-chart:hover { background: var(--bg4); color: var(--text); }
|
||||
|
||||
/* TopMenuBar 앱 다운로드 버튼 */
|
||||
.tmb-app-download-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tmb-app-download-btn:hover {
|
||||
background: var(--bg4);
|
||||
color: var(--accent, #8b5cf6);
|
||||
}
|
||||
|
||||
/* TopMenuBar 알림 버튼 */
|
||||
.tmb-notify-group {
|
||||
display: inline-flex;
|
||||
@@ -12517,3 +12536,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
/* ── 앱 다운로드 모달 ── */
|
||||
.app-download-body { padding: 4px 2px 8px; }
|
||||
.app-download-muted { color: var(--text3); font-size: 13px; }
|
||||
.app-download-error { color: #ef4444; font-size: 13px; }
|
||||
.app-download-empty { text-align: center; padding: 16px 8px; }
|
||||
.app-download-empty-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.app-download-empty h3 { font-size: 16px; margin-bottom: 8px; }
|
||||
.app-download-empty p { font-size: 13px; color: var(--text2); line-height: 1.55; }
|
||||
.app-download-empty code { font-size: 11px; word-break: break-all; }
|
||||
.app-download-hero {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 12px 14px; margin-bottom: 16px;
|
||||
background: var(--bg3); border-radius: 12px; border: 1px solid var(--border);
|
||||
}
|
||||
.app-download-app-icon {
|
||||
width: 52px; height: 52px; border-radius: 14px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #2196f3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 18px; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.app-download-app-name { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
|
||||
.app-download-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--text3); }
|
||||
.app-download-qr-block {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
padding: 16px; margin-bottom: 16px;
|
||||
background: var(--bg2); border-radius: 14px; border: 1px solid var(--border);
|
||||
}
|
||||
.app-download-qr {
|
||||
border-radius: 10px; background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.app-download-qr--placeholder {
|
||||
width: 220px; height: 220px; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text3); font-size: 13px;
|
||||
}
|
||||
.app-download-qr-help { text-align: center; max-width: 320px; }
|
||||
.app-download-qr-help strong { display: block; font-size: 14px; margin-bottom: 6px; }
|
||||
.app-download-qr-help p { font-size: 12px; color: var(--text2); line-height: 1.5; margin: 0 0 6px; }
|
||||
.app-download-url { font-size: 10px; word-break: break-all; opacity: 0.85; }
|
||||
.app-download-btn-primary {
|
||||
width: 100%; padding: 14px; border: none; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||
color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.app-download-btn-primary:hover { filter: brightness(1.06); }
|
||||
.app-download-steps {
|
||||
margin-top: 14px; font-size: 12px; color: var(--text2);
|
||||
border-top: 1px solid var(--border); padding-top: 12px;
|
||||
}
|
||||
.app-download-steps summary { cursor: pointer; font-weight: 600; color: var(--text); margin-bottom: 8px; }
|
||||
.app-download-steps ol { padding-left: 18px; line-height: 1.6; }
|
||||
.app-download-ios-note {
|
||||
margin-top: 12px; font-size: 11px; color: var(--text3); line-height: 1.45;
|
||||
}
|
||||
|
||||
+23
-22
@@ -39,7 +39,7 @@ import {
|
||||
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig';
|
||||
import { normalizeIchimokuConfig } from './utils/ichimokuConfig';
|
||||
import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
|
||||
import { getFavorites, saveFavorites, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
|
||||
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
|
||||
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
|
||||
import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from './types';
|
||||
import type { ChartManager } from './utils/ChartManager';
|
||||
@@ -105,6 +105,7 @@ import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
|
||||
import { useIsMobile } from './hooks/useMediaQuery';
|
||||
import MobileChartDock from './components/MobileChartDock';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import AppDownloadModal from './components/AppDownloadModal';
|
||||
import SplashScreen from './components/SplashScreen';
|
||||
import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth';
|
||||
import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry';
|
||||
@@ -161,6 +162,7 @@ function App() {
|
||||
isAppEntered() && !getAuthSession(),
|
||||
);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [appDownloadOpen, setAppDownloadOpen] = useState(false);
|
||||
|
||||
// 전역 지표 파라미터 + 시각 설정 DB 동기화
|
||||
const {
|
||||
@@ -217,16 +219,8 @@ function App() {
|
||||
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker();
|
||||
|
||||
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
||||
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => {
|
||||
const saved = localStorage.getItem('chartLayout');
|
||||
return LAYOUTS.find(l => l.id === saved) ?? LAYOUTS[0];
|
||||
});
|
||||
const [syncOptions, setSyncOptions] = useState<SyncOptions>(() => {
|
||||
try {
|
||||
const s = localStorage.getItem('chartLayoutSync');
|
||||
return s ? JSON.parse(s) : DEFAULT_SYNC;
|
||||
} catch { return DEFAULT_SYNC; }
|
||||
});
|
||||
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => LAYOUTS[0]);
|
||||
const [syncOptions, setSyncOptions] = useState<SyncOptions>(DEFAULT_SYNC);
|
||||
const [activeSlot, setActiveSlot] = useState(0);
|
||||
// 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용)
|
||||
const [activeSlotIndicators, setActiveSlotIndicators] = useState<IndicatorConfig[]>([]);
|
||||
@@ -387,13 +381,18 @@ function App() {
|
||||
saveAppDef({ defaultLayoutId: def.id });
|
||||
}, [layoutDef.count, activeSlot, symbol, timeframe, indicators, saveAppDef]);
|
||||
|
||||
// 레이아웃/싱크 변경 시 localStorage 저장
|
||||
const appLayoutHydratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('chartLayout', layoutDef.id);
|
||||
}, [layoutDef]);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('chartLayoutSync', JSON.stringify(syncOptions));
|
||||
}, [syncOptions]);
|
||||
if (!appSettingsLoaded || appLayoutHydratedRef.current) return;
|
||||
appLayoutHydratedRef.current = true;
|
||||
if (appDefaults.layoutId) {
|
||||
const def = LAYOUTS.find(l => l.id === appDefaults.layoutId);
|
||||
if (def) setLayoutDef(def);
|
||||
}
|
||||
if (appDefaults.syncOptions) {
|
||||
setSyncOptions(appDefaults.syncOptions);
|
||||
}
|
||||
}, [appSettingsLoaded, appDefaults.layoutId, appDefaults.syncOptions]);
|
||||
|
||||
// 다중 → 싱글 전환 시 activeSlotIndicators 초기화
|
||||
useEffect(() => {
|
||||
@@ -1118,10 +1117,6 @@ function App() {
|
||||
return map;
|
||||
}, [watchlist]);
|
||||
|
||||
// ── 상태 저장 (localStorage) ───────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
saveState({ symbol, timeframe, chartType, theme, indicators, watchlist, alertPrices: alerts.map(a => a.price) });
|
||||
}, [symbol, timeframe, chartType, theme, indicators, watchlist, alerts]);
|
||||
|
||||
// body 포털(알림 팝업 등) 테마 CSS 변수 동기화
|
||||
useEffect(() => {
|
||||
@@ -1201,10 +1196,10 @@ function App() {
|
||||
},
|
||||
onWatchlistLoad: (items: WatchlistItem[]) => {
|
||||
const syms = items.map(i => i.symbol);
|
||||
initFavoritesFromDb(syms);
|
||||
if (syms.length > 0) {
|
||||
setWatchlist(syms);
|
||||
setFavoritesList(syms);
|
||||
saveFavorites(syms, false);
|
||||
}
|
||||
},
|
||||
noAutoSave: !dbLoaded,
|
||||
@@ -1672,6 +1667,7 @@ function App() {
|
||||
theme={theme}
|
||||
onPage={guardedSetMenuPage}
|
||||
onTheme={handleThemeToggle}
|
||||
onOpenAppDownload={() => setAppDownloadOpen(true)}
|
||||
authUser={authUser}
|
||||
guestMode={guestMode}
|
||||
menuPermissions={menuPermissions}
|
||||
@@ -1691,6 +1687,10 @@ function App() {
|
||||
onSuccess={handleLoginSuccess}
|
||||
/>
|
||||
|
||||
{appDownloadOpen && (
|
||||
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
|
||||
{menuPage === 'dashboard' && (
|
||||
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
|
||||
@@ -1900,6 +1900,7 @@ function App() {
|
||||
|
||||
<ChartToolbarNotify
|
||||
onOpenNotifyList={openNotificationsPage}
|
||||
onOpenAppDownload={() => setAppDownloadOpen(true)}
|
||||
symbol={symbol} timeframe={layoutDef.count > 1 ? activeSlotTf : timeframe} chartType={chartType}
|
||||
theme={theme} mode={mode} logScale={logScale} showGrid={showGrid}
|
||||
activeIndicators={toolbarActiveIndicators}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(n?: number): string {
|
||||
if (n == null || !Number.isFinite(n)) return '—';
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
|
||||
function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
|
||||
if (!info?.available) return '';
|
||||
return info.downloadUrl?.trim() ?? '';
|
||||
}
|
||||
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const downloadUrl = useMemo(() => resolveDownloadUrl(info), [info]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadMobileAppReleaseInfo()
|
||||
.then(data => {
|
||||
if (!cancelled) setInfo(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadUrl) {
|
||||
setQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void QRCode.toDataURL(downloadUrl, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
color: { dark: '#111827', light: '#ffffff' },
|
||||
}).then(url => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setQrDataUrl(null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [downloadUrl]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = info?.fileName ?? 'goldenchart-android.apk';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableModalFrame
|
||||
onClose={onClose}
|
||||
title="Mobile App"
|
||||
titleKo="GoldenChart 앱 설치"
|
||||
badge="Android"
|
||||
width={440}
|
||||
dialogClassName="sp-modal app-download-modal"
|
||||
>
|
||||
<div className="app-download-body">
|
||||
{loading && <p className="app-download-muted">앱 정보 불러오는 중…</p>}
|
||||
{!loading && error && <p className="app-download-error">{error}</p>}
|
||||
{!loading && !error && !info?.available && (
|
||||
<div className="app-download-empty">
|
||||
<div className="app-download-empty-icon" aria-hidden>📱</div>
|
||||
<h3>설치 파일 준비 중</h3>
|
||||
<p>
|
||||
Android APK가 아직 서버에 업로드되지 않았습니다.
|
||||
<br />
|
||||
<code>data/mobile-releases/goldenchart-android.apk</code> 경로에 빌드 파일을 배치하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && info?.available && (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart</h3>
|
||||
<p className="app-download-meta">
|
||||
{info.version && <span>v{info.version}</span>}
|
||||
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
||||
{info.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-download-qr-block">
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
|
||||
) : (
|
||||
<div className="app-download-qr app-download-qr--placeholder">QR 생성 중…</div>
|
||||
)}
|
||||
<div className="app-download-qr-help">
|
||||
<strong>휴대폰 카메라로 QR 스캔</strong>
|
||||
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
||||
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
|
||||
Android APK 다운로드
|
||||
</button>
|
||||
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법 (Android)</summary>
|
||||
<ol>
|
||||
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
||||
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
||||
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
||||
<li>설치 후 앱에서 API 서버 URL을 설정하세요 (설정 › 네트워크).</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<p className="app-download-ios-note">
|
||||
iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDownloadModal;
|
||||
@@ -13,6 +13,7 @@ interface NotifyTopMenuBarProps {
|
||||
theme: Theme;
|
||||
onPage: (page: MenuPage) => void;
|
||||
onTheme: () => void;
|
||||
onOpenAppDownload?: () => void;
|
||||
authUser?: AuthSession | null;
|
||||
onLoginClick?: () => void;
|
||||
onLogout?: () => void;
|
||||
@@ -47,8 +48,12 @@ export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
|
||||
|
||||
import Toolbar, { type ToolbarProps } from './Toolbar';
|
||||
|
||||
export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: () => void }> = ({
|
||||
export const ChartToolbarNotify: React.FC<ToolbarProps & {
|
||||
onOpenNotifyList: () => void;
|
||||
onOpenAppDownload?: () => void;
|
||||
}> = ({
|
||||
onOpenNotifyList,
|
||||
onOpenAppDownload,
|
||||
...toolbarProps
|
||||
}) => {
|
||||
const { unreadCount } = useTradeNotification();
|
||||
@@ -57,6 +62,7 @@ export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: ()
|
||||
{...toolbarProps}
|
||||
tradeNotifyUnread={unreadCount}
|
||||
onOpenTradeNotifications={onOpenNotifyList}
|
||||
onOpenAppDownload={onOpenAppDownload}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import type { Theme } from '../types/index';
|
||||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import { loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
|
||||
import type { LogicNode } from '../utils/strategyTypes';
|
||||
import {
|
||||
buildStrategyEditorDefFromSettings,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
type EditorConditionState,
|
||||
} from '../utils/strategyConditionSerde';
|
||||
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
|
||||
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
|
||||
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
defaultStartMeta,
|
||||
@@ -48,10 +49,10 @@ import {
|
||||
} from '../utils/strategyPaletteStorage';
|
||||
import {
|
||||
emptySignalFlowLayout,
|
||||
loadStrategyFlowLayout,
|
||||
migrateStrategyFlowLayout,
|
||||
saveStrategyFlowLayout,
|
||||
deleteStrategyFlowLayout,
|
||||
buildStrategyFlowLayoutStore,
|
||||
normalizeStrategyFlowLayout,
|
||||
readLegacyFlowLayoutFromLocalStorage,
|
||||
clearLegacyFlowLayoutFromLocalStorage,
|
||||
type SignalFlowLayoutSnapshot,
|
||||
type FlowLayoutChangePayload,
|
||||
type StrategyFlowLayoutStore,
|
||||
@@ -97,21 +98,6 @@ interface Props {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
|
||||
const stored = loadStrategyFlowLayout(strategyKey);
|
||||
const snap = stored?.[tab];
|
||||
if (!snap) return emptySignalFlowLayout();
|
||||
return {
|
||||
positions: snap.positions ?? {},
|
||||
edgeHandles: snap.edgeHandles ?? {},
|
||||
orphans: snap.orphans ?? [],
|
||||
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: snap.extraStartIds ?? [],
|
||||
extraRoots: snap.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
|
||||
};
|
||||
}
|
||||
|
||||
function toEditorState(
|
||||
root: LogicNode | null,
|
||||
layout: Pick<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
|
||||
@@ -136,6 +122,28 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
function applyFlowLayoutFromStore(
|
||||
stored: StrategyFlowLayoutStore | null,
|
||||
setBuyLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
|
||||
setSellLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
|
||||
setBuyOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
|
||||
setSellOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
|
||||
) {
|
||||
if (stored) {
|
||||
setBuyLayout(normalizeTabLayoutState(stored.buy));
|
||||
setSellLayout(normalizeTabLayoutState(stored.sell));
|
||||
setBuyOrphans(stored.buy.orphans ?? []);
|
||||
setSellOrphans(stored.sell.orphans ?? []);
|
||||
} else {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
}
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme }: Props) {
|
||||
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
|
||||
const DEF = useMemo(
|
||||
@@ -164,44 +172,32 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const [descOpen, setDescOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const initialDraftBuy = readTabLayout('draft', 'buy');
|
||||
const initialDraftSell = readTabLayout('draft', 'sell');
|
||||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
|
||||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
|
||||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
|
||||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
|
||||
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
const [saveToast, setSaveToast] = useState(false);
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||||
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const terminalHeightRef = useRef(terminalHeight);
|
||||
const [buyLayout, setBuyLayout] = useState(() => ({
|
||||
positions: initialDraftBuy.positions,
|
||||
edgeHandles: initialDraftBuy.edgeHandles,
|
||||
startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: initialDraftBuy.extraStartIds ?? [],
|
||||
extraRoots: initialDraftBuy.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
|
||||
}));
|
||||
const [sellLayout, setSellLayout] = useState(() => ({
|
||||
positions: initialDraftSell.positions,
|
||||
edgeHandles: initialDraftSell.edgeHandles,
|
||||
startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: initialDraftSell.extraStartIds ?? [],
|
||||
extraRoots: initialDraftSell.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
|
||||
}));
|
||||
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
const buyLayoutRef = useRef(buyLayout);
|
||||
const sellLayoutRef = useRef(sellLayout);
|
||||
const buyConditionRef = useRef(buyCondition);
|
||||
const sellConditionRef = useRef(sellCondition);
|
||||
const buyOrphansRef = useRef(buyOrphans);
|
||||
const sellOrphansRef = useRef(sellOrphans);
|
||||
buyLayoutRef.current = buyLayout;
|
||||
sellLayoutRef.current = sellLayout;
|
||||
buyConditionRef.current = buyCondition;
|
||||
sellConditionRef.current = sellCondition;
|
||||
const timeframeAutosaveRef = useRef<number | null>(null);
|
||||
buyOrphansRef.current = buyOrphans;
|
||||
sellOrphansRef.current = sellOrphans;
|
||||
const layoutRevisionRef = useRef(0);
|
||||
const persistLayoutTimerRef = useRef<number | null>(null);
|
||||
const strategyAutosaveTimerRef = useRef<number | null>(null);
|
||||
const layoutPersistReadyRef = useRef(false);
|
||||
const STRATEGY_AUTOSAVE_MS = 350;
|
||||
leftWidthRef.current = leftWidth;
|
||||
terminalHeightRef.current = terminalHeight;
|
||||
|
||||
@@ -268,20 +264,67 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||||
|
||||
const persistFlowLayout = useCallback((strategyKey: string) => {
|
||||
saveStrategyFlowLayout(strategyKey, {
|
||||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||||
});
|
||||
}, [buyOrphans, sellOrphans]);
|
||||
const buildCurrentFlowLayout = useCallback(
|
||||
(): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore(
|
||||
buyLayoutRef.current,
|
||||
sellLayoutRef.current,
|
||||
buyOrphansRef.current,
|
||||
sellOrphansRef.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
|
||||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||||
persistLayoutTimerRef.current = window.setTimeout(() => {
|
||||
persistLayoutTimerRef.current = null;
|
||||
persistFlowLayout(strategyKey);
|
||||
}, 150);
|
||||
}, [persistFlowLayout]);
|
||||
const persistStrategyToDb = useCallback(async () => {
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||||
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
if (!encodedBuy && !encodedSell) return;
|
||||
const flowLayout = buildCurrentFlowLayout();
|
||||
const saved = await persistStrategyEditorState(
|
||||
selectedId,
|
||||
stratName,
|
||||
stratDesc,
|
||||
aligned.buy,
|
||||
aligned.sell,
|
||||
flowLayout,
|
||||
);
|
||||
setStrategies(prev => prev.map(s => s.id === selectedId
|
||||
? {
|
||||
...s,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
updatedAt: saved?.updatedAt ?? s.updatedAt,
|
||||
}
|
||||
: s));
|
||||
}, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]);
|
||||
|
||||
const flushStrategyPersist = useCallback(() => {
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
}
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
void persistStrategyToDb().catch(err => {
|
||||
console.warn('[StrategyEditor] 전략 DB 저장 실패:', err);
|
||||
});
|
||||
}, [selectedId, stratName, persistStrategyToDb]);
|
||||
|
||||
const scheduleStrategyPersist = useCallback(() => {
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
}
|
||||
strategyAutosaveTimerRef.current = window.setTimeout(() => {
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
void persistStrategyToDb().catch(err => {
|
||||
console.warn('[StrategyEditor] 전략 자동저장 실패:', err);
|
||||
});
|
||||
}, STRATEGY_AUTOSAVE_MS);
|
||||
}, [selectedId, stratName, persistStrategyToDb]);
|
||||
|
||||
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
|
||||
layoutRevisionRef.current += 1;
|
||||
@@ -318,50 +361,11 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
|
||||
}));
|
||||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
|
||||
if (selectedId != null && stratName.trim()) scheduleStrategyPersist();
|
||||
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]);
|
||||
|
||||
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
|
||||
bumpLayoutSeed(strategyKey, tab);
|
||||
}, [bumpLayoutSeed]);
|
||||
|
||||
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const stored = loadStrategyFlowLayout(strategyKey);
|
||||
if (stored) {
|
||||
const nextBuy = {
|
||||
positions: stored.buy.positions ?? {},
|
||||
edgeHandles: stored.buy.edgeHandles ?? {},
|
||||
startMeta: stored.buy.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: stored.buy.extraStartIds ?? [],
|
||||
extraRoots: stored.buy.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
|
||||
};
|
||||
const nextSell = {
|
||||
positions: stored.sell.positions ?? {},
|
||||
edgeHandles: stored.sell.edgeHandles ?? {},
|
||||
startMeta: stored.sell.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: stored.sell.extraStartIds ?? [],
|
||||
extraRoots: stored.sell.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
|
||||
};
|
||||
setBuyLayout(nextBuy);
|
||||
setSellLayout(nextSell);
|
||||
setBuyOrphans(stored.buy.orphans ?? []);
|
||||
setSellOrphans(stored.sell.orphans ?? []);
|
||||
} else {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
}
|
||||
applyFlowLayoutFromStore(null, setBuyLayout, setSellLayout, setBuyOrphans, setSellOrphans);
|
||||
bumpLayoutSeed(strategyKey, tab);
|
||||
}, [bumpLayoutSeed]);
|
||||
|
||||
@@ -372,24 +376,34 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
};
|
||||
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
|
||||
else setSellLayout(prev => ({ ...prev, ...patch }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [scheduleStrategyPersist]);
|
||||
|
||||
const handleRootChange = useCallback((root: LogicNode | null) => {
|
||||
setCurrentRoot(root);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentRoot, scheduleStrategyPersist]);
|
||||
|
||||
const handleOrphansChange = useCallback((orphans: LogicNode[]) => {
|
||||
setCurrentOrphans(orphans);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentOrphans, scheduleStrategyPersist]);
|
||||
|
||||
const handleStartMetaChange = useCallback((meta: Record<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentLayout, scheduleStrategyPersist]);
|
||||
|
||||
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
|
||||
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
scheduleStrategyPersist();
|
||||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
|
||||
}, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]);
|
||||
|
||||
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentLayout, scheduleStrategyPersist]);
|
||||
|
||||
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
|
||||
setCurrentRoot(next.root);
|
||||
@@ -400,24 +414,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
extraRoots: next.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
|
||||
}));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const scheduleTimeframePersist = useCallback(() => {
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
|
||||
const id = selectedId;
|
||||
const name = stratName;
|
||||
const desc = stratDesc;
|
||||
timeframeAutosaveRef.current = window.setTimeout(() => {
|
||||
timeframeAutosaveRef.current = null;
|
||||
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||||
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||||
void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
|
||||
console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
|
||||
});
|
||||
}, 700);
|
||||
}, [selectedId, stratName, stratDesc]);
|
||||
scheduleStrategyPersist();
|
||||
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
|
||||
|
||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
if (startId === START_NODE_ID) {
|
||||
@@ -438,20 +436,16 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
extraRoots: synced.sell.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
|
||||
}));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
scheduleTimeframePersist();
|
||||
scheduleStrategyPersist();
|
||||
return;
|
||||
}
|
||||
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
|
||||
scheduleTimeframePersist();
|
||||
}, [
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
handleEditorStateChange,
|
||||
layoutStrategyKey,
|
||||
schedulePersistFlowLayout,
|
||||
scheduleTimeframePersist,
|
||||
scheduleStrategyPersist,
|
||||
signalTab,
|
||||
]);
|
||||
|
||||
@@ -461,18 +455,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
persistFlowLayout(layoutStrategyKey);
|
||||
flushStrategyPersist();
|
||||
layoutRevisionRef.current += 1;
|
||||
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||||
setSignalTab(tab);
|
||||
setSelectedNodeId(null);
|
||||
}, [layoutStrategyKey, persistFlowLayout, editorMode]);
|
||||
}, [layoutStrategyKey, flushStrategyPersist, editorMode]);
|
||||
|
||||
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
|
||||
if (mode === editorMode) return;
|
||||
if (editorMode === 'graph') {
|
||||
layoutFlushRef.current?.();
|
||||
persistFlowLayout(layoutStrategyKey);
|
||||
flushStrategyPersist();
|
||||
}
|
||||
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
|
||||
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
|
||||
@@ -480,7 +474,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
setEditorMode(mode);
|
||||
saveEditorMode(mode);
|
||||
setSelectedNodeId(null);
|
||||
}, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
|
||||
}, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
|
||||
|
||||
const layoutSeed = useMemo((): FlowLayoutSeed => {
|
||||
const source = signalTab === 'buy' ? buyLayout : sellLayout;
|
||||
@@ -491,17 +485,29 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
};
|
||||
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||||
}, []);
|
||||
const persistStrategyToDbRef = useRef(persistStrategyToDb);
|
||||
persistStrategyToDbRef.current = persistStrategyToDb;
|
||||
useEffect(() => {
|
||||
const id = selectedId;
|
||||
const name = stratName;
|
||||
return () => {
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
}
|
||||
if (id != null && name.trim()) {
|
||||
void persistStrategyToDbRef.current().catch(() => { /* unmount flush */ });
|
||||
}
|
||||
};
|
||||
}, [selectedId, stratName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutPersistReadyRef.current) {
|
||||
layoutPersistReadyRef.current = true;
|
||||
return;
|
||||
}
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
scheduleStrategyPersist();
|
||||
}, [buyOrphans, sellOrphans, scheduleStrategyPersist]);
|
||||
|
||||
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
|
||||
|
||||
@@ -514,6 +520,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
description: s.description,
|
||||
buyCondition: s.buyCondition as LogicNode | null ?? null,
|
||||
sellCondition: s.sellCondition as LogicNode | null ?? null,
|
||||
flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined,
|
||||
enabled: s.enabled ?? true,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
@@ -553,7 +560,10 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const handleSelectStrategy = (s: StrategyDto) => {
|
||||
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
|
||||
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
||||
const stored = loadStrategyFlowLayout(String(s.id));
|
||||
let stored = normalizeStrategyFlowLayout(s.flowLayout);
|
||||
if (!stored) {
|
||||
stored = readLegacyFlowLayoutFromLocalStorage(String(s.id));
|
||||
}
|
||||
|
||||
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
|
||||
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
|
||||
@@ -600,6 +610,58 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
bumpLayoutSeed(String(s.id), signalTab);
|
||||
|
||||
if (!s.flowLayout && stored) {
|
||||
void (async () => {
|
||||
try {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(
|
||||
toEditorState(buySynced.root, {
|
||||
startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta),
|
||||
extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds,
|
||||
extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp),
|
||||
}),
|
||||
toEditorState(sellSynced.root, {
|
||||
startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta),
|
||||
extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds,
|
||||
extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp),
|
||||
}),
|
||||
);
|
||||
await saveStrategy({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description ?? '',
|
||||
buyCondition: encodeConditionForSave(aligned.buy),
|
||||
sellCondition: encodeConditionForSave(aligned.sell),
|
||||
flowLayout: stored,
|
||||
enabled: s.enabled ?? true,
|
||||
});
|
||||
clearLegacyFlowLayoutFromLocalStorage(String(s.id));
|
||||
setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준)
|
||||
void (async () => {
|
||||
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout });
|
||||
if (!synced) return;
|
||||
try {
|
||||
const fresh = await loadStrategy(s.id);
|
||||
if (!fresh) return;
|
||||
const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null);
|
||||
const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null);
|
||||
const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy');
|
||||
const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell');
|
||||
setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root);
|
||||
setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
@@ -620,39 +682,59 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}
|
||||
setSaveNameError(false);
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
if (strategyAutosaveTimerRef.current != null) {
|
||||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||||
strategyAutosaveTimerRef.current = null;
|
||||
}
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const flowLayout = buildCurrentFlowLayout();
|
||||
const payload: ApiStrategyDto = {
|
||||
id: selectedId ?? undefined,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
};
|
||||
const saved = await saveStrategy(payload);
|
||||
const now = new Date().toISOString();
|
||||
const dbId = saved?.id ?? selectedId ?? Date.now();
|
||||
const wasDraft = selectedId == null;
|
||||
setStrategies(prev => {
|
||||
const existing = prev.find(s => s.id === selectedId);
|
||||
if (existing && selectedId) {
|
||||
return prev.map(s => s.id === selectedId
|
||||
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
|
||||
? {
|
||||
...s,
|
||||
id: dbId,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
updatedAt: saved?.updatedAt ?? now,
|
||||
}
|
||||
: s);
|
||||
}
|
||||
setSelectedId(dbId);
|
||||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||||
return [...prev, {
|
||||
id: dbId,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
createdAt: saved?.createdAt ?? now,
|
||||
updatedAt: saved?.updatedAt ?? now,
|
||||
}];
|
||||
});
|
||||
if (!selectedId) setSelectedId(dbId);
|
||||
if (wasDraft) {
|
||||
migrateStrategyFlowLayout('draft', String(dbId));
|
||||
}
|
||||
persistFlowLayout(String(dbId));
|
||||
bumpLayoutSeed(String(dbId), signalTab);
|
||||
setSaveOpen(false);
|
||||
setSaveToast(true);
|
||||
@@ -668,7 +750,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteId) return;
|
||||
try { await deleteStrategy(deleteId); } catch { /* local */ }
|
||||
deleteStrategyFlowLayout(String(deleteId));
|
||||
clearLegacyFlowLayoutFromLocalStorage(String(deleteId));
|
||||
setStrategies(prev => prev.filter(s => s.id !== deleteId));
|
||||
if (selectedId === deleteId) handleNew();
|
||||
setDeleteOpen(false);
|
||||
@@ -696,7 +778,6 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
});
|
||||
setBuyOrphans(layout.buy.orphans ?? []);
|
||||
setSellOrphans(layout.sell.orphans ?? []);
|
||||
saveStrategyFlowLayout('draft', layout);
|
||||
} else {
|
||||
resetFlowLayout('draft', tab);
|
||||
}
|
||||
@@ -773,7 +854,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}
|
||||
const items = strategies.map(s => strategyDtoToListExportItem(
|
||||
s,
|
||||
loadStrategyFlowLayout(String(s.id)),
|
||||
s.flowLayout ?? null,
|
||||
));
|
||||
downloadStrategyJson(
|
||||
`전략목록_${new Date().toISOString().slice(0, 10)}`,
|
||||
@@ -794,7 +875,6 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const baseId = Date.now();
|
||||
const imported = result.data.strategies.map((item, index) => {
|
||||
const id = baseId + index;
|
||||
if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout);
|
||||
return listImportItemToStrategyDto(item, id);
|
||||
});
|
||||
setStrategies(prev => [...prev, ...imported]);
|
||||
@@ -819,7 +899,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
|
||||
scheduleStrategyPersist();
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
|
||||
|
||||
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||||
const composite = item.kind === 'composite';
|
||||
@@ -827,7 +908,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const root = currentRoot;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
|
||||
scheduleStrategyPersist();
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
|
||||
|
||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||
|
||||
@@ -846,9 +928,10 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
resetFlowLayout(layoutStrategyKey, targetTab);
|
||||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||||
scheduleStrategyPersist();
|
||||
|
||||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]);
|
||||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
|
||||
|
||||
const operators = [
|
||||
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
|
||||
@@ -1068,10 +1151,10 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
theme={theme}
|
||||
root={currentRoot}
|
||||
orphans={currentOrphans}
|
||||
onOrphansChange={setCurrentOrphans}
|
||||
onOrphansChange={handleOrphansChange}
|
||||
def={DEF}
|
||||
signalTab={signalTab}
|
||||
onChange={setCurrentRoot}
|
||||
onChange={handleRootChange}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
layoutSeed={layoutSeed}
|
||||
@@ -1097,14 +1180,14 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
if (!selectedNodeId) return;
|
||||
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
||||
if (inOrphans) {
|
||||
setCurrentOrphans(currentOrphans.map(o => (
|
||||
handleOrphansChange(currentOrphans.map(o => (
|
||||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
|
||||
if (findLogicNode(currentRoot, [], selectedNodeId)) {
|
||||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||
handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||
return;
|
||||
}
|
||||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||||
@@ -1131,7 +1214,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
onEditorStateChange={handleEditorStateChange}
|
||||
onAddStart={handleAddStartSection}
|
||||
orphans={currentOrphans}
|
||||
onOrphansChange={setCurrentOrphans}
|
||||
onOrphansChange={handleOrphansChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -66,11 +66,8 @@ interface ValidationResult {
|
||||
let _cnt = 0;
|
||||
const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||||
|
||||
const STORAGE_KEY = 'gc_strat_v2';
|
||||
const loadStratsLocal = (): StrategyDto[] => {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
|
||||
};
|
||||
const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
const loadStratsLocal = (): StrategyDto[] => [];
|
||||
const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ };
|
||||
|
||||
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
|
||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||
|
||||
@@ -40,30 +40,18 @@ import LayoutPicker from './LayoutPicker';
|
||||
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
|
||||
import { loadStrategies } from '../utils/backendApi';
|
||||
|
||||
const LOCAL_BT_KEY = 'gc_strat_v2';
|
||||
|
||||
interface BtStrategy { id?: number; name: string; source: 'db' | 'local'; buyCondition?: unknown; sellCondition?: unknown; }
|
||||
interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; }
|
||||
|
||||
function loadBtStrategies(): Promise<BtStrategy[]> {
|
||||
return loadStrategies()
|
||||
.then(list => {
|
||||
if (list && list.length > 0) {
|
||||
return list.map(s => ({ id: s.id, name: s.name, source: 'db' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
|
||||
}
|
||||
// localStorage 폴백
|
||||
try {
|
||||
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
|
||||
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
|
||||
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
|
||||
} catch { return []; }
|
||||
})
|
||||
.catch(() => {
|
||||
try {
|
||||
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
|
||||
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
|
||||
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
|
||||
} catch { return []; }
|
||||
});
|
||||
.then(list => (list ?? []).map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
source: 'db' as const,
|
||||
buyCondition: s.buyCondition,
|
||||
sellCondition: s.sellCondition,
|
||||
})))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
export interface ToolbarProps {
|
||||
@@ -98,6 +86,8 @@ export interface ToolbarProps {
|
||||
/** 매매 시그널 알림 (미확인 개수) */
|
||||
tradeNotifyUnread?: number;
|
||||
onOpenTradeNotifications?: () => void;
|
||||
/** 모바일 앱 다운로드 */
|
||||
onOpenAppDownload?: () => void;
|
||||
/** 자석모드 현재 상태 */
|
||||
magnetMode?: 'off' | 'weak' | 'strong';
|
||||
/** 자석모드 토글 (off↔strong) */
|
||||
@@ -233,6 +223,14 @@ const IcBell = () => (
|
||||
<circle cx="7.5" cy="13" r="0.8" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
);
|
||||
/** 모바일 앱 다운로드 */
|
||||
const IcAppDownload = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="1.5" width="7" height="12" rx="1.5"/>
|
||||
<line x1="7.5" y1="9" x2="7.5" y2="12"/>
|
||||
<polyline points="5.5,10.5 7.5,12.5 9.5,10.5"/>
|
||||
</svg>
|
||||
);
|
||||
/** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */
|
||||
const IcTradeSignal = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -785,7 +783,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onIndicatorOrderChange, onRemoveByType,
|
||||
onAutoFib, onClearFib, onScreenshot,
|
||||
onToggleStats, onToggleWatch, onToggleAlert,
|
||||
tradeNotifyUnread = 0, onOpenTradeNotifications,
|
||||
tradeNotifyUnread = 0, onOpenTradeNotifications, onOpenAppDownload,
|
||||
magnetMode = 'off', onToggleMagnet,
|
||||
onFullscreen, onUndo, onRedo, canUndo, canRedo, onClearDrawings,
|
||||
onToggleGrid,
|
||||
@@ -1091,7 +1089,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<li key={i} className="bt-drop-item">
|
||||
<span className="bt-drop-name">
|
||||
{s.name}
|
||||
{s.source === 'local' && <span className="bt-drop-local-badge">로컬</span>}
|
||||
</span>
|
||||
<button
|
||||
className={`bt-drop-run${isRunning ? ' running' : ''}`}
|
||||
@@ -1283,6 +1280,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onOpenAppDownload && (
|
||||
<button
|
||||
className="tv-icon-btn"
|
||||
onClick={onOpenAppDownload}
|
||||
title="GoldenChart 모바일 앱 다운로드"
|
||||
aria-label="앱 다운로드"
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
)}
|
||||
{onOpenTradeNotifications && (
|
||||
<span className="tv-trade-notify-wrap">
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,8 @@ interface TopMenuBarProps {
|
||||
/** 미확인 매매 시그널 알림 수 */
|
||||
tradeNotifyUnread?: number;
|
||||
onOpenNotifications?: () => void;
|
||||
/** 모바일 앱 다운로드 모달 */
|
||||
onOpenAppDownload?: () => void;
|
||||
/** 우측에 떠 있는 알림 팝업(토스트) 개수 */
|
||||
tradeNotifyToastCount?: number;
|
||||
/** 모든 알림 팝업 닫기 */
|
||||
@@ -79,6 +81,14 @@ const IcSettings = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcAppDownload = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5"/>
|
||||
<line x1="8" y1="9" x2="8" y2="12.5"/>
|
||||
<polyline points="6,10.5 8,12.5 10,10.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcNotify = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
|
||||
@@ -160,6 +170,7 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
activePage, theme, onPage, onTheme,
|
||||
tradeNotifyUnread = 0,
|
||||
onOpenNotifications,
|
||||
onOpenAppDownload,
|
||||
tradeNotifyToastCount = 0,
|
||||
onDismissAllNotifyPopups,
|
||||
tradeAlertPopupPosition = 'right',
|
||||
@@ -209,6 +220,17 @@ export const TopMenuBar = memo(function TopMenuBar({
|
||||
|
||||
{/* 우측 영역 */}
|
||||
<div className="tmb-right">
|
||||
{onOpenAppDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-app-download-btn"
|
||||
onClick={onOpenAppDownload}
|
||||
title="GoldenChart 모바일 앱 다운로드"
|
||||
aria-label="앱 다운로드"
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
)}
|
||||
{onOpenNotifications && showNotifications && (
|
||||
<div className="tmb-notify-group">
|
||||
<button
|
||||
|
||||
@@ -221,8 +221,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevTheme = useRef<Theme>(theme);
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
const indicatorsRef = useRef(indicators);
|
||||
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resizeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const recoveryTimerRef = useRef<number | null>(null);
|
||||
const resizeDebounceRef = useRef<number | null>(null);
|
||||
const chartVisibleRef = useRef(chartVisible);
|
||||
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
|
||||
@@ -33,16 +33,6 @@ import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
|
||||
import '../styles/trendSearchDashboard.css';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
const DISPLAY_MODE_KEY = 'tsd-display-mode';
|
||||
|
||||
function loadDisplayMode(): TrendSearchDisplayMode {
|
||||
try {
|
||||
const v = localStorage.getItem(DISPLAY_MODE_KEY);
|
||||
if (v === 'chart' || v === 'summary') return v;
|
||||
} catch { /* ignore */ }
|
||||
return 'summary';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
@@ -68,7 +58,9 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
|
||||
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
|
||||
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(
|
||||
() => tsSettings.displayMode ?? 'summary',
|
||||
);
|
||||
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
|
||||
const filtersRef = useRef(filters);
|
||||
@@ -84,6 +76,7 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
if (lastSyncedSettingsSigRef.current === tsSettingsSig) return;
|
||||
lastSyncedSettingsSigRef.current = tsSettingsSig;
|
||||
setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings));
|
||||
if (tsSettings.displayMode) setDisplayMode(tsSettings.displayMode);
|
||||
}, [appSettingsLoaded, tsSettingsSig, tsSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,8 +161,10 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
}, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
|
||||
}, [displayMode]);
|
||||
if (!appSettingsLoaded) return;
|
||||
if (tsSettings.displayMode === displayMode) return;
|
||||
persistTrendSettings({ displayMode });
|
||||
}, [displayMode, appSettingsLoaded, tsSettings.displayMode, persistTrendSettings]);
|
||||
|
||||
const handleSelect = useCallback(async (row: TrendSearchResultDto) => {
|
||||
setSelectedMarket(row.market);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { getUiPreferences, patchUiPreferences } from '../../utils/uiPreferencesDb';
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -49,9 +50,9 @@ export function usePanelResize(
|
||||
|
||||
export function readStoredSize(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const n = Number(raw);
|
||||
const raw = getUiPreferences().panels?.[key];
|
||||
if (raw == null) return fallback;
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
@@ -59,22 +60,20 @@ export function readStoredSize(key: string, fallback: number): number {
|
||||
}
|
||||
|
||||
export function storeSize(key: string, value: number): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(Math.round(value)));
|
||||
} catch { /* ignore */ }
|
||||
const panels = { ...getUiPreferences().panels, [key]: Math.round(value) };
|
||||
patchUiPreferences({ panels });
|
||||
}
|
||||
|
||||
export function readStoredBool(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === '1' || raw === 'true') return true;
|
||||
if (raw === '0' || raw === 'false') return false;
|
||||
const raw = getUiPreferences().panels?.[key];
|
||||
if (raw === true || raw === '1' || raw === 'true') return true;
|
||||
if (raw === false || raw === '0' || raw === 'false') return false;
|
||||
} catch { /* ignore */ }
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function storeBool(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, value ? '1' : '0');
|
||||
} catch { /* ignore */ }
|
||||
const panels = { ...getUiPreferences().panels, [key]: value };
|
||||
patchUiPreferences({ panels });
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ import {
|
||||
type TradeAlertSoundId,
|
||||
} from '../utils/tradeAlertSound';
|
||||
|
||||
const STORAGE_KEY = 'gc_trade_notify_read_v1';
|
||||
const HIDDEN_STORAGE_KEY = 'gc_trade_notify_hidden_v1';
|
||||
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
|
||||
const MAX_HISTORY = 300;
|
||||
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
||||
const MAX_TOAST_QUEUE = 50;
|
||||
@@ -94,35 +93,21 @@ function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotification
|
||||
}
|
||||
|
||||
function loadReadIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
return new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
const ids = getUiPreferences().tradeNotifications?.readIds;
|
||||
return new Set(ids ?? []);
|
||||
}
|
||||
|
||||
function saveReadIds(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch { /* ignore */ }
|
||||
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
|
||||
}
|
||||
|
||||
function loadHiddenIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
return new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
const ids = getUiPreferences().tradeNotifications?.hiddenIds;
|
||||
return new Set(ids ?? []);
|
||||
}
|
||||
|
||||
function saveHiddenIds(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch { /* ignore */ }
|
||||
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
|
||||
}
|
||||
|
||||
export function useTradeNotification(): TradeNotificationContextValue {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
resolveChartPaneSeparatorOptions,
|
||||
type ChartPaneSeparatorOptions,
|
||||
} from '../types/chartPaneSeparator';
|
||||
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
|
||||
|
||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||
let _cache: AppSettingsDto | null = null;
|
||||
@@ -73,8 +74,18 @@ export function subscribeAppSettings(listener: AppSettingsListener): () => void
|
||||
function ensureLoaded(): Promise<AppSettingsDto> {
|
||||
if (_cache !== null) return Promise.resolve(_cache);
|
||||
if (_loadPromise) return _loadPromise;
|
||||
_loadPromise = loadAppSettings().then(data => {
|
||||
_loadPromise = loadAppSettings().then(async data => {
|
||||
_cache = data ?? {};
|
||||
const migrated = migrateLocalStorageToUiPreferences(_cache);
|
||||
if (migrated?.changed) {
|
||||
try {
|
||||
const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences });
|
||||
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
|
||||
} catch (e) {
|
||||
console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e);
|
||||
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
|
||||
}
|
||||
}
|
||||
_loadPromise = null;
|
||||
notifyAppSettingsListeners();
|
||||
return _cache;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { IndicatorConfig } from './index';
|
||||
import type { PaletteItem } from '../utils/strategyPaletteStorage';
|
||||
import type { IndicatorCustomTab } from '../utils/indicatorCustomTabsStorage';
|
||||
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
|
||||
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
|
||||
|
||||
/** gc_app_settings.ui_preferences_json 구조 */
|
||||
export interface UiPreferences {
|
||||
v?: number;
|
||||
strategyEditor?: {
|
||||
editorMode?: StrategyEditorMode;
|
||||
canvasInteraction?: string;
|
||||
paletteAuxiliary?: PaletteItem[];
|
||||
paletteComposite?: PaletteItem[];
|
||||
};
|
||||
indicator?: {
|
||||
customTabs?: IndicatorCustomTab[];
|
||||
mainTabOrder?: string[];
|
||||
listOrder?: string[];
|
||||
};
|
||||
chart?: {
|
||||
alertPrices?: number[];
|
||||
legacyIndicators?: IndicatorConfig[];
|
||||
};
|
||||
panels?: Record<string, number | boolean | string>;
|
||||
virtual?: {
|
||||
targets?: VirtualTargetItem[];
|
||||
session?: VirtualSessionConfig;
|
||||
cardViewMode?: VirtualCardViewMode;
|
||||
};
|
||||
tradeNotifications?: {
|
||||
readIds?: string[];
|
||||
hiddenIds?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const UI_PREFERENCES_VERSION = 1;
|
||||
|
||||
export const EMPTY_UI_PREFERENCES: UiPreferences = { v: UI_PREFERENCES_VERSION };
|
||||
@@ -5,6 +5,8 @@
|
||||
* 환경변수 VITE_API_BASE_URL 로 백엔드 URL 설정 (기본: http://localhost:8080/api)
|
||||
*/
|
||||
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
/** 백엔드 REST base (Docker 빌드: `/api`, 로컬 Vite: `.env` 또는 localhost:8080) */
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
|
||||
|
||||
@@ -444,6 +446,8 @@ export interface AppSettingsDto {
|
||||
displayTimezone?: string;
|
||||
/** 추세검색 기본 설정 */
|
||||
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
||||
uiPreferences?: import('../types/uiPreferences').UiPreferences | null;
|
||||
}
|
||||
|
||||
export interface LiveSummaryDto {
|
||||
@@ -555,6 +559,22 @@ export async function sendFcmTest(): Promise<{ ok: boolean; available: boolean }
|
||||
return request<{ ok: boolean; available: boolean }>('/fcm/test', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 모바일 앱 배포 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MobileAppReleaseInfoDto {
|
||||
available: boolean;
|
||||
fileName?: string;
|
||||
version?: string;
|
||||
sizeBytes?: number;
|
||||
updatedAt?: string;
|
||||
downloadUrl?: string;
|
||||
installPageUrl?: string;
|
||||
}
|
||||
|
||||
export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDto | null> {
|
||||
return request<MobileAppReleaseInfoDto>('/mobile-app/info');
|
||||
}
|
||||
|
||||
// ── 모의투자 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaperPositionDto {
|
||||
@@ -811,6 +831,8 @@ export interface StrategyDto {
|
||||
description?: string;
|
||||
buyCondition?: unknown; // LogicNode DSL
|
||||
sellCondition?: unknown; // LogicNode DSL
|
||||
/** 편집기 캔버스·START 분봉·고아 노드 (gc_strategy.flow_layout_json) */
|
||||
flowLayout?: StrategyFlowLayoutStore;
|
||||
enabled?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** 지표 추가 팝업 — 사용자 정의 탭 (localStorage) */
|
||||
/** 지표 추가 팝업 — 사용자 정의 탭 (DB) */
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export interface IndicatorCustomTab {
|
||||
id: string;
|
||||
@@ -7,33 +8,16 @@ export interface IndicatorCustomTab {
|
||||
indicatorTypes: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'gc-indicator-custom-tabs-v1';
|
||||
|
||||
function newId(): string {
|
||||
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function loadCustomTabs(): IndicatorCustomTab[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr.filter(
|
||||
(t): t is IndicatorCustomTab =>
|
||||
t != null
|
||||
&& typeof t === 'object'
|
||||
&& typeof (t as IndicatorCustomTab).id === 'string'
|
||||
&& typeof (t as IndicatorCustomTab).name === 'string'
|
||||
&& Array.isArray((t as IndicatorCustomTab).indicatorTypes),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return getUiPreferences().indicator?.customTabs ?? [];
|
||||
}
|
||||
|
||||
export function saveCustomTabs(tabs: IndicatorCustomTab[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||
patchUiPreferences({ indicator: { customTabs: tabs } });
|
||||
window.dispatchEvent(new CustomEvent('gc-indicator-custom-tabs-changed'));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
/**
|
||||
* 보조지표 설정 목록 표시·차트 pane 순서 (localStorage)
|
||||
* 보조지표 설정 목록 표시·차트 pane 순서 (DB)
|
||||
*/
|
||||
import { getSettingsIndicatorTypes } from './indicatorSettingsList';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { getPaneHostId } from './indicatorPaneMerge';
|
||||
|
||||
const STORAGE_KEY = 'gc_indicator_settings_list_order';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export function loadIndicatorListOrder(): string[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const order = getUiPreferences().indicator?.listOrder;
|
||||
if (!order?.length) return null;
|
||||
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
}
|
||||
|
||||
export function saveIndicatorListOrder(types: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
patchUiPreferences({ indicator: { listOrder: types } });
|
||||
}
|
||||
|
||||
/** 저장 순서 + registry 신규 type 병합 */
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
/**
|
||||
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서
|
||||
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서 (DB)
|
||||
*/
|
||||
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
|
||||
import { mergeIndicatorListOrder } from './indicatorListOrder';
|
||||
|
||||
const STORAGE_KEY = 'gc_indicator_main_tab_order';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export function loadMainTabOrder(): string[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const order = getUiPreferences().indicator?.mainTabOrder;
|
||||
if (!order?.length) return null;
|
||||
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
}
|
||||
|
||||
export function saveMainTabOrder(types: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
patchUiPreferences({ indicator: { mainTabOrder: types } });
|
||||
}
|
||||
|
||||
export function getOrderedMainIndicatorTypes(): string[] {
|
||||
|
||||
@@ -1,50 +1,36 @@
|
||||
/**
|
||||
* 관심종목(즐겨찾기) / 보유종목 관리
|
||||
* - DB(gc_watchlist)가 기준 저장소
|
||||
* - localStorage(gc_favorites)는 오프라인 미러·UI 동기화용
|
||||
* - DB(gc_watchlist / gc_holdings)가 기준 저장소
|
||||
* - 메모리 캐시만 사용 (localStorage 미사용)
|
||||
*/
|
||||
|
||||
import { addWatchlistItem, removeWatchlistItem } from './backendApi';
|
||||
|
||||
const FAV_KEY = 'gc_favorites';
|
||||
const LEGACY_FAV_KEY = 'upbit_market_favorites';
|
||||
const HOLD_KEY = 'gc_holdings';
|
||||
|
||||
/** 관심종목 변경 시 MarketSearchPanel·App 등이 동기화하도록 발행 */
|
||||
export const FAVORITES_CHANGED_EVENT = 'gc-favorites-changed';
|
||||
|
||||
// ── 즐겨찾기 (관심종목) ─────────────────────────────────────────────────────
|
||||
let favoritesCache: string[] | null = null;
|
||||
let holdingsCache: string[] | null = null;
|
||||
|
||||
/** 구버전 MarketSearchPanel 키 → gc_favorites 로 1회 마이그레이션 */
|
||||
function migrateLegacyFavorites(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAV_KEY);
|
||||
if (raw) {
|
||||
const current = JSON.parse(raw) as string[];
|
||||
if (Array.isArray(current) && current.length > 0) return;
|
||||
}
|
||||
const legacy = localStorage.getItem(LEGACY_FAV_KEY);
|
||||
if (!legacy) return;
|
||||
const parsed = JSON.parse(legacy) as string[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
saveFavorites(parsed, false);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
/** DB 로드 후 App·워크스페이스 훅에서 호출 */
|
||||
export function initFavoritesFromDb(symbols: string[]): void {
|
||||
favoritesCache = [...symbols];
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: favoritesCache }));
|
||||
}
|
||||
|
||||
export function initHoldingsFromDb(symbols: string[]): void {
|
||||
holdingsCache = [...symbols];
|
||||
}
|
||||
|
||||
export function getFavorites(): string[] {
|
||||
migrateLegacyFavorites();
|
||||
try { return JSON.parse(localStorage.getItem(FAV_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
return favoritesCache ?? [];
|
||||
}
|
||||
|
||||
export function saveFavorites(markets: string[], notify = true): void {
|
||||
try {
|
||||
localStorage.setItem(FAV_KEY, JSON.stringify(markets));
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
favoritesCache = [...markets];
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,15 +62,12 @@ export function isFavorite(market: string): boolean {
|
||||
return getFavorites().includes(market);
|
||||
}
|
||||
|
||||
// ── 보유종목 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getHoldings(): string[] {
|
||||
try { return JSON.parse(localStorage.getItem(HOLD_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
return holdingsCache ?? [];
|
||||
}
|
||||
|
||||
export function saveHoldings(markets: string[]): void {
|
||||
try { localStorage.setItem(HOLD_KEY, JSON.stringify(markets)); } catch { /* ignore */ }
|
||||
holdingsCache = [...markets];
|
||||
}
|
||||
|
||||
/** 추가 → 변경 후 전체 목록 반환 (중복 무시) */
|
||||
|
||||
@@ -12,8 +12,7 @@ interface ChartState {
|
||||
mainChartStyle?: MainChartStyle;
|
||||
}
|
||||
|
||||
const KEY = 'trading_chart_state';
|
||||
const CUR_VERSION = 8; // v8: 기본 지표 제거 — 초기 로딩 시 캔들차트만 표시
|
||||
const CUR_VERSION = 8;
|
||||
|
||||
/** 기본 보조지표 없음 — 처음 로딩 시 캔들차트만 표시 */
|
||||
const DEFAULT_INDICATORS: IndicatorConfig[] = [];
|
||||
@@ -25,8 +24,7 @@ const DEFAULT_WATCHLIST = [
|
||||
|
||||
/**
|
||||
* 절대적 최후 fallback 기본값.
|
||||
* 우선순위: DB(gc_app_settings) > localStorage > 이 값.
|
||||
* 타임프레임을 '1h' 에서 '1D' 로 통일 (ChartSlot 기본값과 일치).
|
||||
* 우선순위: DB(gc_app_settings + gc_chart_workspace) > 이 값.
|
||||
*/
|
||||
const DEFAULT_STATE: ChartState = {
|
||||
version: CUR_VERSION,
|
||||
@@ -39,31 +37,19 @@ const DEFAULT_STATE: ChartState = {
|
||||
alertPrices: [],
|
||||
};
|
||||
|
||||
export function saveState(state: Partial<ChartState>): void {
|
||||
try {
|
||||
const existing = loadState();
|
||||
localStorage.setItem(KEY, JSON.stringify({ ...existing, ...state, version: CUR_VERSION }));
|
||||
} catch { /* ignore */ }
|
||||
/** @deprecated DB·워크스페이스 사용 — no-op */
|
||||
export function saveState(_state: Partial<ChartState>): void {
|
||||
/* chart state는 gc_chart_workspace·gc_app_settings로 저장 */
|
||||
}
|
||||
|
||||
/** 초기 마운트 fallback (DB 로드 전 1회) */
|
||||
export function loadState(): ChartState {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as ChartState;
|
||||
// 버전이 다르면 기본값으로 재설정 (마이그레이션)
|
||||
if (parsed.version !== CUR_VERSION) {
|
||||
localStorage.removeItem(KEY);
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function clearState(): void {
|
||||
localStorage.removeItem(KEY);
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
export const DEFAULT_MAIN_CHART_STYLE: MainChartStyle = {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
export type StrategyCanvasInteractionMode = 'pan' | 'select';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
const STORAGE_KEY = 'gc_se_canvas_interaction';
|
||||
export type CanvasInteractionMode = 'pan' | 'select';
|
||||
/** @deprecated CanvasInteractionMode */
|
||||
export type StrategyCanvasInteractionMode = CanvasInteractionMode;
|
||||
|
||||
export function loadCanvasInteractionMode(): StrategyCanvasInteractionMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'pan' ? 'pan' : 'select';
|
||||
} catch {
|
||||
return 'select';
|
||||
}
|
||||
export function loadCanvasInteractionMode(): CanvasInteractionMode {
|
||||
const mode = getUiPreferences().strategyEditor?.canvasInteraction;
|
||||
return mode === 'pan' ? 'pan' : 'select';
|
||||
}
|
||||
|
||||
export function saveCanvasInteractionMode(mode: StrategyCanvasInteractionMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
export function saveCanvasInteractionMode(mode: CanvasInteractionMode): void {
|
||||
patchUiPreferences({ strategyEditor: { canvasInteraction: mode } });
|
||||
}
|
||||
|
||||
@@ -432,35 +432,33 @@ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
||||
}
|
||||
|
||||
/** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */
|
||||
function pickMergedStartCandleTypes(decoded: string[], stored?: string[]): string[] {
|
||||
const fromDb = normalizeCandleTypesList(decoded);
|
||||
const fromStored = stored?.length ? normalizeCandleTypesList(stored) : [];
|
||||
const dbIsLegacyDefault = fromDb.length === 1 && fromDb[0] === '1m';
|
||||
const storedDiffers = fromStored.length > 0
|
||||
&& (fromStored.length !== fromDb.length || fromStored.some((ct, i) => ct !== fromDb[i]));
|
||||
if (storedDiffers && (dbIsLegacyDefault || fromDb.length === 0)) {
|
||||
return fromStored;
|
||||
}
|
||||
return fromDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 로드 시 startMeta 병합 — DB가 1m 기본값만 있고 localStorage에 다른 분봉이 있으면 localStorage 우선.
|
||||
* 전략 로드 시 startMeta 병합 — flow_layout_json(편집기)이 있으면 DSL TIMEFRAME보다 우선.
|
||||
*/
|
||||
export function mergeStartMetaForLoad(
|
||||
decoded: Record<string, StartNodeMeta>,
|
||||
stored?: Record<string, StartNodeMeta> | null,
|
||||
): Record<string, StartNodeMeta> {
|
||||
if (!stored || Object.keys(stored).length === 0) {
|
||||
const fromDsl: Record<string, StartNodeMeta> = { ...defaultStartMeta(), ...decoded };
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||
fromDsl[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
return fromDsl;
|
||||
}
|
||||
const merged: Record<string, StartNodeMeta> = {
|
||||
...defaultStartMeta(),
|
||||
...stored,
|
||||
};
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
const types = pickMergedStartCandleTypes(
|
||||
getStartCandleTypes(meta),
|
||||
stored?.[startId] ? getStartCandleTypes(stored[startId]) : undefined,
|
||||
);
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
if (stored[startId]) {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(stored[startId]));
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
} else {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export type StrategyFlowLayoutStore = {
|
||||
sell: SignalFlowLayoutSnapshot;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'gc_se_flow_layout_v1';
|
||||
/** @deprecated 레거시 localStorage 키 — 신규 저장은 DB flow_layout_json만 사용 */
|
||||
const LEGACY_STORAGE_KEY = 'gc_se_flow_layout_v1';
|
||||
|
||||
export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot {
|
||||
return {
|
||||
@@ -42,44 +43,67 @@ export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore {
|
||||
return { buy: emptySignalFlowLayout(), sell: emptySignalFlowLayout() };
|
||||
}
|
||||
|
||||
export function loadStrategyFlowLayout(strategyKey: string): StrategyFlowLayoutStore | null {
|
||||
export function normalizeSignalFlowLayout(
|
||||
snap: SignalFlowLayoutSnapshot | null | undefined,
|
||||
): SignalFlowLayoutSnapshot {
|
||||
if (!snap) return emptySignalFlowLayout();
|
||||
return {
|
||||
positions: snap.positions ?? {},
|
||||
edgeHandles: snap.edgeHandles ?? {},
|
||||
orphans: snap.orphans ?? [],
|
||||
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: snap.extraStartIds ?? [],
|
||||
extraRoots: snap.extraRoots ?? {},
|
||||
startCombineOp: snap.startCombineOp,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStrategyFlowLayout(
|
||||
store: StrategyFlowLayoutStore | null | undefined,
|
||||
): StrategyFlowLayoutStore | null {
|
||||
if (!store) return null;
|
||||
return {
|
||||
buy: normalizeSignalFlowLayout(store.buy),
|
||||
sell: normalizeSignalFlowLayout(store.sell),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStrategyFlowLayoutStore(
|
||||
buy: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
|
||||
sell: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
|
||||
buyOrphans: LogicNode[],
|
||||
sellOrphans: LogicNode[],
|
||||
): StrategyFlowLayoutStore {
|
||||
return {
|
||||
buy: { ...buy, orphans: buyOrphans },
|
||||
sell: { ...sell, orphans: sellOrphans },
|
||||
};
|
||||
}
|
||||
|
||||
/** DB에 flow layout이 없을 때만 1회 읽기 — 이후 저장은 서버로 이전 */
|
||||
export function readLegacyFlowLayoutFromLocalStorage(
|
||||
strategyKey: string,
|
||||
): StrategyFlowLayoutStore | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
|
||||
return all[strategyKey] ?? null;
|
||||
return normalizeStrategyFlowLayout(all[strategyKey] ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStrategyFlowLayout(strategyKey: string, layout: StrategyFlowLayoutStore): void {
|
||||
export function clearLegacyFlowLayoutFromLocalStorage(strategyKey: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const all: Record<string, StrategyFlowLayoutStore> = raw ? JSON.parse(raw) : {};
|
||||
all[strategyKey] = layout;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteStrategyFlowLayout(strategyKey: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
|
||||
if (!(strategyKey in all)) return;
|
||||
delete all[strategyKey];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
if (Object.keys(all).length === 0) localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
else localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateStrategyFlowLayout(fromKey: string, toKey: string): void {
|
||||
if (fromKey === toKey) return;
|
||||
const layout = loadStrategyFlowLayout(fromKey);
|
||||
if (!layout) return;
|
||||
saveStrategyFlowLayout(toKey, layout);
|
||||
deleteStrategyFlowLayout(fromKey);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export type StrategyEditorMode = 'graph' | 'list';
|
||||
|
||||
const STORAGE_KEY = 'gc_se_editor_mode';
|
||||
|
||||
export function loadEditorMode(): StrategyEditorMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'list' ? 'list' : 'graph';
|
||||
} catch {
|
||||
return 'graph';
|
||||
}
|
||||
const mode = getUiPreferences().strategyEditor?.editorMode;
|
||||
return mode === 'list' ? 'list' : 'graph';
|
||||
}
|
||||
|
||||
export function saveEditorMode(mode: StrategyEditorMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
patchUiPreferences({ strategyEditor: { editorMode: mode } });
|
||||
}
|
||||
|
||||
@@ -56,12 +56,15 @@ import {
|
||||
} from '../utils/thresholdSymbols';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
|
||||
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
export interface StrategyDto {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
buyCondition?: LogicNode | null;
|
||||
sellCondition?: LogicNode | null;
|
||||
flowLayout?: StrategyFlowLayoutStore;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -78,11 +81,10 @@ export type DefType = typeof DEF_DEFAULTS;
|
||||
let _cnt = 0;
|
||||
export const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||||
|
||||
export const STORAGE_KEY = 'gc_strat_v2';
|
||||
export const loadStratsLocal = (): StrategyDto[] => {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
|
||||
};
|
||||
export const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
/** @deprecated DB(gc_strategy)만 사용 */
|
||||
export const loadStratsLocal = (): StrategyDto[] => [];
|
||||
/** @deprecated */
|
||||
export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ };
|
||||
|
||||
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
|
||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (localStorage) */
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (DB ui_preferences) */
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
|
||||
|
||||
export type PaletteItemKind = 'auxiliary' | 'composite';
|
||||
@@ -18,9 +19,6 @@ export interface PaletteItem {
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_AUX = 'se-palette-auxiliary-v1';
|
||||
const STORAGE_COMP = 'se-palette-composite-v1';
|
||||
|
||||
export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', builtIn: true },
|
||||
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', builtIn: true },
|
||||
@@ -111,32 +109,32 @@ function mergeMissingBuiltIns(
|
||||
}
|
||||
|
||||
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
try {
|
||||
const stored = parseStored(localStorage.getItem(key));
|
||||
if (stored && stored.length > 0) {
|
||||
return mergeMissingBuiltIns(
|
||||
stored.map(i => ({
|
||||
...i,
|
||||
kind,
|
||||
...(i.builtIn
|
||||
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
|
||||
: {}),
|
||||
})),
|
||||
const prefs = getUiPreferences().strategyEditor;
|
||||
const raw = kind === 'auxiliary' ? prefs?.paletteAuxiliary : prefs?.paletteComposite;
|
||||
const stored = raw?.length ? raw : null;
|
||||
if (stored && stored.length > 0) {
|
||||
return mergeMissingBuiltIns(
|
||||
stored.map(i => ({
|
||||
...i,
|
||||
kind,
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
...(i.builtIn
|
||||
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
|
||||
: {}),
|
||||
})),
|
||||
kind,
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
return seedItems(kind, defaults);
|
||||
}
|
||||
|
||||
export function savePaletteItems(kind: PaletteItemKind, items: PaletteItem[]): void {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items));
|
||||
} catch { /* ignore */ }
|
||||
if (kind === 'auxiliary') {
|
||||
patchUiPreferences({ strategyEditor: { paletteAuxiliary: items } });
|
||||
} else {
|
||||
patchUiPreferences({ strategyEditor: { paletteComposite: items } });
|
||||
}
|
||||
}
|
||||
|
||||
export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi';
|
||||
import { loadStrategyFlowLayout } from './strategyEditorLayoutStorage';
|
||||
import {
|
||||
alignBuySellStartCandleTypesForSave,
|
||||
collectDslBranches,
|
||||
@@ -14,8 +13,7 @@ import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNo
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
function collectFromFlowLayout(strategyId: number): string[] {
|
||||
const layout = loadStrategyFlowLayout(String(strategyId));
|
||||
function collectFromFlowLayout(layout: StrategyFlowLayoutStore | null | undefined): string[] {
|
||||
if (!layout) return [];
|
||||
const set = new Set<string>();
|
||||
for (const side of ['buy', 'sell'] as const) {
|
||||
@@ -43,9 +41,9 @@ function collectFromStrategyDto(strategy: StrategyDto | null | undefined): strin
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 (서버 DSL보다 우선) */
|
||||
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 */
|
||||
export function collectUiEvaluationTimeframes(
|
||||
strategyId: number,
|
||||
_strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||
): string[] {
|
||||
@@ -56,7 +54,7 @@ export function collectUiEvaluationTimeframes(
|
||||
].map(normalizeStartCandleType);
|
||||
if (fromEditor.length > 0) return [...new Set(fromEditor)];
|
||||
}
|
||||
const fromLayout = collectFromFlowLayout(strategyId);
|
||||
const fromLayout = collectFromFlowLayout(strategy?.flowLayout);
|
||||
if (fromLayout.length > 0) return fromLayout;
|
||||
return collectFromStrategyDto(strategy);
|
||||
}
|
||||
@@ -85,7 +83,6 @@ export async function warnStrategyTimeframeMismatch(
|
||||
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
|
||||
|
||||
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
|
||||
// 양쪽 모두 1m만 — 일치로 간주
|
||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||
return true;
|
||||
}
|
||||
@@ -94,14 +91,14 @@ export async function warnStrategyTimeframeMismatch(
|
||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||
window.alert(
|
||||
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 자동 저장된 뒤 실시간 체크를 켜 주세요.',
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildEditorStateFromStrategy(
|
||||
strategy: StrategyDto,
|
||||
layout: StrategyFlowLayoutStore | null,
|
||||
layout: StrategyFlowLayoutStore | null | undefined,
|
||||
side: 'buy' | 'sell',
|
||||
): EditorConditionState {
|
||||
const decoded = decodeConditionForEditor(
|
||||
@@ -118,14 +115,16 @@ function buildEditorStateFromStrategy(
|
||||
}
|
||||
|
||||
/**
|
||||
* flow layout(localStorage)의 START 분봉이 서버 DSL과 다르면 편집기 저장 없이 자동 동기화.
|
||||
* @returns false — layout 없음 등으로 동기화 불가
|
||||
* DB flow layout의 START 분봉이 서버 DSL과 다르면 자동 동기화.
|
||||
*/
|
||||
export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
): Promise<boolean> {
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy);
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
if (!strat) return true;
|
||||
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
||||
if (uiTfs.length === 0) return true;
|
||||
|
||||
let serverTfs: string[];
|
||||
@@ -139,14 +138,11 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
if (missingOnServer.length === 0) return true;
|
||||
|
||||
const layout = loadStrategyFlowLayout(String(strategyId));
|
||||
const layout = strat.flowLayout;
|
||||
if (!layout) {
|
||||
return warnStrategyTimeframeMismatch(strategyId, strategy);
|
||||
return warnStrategyTimeframeMismatch(strategyId, strat);
|
||||
}
|
||||
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
if (!strat) return true;
|
||||
|
||||
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
||||
const sellState = buildEditorStateFromStrategy(strat, layout, 'sell');
|
||||
|
||||
@@ -156,6 +152,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strat.description ?? '',
|
||||
buyState,
|
||||
sellState,
|
||||
layout,
|
||||
);
|
||||
try {
|
||||
await repairStrategyTimeframes(strategyId);
|
||||
@@ -165,25 +162,46 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */
|
||||
/** 편집기 상태(DSL + flow layout)를 DB에 저장 — 실시간 평가 캐시는 서버에서 무효화됨 */
|
||||
export async function persistStrategyEditorState(
|
||||
strategyId: number,
|
||||
name: string,
|
||||
description: string,
|
||||
buy: EditorConditionState,
|
||||
sell: EditorConditionState,
|
||||
flowLayout: StrategyFlowLayoutStore,
|
||||
): Promise<StrategyDto> {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
const saved = await saveStrategy({
|
||||
id: strategyId,
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('gc:strategy-saved', {
|
||||
detail: { strategyId, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout },
|
||||
}));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** @deprecated persistStrategyEditorState 사용 */
|
||||
export async function persistStrategyEvaluationTimeframes(
|
||||
strategyId: number,
|
||||
name: string,
|
||||
description: string,
|
||||
buy: EditorConditionState,
|
||||
sell: EditorConditionState,
|
||||
flowLayout?: StrategyFlowLayoutStore,
|
||||
): Promise<void> {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
await saveStrategy({
|
||||
id: strategyId,
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
enabled: true,
|
||||
});
|
||||
if (!flowLayout) return;
|
||||
await persistStrategyEditorState(strategyId, name, description, buy, sell, flowLayout);
|
||||
}
|
||||
|
||||
export type { StrategyFlowLayoutStore };
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
type BullishWeightKey,
|
||||
} from './trendSearchBullishWeights';
|
||||
|
||||
/** 결과 목록 표시: chart | summary(신호) */
|
||||
export type TrendSearchDisplayMode = 'chart' | 'summary';
|
||||
|
||||
export interface TrendSearchAppSettings {
|
||||
/** 결과 UI 표시 모드 (기본 summary) */
|
||||
displayMode?: TrendSearchDisplayMode;
|
||||
weightMaAlignment: number;
|
||||
weightMaSlope: number;
|
||||
weightAdxTrend: number;
|
||||
@@ -32,6 +37,7 @@ export interface TrendSearchAppSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
|
||||
displayMode: 'summary',
|
||||
weightMaAlignment: DEFAULT_BULLISH_WEIGHTS.weightMaAlignment,
|
||||
weightMaSlope: DEFAULT_BULLISH_WEIGHTS.weightMaSlope,
|
||||
weightAdxTrend: DEFAULT_BULLISH_WEIGHTS.weightAdxTrend,
|
||||
@@ -59,7 +65,11 @@ export function resolveTrendSearchAppSettings(
|
||||
const rank = Number(raw.autoAddTopRank);
|
||||
const limit = Number(raw.limit);
|
||||
const scanLimit = Number(raw.scanLimit);
|
||||
const displayMode = raw.displayMode === 'chart' || raw.displayMode === 'summary'
|
||||
? raw.displayMode
|
||||
: d.displayMode ?? 'summary';
|
||||
return {
|
||||
displayMode,
|
||||
weightMaAlignment: clampWeight(raw.weightMaAlignment, d.weightMaAlignment),
|
||||
weightMaSlope: clampWeight(raw.weightMaSlope, d.weightMaSlope),
|
||||
weightAdxTrend: clampWeight(raw.weightAdxTrend, d.weightAdxTrend),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* UI 설정 — gc_app_settings.ui_preferences_json (DB 단일 소스)
|
||||
*/
|
||||
import {
|
||||
getAppSettingsCache,
|
||||
subscribeAppSettings,
|
||||
} from '../hooks/useAppSettings';
|
||||
import { saveAppSettings, type AppSettingsDto } from './backendApi';
|
||||
import {
|
||||
EMPTY_UI_PREFERENCES,
|
||||
UI_PREFERENCES_VERSION,
|
||||
type UiPreferences,
|
||||
} from '../types/uiPreferences';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingPatch: Partial<UiPreferences> | null = null;
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return v != null && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(base: T, patch: Partial<T>): T {
|
||||
const out = { ...base } as T;
|
||||
for (const key of Object.keys(patch) as (keyof T)[]) {
|
||||
const pv = patch[key];
|
||||
if (pv === undefined) continue;
|
||||
const bv = base[key];
|
||||
if (isPlainObject(bv) && isPlainObject(pv)) {
|
||||
out[key] = deepMerge(bv as Record<string, unknown>, pv as Record<string, unknown>) as T[keyof T];
|
||||
} else {
|
||||
out[key] = pv as T[keyof T];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resolveUiPreferences(raw: unknown): UiPreferences {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...EMPTY_UI_PREFERENCES };
|
||||
}
|
||||
const o = raw as UiPreferences;
|
||||
return {
|
||||
...EMPTY_UI_PREFERENCES,
|
||||
...o,
|
||||
v: UI_PREFERENCES_VERSION,
|
||||
strategyEditor: { ...EMPTY_UI_PREFERENCES.strategyEditor, ...o.strategyEditor },
|
||||
indicator: { ...EMPTY_UI_PREFERENCES.indicator, ...o.indicator },
|
||||
chart: { ...EMPTY_UI_PREFERENCES.chart, ...o.chart },
|
||||
panels: { ...EMPTY_UI_PREFERENCES.panels, ...o.panels },
|
||||
virtual: { ...EMPTY_UI_PREFERENCES.virtual, ...o.virtual },
|
||||
tradeNotifications: { ...EMPTY_UI_PREFERENCES.tradeNotifications, ...o.tradeNotifications },
|
||||
};
|
||||
}
|
||||
|
||||
/** DB 캐시 기준 UI 설정 (앱 설정 로드 후 유효) */
|
||||
export function getUiPreferences(): UiPreferences {
|
||||
return resolveUiPreferences(getAppSettingsCache().uiPreferences);
|
||||
}
|
||||
|
||||
/** UI 설정 부분 갱신 — 낙관적 캐시 + 디바운스 DB 저장 */
|
||||
export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = false): void {
|
||||
const current = getUiPreferences();
|
||||
const next = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>) as UiPreferences;
|
||||
next.v = UI_PREFERENCES_VERSION;
|
||||
|
||||
const cache = getAppSettingsCache();
|
||||
const merged: AppSettingsDto = { ...cache, uiPreferences: next };
|
||||
Object.assign(cache, merged);
|
||||
|
||||
pendingPatch = pendingPatch
|
||||
? (deepMerge(pendingPatch as Record<string, unknown>, patch as Record<string, unknown>) as Partial<UiPreferences>)
|
||||
: patch;
|
||||
|
||||
const flush = () => {
|
||||
saveTimer = null;
|
||||
const toSave = pendingPatch
|
||||
? resolveUiPreferences(deepMerge(getUiPreferences() as Record<string, unknown>, pendingPatch as Record<string, unknown>))
|
||||
: next;
|
||||
pendingPatch = null;
|
||||
void saveAppSettings({ uiPreferences: toSave }).catch(err => {
|
||||
console.warn('[uiPreferences] DB 저장 실패:', err);
|
||||
});
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
if (saveTimer != null) clearTimeout(saveTimer);
|
||||
pendingPatch = null;
|
||||
void saveAppSettings({ uiPreferences: next }).catch(err => {
|
||||
console.warn('[uiPreferences] DB 저장 실패:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveTimer != null) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
export function subscribeUiPreferences(listener: () => void): () => void {
|
||||
return subscribeAppSettings(listener);
|
||||
}
|
||||
|
||||
/** 중첩 경로 헬퍼 */
|
||||
export function getUiPref<K extends keyof UiPreferences>(key: K): UiPreferences[K] {
|
||||
return getUiPreferences()[key];
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* localStorage → gc_app_settings.ui_preferences_json 1회 이전
|
||||
*/
|
||||
import type { AppSettingsDto } from './backendApi';
|
||||
import type { UiPreferences } from '../types/uiPreferences';
|
||||
import { resolveUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
const MIGRATED_FLAG = 'gc_ui_prefs_migrated_v1';
|
||||
|
||||
function readJson<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeKeys(keys: string[]): void {
|
||||
for (const k of keys) {
|
||||
try { localStorage.removeItem(k); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 이미 DB에 uiPreferences가 있으면 스킵. 없으면 localStorage 병합 후 키 제거. */
|
||||
export function migrateLocalStorageToUiPreferences(
|
||||
current: AppSettingsDto,
|
||||
): { uiPreferences: UiPreferences; changed: boolean } | null {
|
||||
try {
|
||||
if (localStorage.getItem(MIGRATED_FLAG) === '1' && current.uiPreferences) {
|
||||
return null;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const base = resolveUiPreferences(current.uiPreferences);
|
||||
const patch: UiPreferences = { ...base };
|
||||
let changed = false;
|
||||
|
||||
const editorMode = localStorage.getItem('gc_se_editor_mode');
|
||||
if (editorMode === 'list' || editorMode === 'graph') {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, editorMode };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const canvasMode = localStorage.getItem('gc_se_canvas_interaction');
|
||||
if (canvasMode) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, canvasInteraction: canvasMode };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const paletteAux = readJson<unknown[]>('se-palette-auxiliary-v1');
|
||||
if (paletteAux?.length) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, paletteAuxiliary: paletteAux as NonNullable<UiPreferences['strategyEditor']>['paletteAuxiliary'] };
|
||||
changed = true;
|
||||
}
|
||||
const paletteComp = readJson<unknown[]>('se-palette-composite-v1');
|
||||
if (paletteComp?.length) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, paletteComposite: paletteComp as NonNullable<UiPreferences['strategyEditor']>['paletteComposite'] };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const customTabs = readJson<NonNullable<UiPreferences['indicator']>['customTabs']>('gc-indicator-custom-tabs-v1' as never);
|
||||
if (customTabs?.length) {
|
||||
patch.indicator = { ...patch.indicator, customTabs };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const mainTabOrder = readJson<string[]>('gc_indicator_main_tab_order');
|
||||
if (mainTabOrder?.length) {
|
||||
patch.indicator = { ...patch.indicator, mainTabOrder };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const listOrder = readJson<string[]>('gc_indicator_settings_list_order');
|
||||
if (listOrder?.length) {
|
||||
patch.indicator = { ...patch.indicator, listOrder };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const chartState = readJson<{ alertPrices?: number[]; indicators?: unknown[] }>('trading_chart_state');
|
||||
if (chartState?.alertPrices?.length || chartState?.indicators?.length) {
|
||||
patch.chart = {
|
||||
...patch.chart,
|
||||
alertPrices: chartState.alertPrices,
|
||||
legacyIndicators: chartState.indicators as NonNullable<UiPreferences['chart']>['legacyIndicators'],
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const panels: Record<string, number | boolean | string> = { ...patch.panels };
|
||||
for (const key of ['se-left-width', 'se-terminal-height', 'btd-left-width', 'btd-right-width']) {
|
||||
const v = localStorage.getItem(key);
|
||||
if (v != null) {
|
||||
const n = Number(v);
|
||||
panels[key] = Number.isFinite(n) ? n : v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const virtualTargets = readJson<unknown[]>('gc_virtual_targets_v1');
|
||||
const virtualSession = readJson<unknown>('gc_virtual_session_v1');
|
||||
const cardView = localStorage.getItem('gc_virtual_card_view_v1');
|
||||
if (virtualTargets?.length || virtualSession || cardView) {
|
||||
patch.virtual = {
|
||||
...patch.virtual,
|
||||
...(virtualTargets?.length ? { targets: virtualTargets as NonNullable<UiPreferences['virtual']>['targets'] } : {}),
|
||||
...(virtualSession ? { session: virtualSession as NonNullable<UiPreferences['virtual']>['session'] } : {}),
|
||||
...(cardView === 'detail' || cardView === 'summary' ? { cardViewMode: cardView } : {}),
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const readIds = readJson<string[]>('gc_trade_notify_read_v1');
|
||||
const hiddenIds = readJson<string[]>('gc_trade_notify_hidden_v1');
|
||||
if (readIds?.length || hiddenIds?.length) {
|
||||
patch.tradeNotifications = {
|
||||
readIds: readIds ?? [],
|
||||
hiddenIds: hiddenIds ?? [],
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const displayMode = localStorage.getItem('tsd-display-mode');
|
||||
if (displayMode === 'chart' || displayMode === 'summary') {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed && current.uiPreferences) {
|
||||
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
removeKeys([
|
||||
'gc_se_editor_mode',
|
||||
'gc_se_canvas_interaction',
|
||||
'se-palette-auxiliary-v1',
|
||||
'se-palette-composite-v1',
|
||||
'gc-indicator-custom-tabs-v1',
|
||||
'gc_indicator_main_tab_order',
|
||||
'gc_indicator_settings_list_order',
|
||||
'trading_chart_state',
|
||||
'chartLayout',
|
||||
'chartLayoutSync',
|
||||
'gc_virtual_targets_v1',
|
||||
'gc_virtual_session_v1',
|
||||
'gc_virtual_card_view_v1',
|
||||
'gc_trade_notify_read_v1',
|
||||
'gc_trade_notify_hidden_v1',
|
||||
'tsd-display-mode',
|
||||
'gc_se_flow_layout_v1',
|
||||
'gc_strat_v2',
|
||||
'gc_strategies_v1',
|
||||
'se-left-width',
|
||||
'se-terminal-height',
|
||||
'btd-left-width',
|
||||
'btd-right-width',
|
||||
]);
|
||||
|
||||
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
|
||||
|
||||
return { uiPreferences: resolveUiPreferences(patch), changed: true };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
/** 가상투자 대상 종목·세션 설정 localStorage */
|
||||
/** 가상투자 대상 종목·세션 설정 (DB ui_preferences + per-market live settings) */
|
||||
|
||||
export interface VirtualTargetItem {
|
||||
market: string;
|
||||
@@ -22,68 +23,9 @@ export interface VirtualSessionConfig {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const TARGETS_KEY = 'gc_virtual_targets_v1';
|
||||
const SESSION_KEY = 'gc_virtual_session_v1';
|
||||
const CARD_VIEW_KEY = 'gc_virtual_card_view_v1';
|
||||
|
||||
/** 카드 표시: summary=이퀄라이저·신호등·푸터만, detail=지표 테이블 포함 전체 */
|
||||
export type VirtualCardViewMode = 'summary' | 'detail';
|
||||
|
||||
export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(TARGETS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as VirtualTargetItem[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map(t => {
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||
t.market,
|
||||
t.koreanName,
|
||||
t.englishName,
|
||||
);
|
||||
return { ...t, koreanName, englishName };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
try {
|
||||
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
|
||||
notifyVirtualSessionChanged();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function loadVirtualSession(): VirtualSessionConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return defaultSession();
|
||||
const parsed = JSON.parse(raw) as Partial<VirtualSessionConfig>;
|
||||
return {
|
||||
globalStrategyId: parsed.globalStrategyId ?? null,
|
||||
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
running: parsed.running === true,
|
||||
};
|
||||
} catch {
|
||||
return defaultSession();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
|
||||
notifyVirtualSessionChanged();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
|
||||
|
||||
export function notifyVirtualSessionChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
function defaultSession(): VirtualSessionConfig {
|
||||
return {
|
||||
globalStrategyId: null,
|
||||
@@ -93,19 +35,57 @@ function defaultSession(): VirtualSessionConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
||||
return items.map(t => {
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||
t.market,
|
||||
t.koreanName,
|
||||
t.englishName,
|
||||
);
|
||||
return { ...t, koreanName, englishName };
|
||||
});
|
||||
}
|
||||
|
||||
export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
const raw = getUiPreferences().virtual?.targets;
|
||||
if (!raw?.length) return [];
|
||||
return normalizeTargets(raw);
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
patchUiPreferences({ virtual: { targets: items } });
|
||||
notifyVirtualSessionChanged();
|
||||
}
|
||||
|
||||
export function loadVirtualSession(): VirtualSessionConfig {
|
||||
const parsed = getUiPreferences().virtual?.session;
|
||||
if (!parsed) return defaultSession();
|
||||
return {
|
||||
globalStrategyId: parsed.globalStrategyId ?? null,
|
||||
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
running: parsed.running === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||
patchUiPreferences({ virtual: { session: cfg } });
|
||||
notifyVirtualSessionChanged();
|
||||
}
|
||||
|
||||
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
|
||||
|
||||
export function notifyVirtualSessionChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export function loadVirtualCardViewMode(): VirtualCardViewMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(CARD_VIEW_KEY);
|
||||
return raw === 'detail' ? 'detail' : 'summary';
|
||||
} catch {
|
||||
return 'summary';
|
||||
}
|
||||
const mode = getUiPreferences().virtual?.cardViewMode;
|
||||
return mode === 'detail' ? 'detail' : 'summary';
|
||||
}
|
||||
|
||||
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||
try {
|
||||
localStorage.setItem(CARD_VIEW_KEY, mode);
|
||||
} catch { /* ignore */ }
|
||||
patchUiPreferences({ virtual: { cardViewMode: mode } });
|
||||
}
|
||||
|
||||
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
|
||||
|
||||
Reference in New Issue
Block a user