mobile download

This commit is contained in:
Macbook
2026-05-28 14:44:19 +09:00
parent e2816b037f
commit 3503ef33f5
152 changed files with 11021 additions and 687 deletions
+74
View File
@@ -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
View File
@@ -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;
+7 -1
View File
@@ -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}
/>
);
};
+242 -159
View File
@@ -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}
/>
)}
+2 -5
View File
@@ -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 임계값 (과열선/중앙선/침체선) */
+30 -23
View File
@@ -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
+22
View File
@@ -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
+2 -2
View File
@@ -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);
+8 -13
View File
@@ -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 {
+12 -1
View File
@@ -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;
+39
View File
@@ -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 };
+22
View File
@@ -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'));
}
+6 -17
View File
@@ -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 병합 */
+6 -17
View File
@@ -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[] {
+19 -36
View File
@@ -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];
}
/** 추가 → 변경 후 전체 목록 반환 (중복 무시) */
+8 -22
View File
@@ -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 } });
}
+16 -18
View File
@@ -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 } });
}
+7 -5
View File
@@ -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 임계값 (과열선/중앙선/침체선) */
+22 -24
View File
@@ -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[] {
+47 -29
View File
@@ -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),
+106
View File
@@ -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];
}
+165
View File
@@ -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 };
}
+49 -69
View File
@@ -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 */