mobile download
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
* 환경변수 VITE_API_BASE_URL 로 백엔드 URL 설정 (기본: http://localhost:8080/api)
|
||||
*/
|
||||
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
/** 백엔드 REST base (Docker 빌드: `/api`, 로컬 Vite: `.env` 또는 localhost:8080) */
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
|
||||
|
||||
@@ -444,6 +446,8 @@ export interface AppSettingsDto {
|
||||
displayTimezone?: string;
|
||||
/** 추세검색 기본 설정 */
|
||||
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
||||
uiPreferences?: import('../types/uiPreferences').UiPreferences | null;
|
||||
}
|
||||
|
||||
export interface LiveSummaryDto {
|
||||
@@ -555,6 +559,22 @@ export async function sendFcmTest(): Promise<{ ok: boolean; available: boolean }
|
||||
return request<{ ok: boolean; available: boolean }>('/fcm/test', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 모바일 앱 배포 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MobileAppReleaseInfoDto {
|
||||
available: boolean;
|
||||
fileName?: string;
|
||||
version?: string;
|
||||
sizeBytes?: number;
|
||||
updatedAt?: string;
|
||||
downloadUrl?: string;
|
||||
installPageUrl?: string;
|
||||
}
|
||||
|
||||
export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDto | null> {
|
||||
return request<MobileAppReleaseInfoDto>('/mobile-app/info');
|
||||
}
|
||||
|
||||
// ── 모의투자 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaperPositionDto {
|
||||
@@ -811,6 +831,8 @@ export interface StrategyDto {
|
||||
description?: string;
|
||||
buyCondition?: unknown; // LogicNode DSL
|
||||
sellCondition?: unknown; // LogicNode DSL
|
||||
/** 편집기 캔버스·START 분봉·고아 노드 (gc_strategy.flow_layout_json) */
|
||||
flowLayout?: StrategyFlowLayoutStore;
|
||||
enabled?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** 지표 추가 팝업 — 사용자 정의 탭 (localStorage) */
|
||||
/** 지표 추가 팝업 — 사용자 정의 탭 (DB) */
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export interface IndicatorCustomTab {
|
||||
id: string;
|
||||
@@ -7,33 +8,16 @@ export interface IndicatorCustomTab {
|
||||
indicatorTypes: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'gc-indicator-custom-tabs-v1';
|
||||
|
||||
function newId(): string {
|
||||
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function loadCustomTabs(): IndicatorCustomTab[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr.filter(
|
||||
(t): t is IndicatorCustomTab =>
|
||||
t != null
|
||||
&& typeof t === 'object'
|
||||
&& typeof (t as IndicatorCustomTab).id === 'string'
|
||||
&& typeof (t as IndicatorCustomTab).name === 'string'
|
||||
&& Array.isArray((t as IndicatorCustomTab).indicatorTypes),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return getUiPreferences().indicator?.customTabs ?? [];
|
||||
}
|
||||
|
||||
export function saveCustomTabs(tabs: IndicatorCustomTab[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||
patchUiPreferences({ indicator: { customTabs: tabs } });
|
||||
window.dispatchEvent(new CustomEvent('gc-indicator-custom-tabs-changed'));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
/**
|
||||
* 보조지표 설정 목록 표시·차트 pane 순서 (localStorage)
|
||||
* 보조지표 설정 목록 표시·차트 pane 순서 (DB)
|
||||
*/
|
||||
import { getSettingsIndicatorTypes } from './indicatorSettingsList';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { getPaneHostId } from './indicatorPaneMerge';
|
||||
|
||||
const STORAGE_KEY = 'gc_indicator_settings_list_order';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export function loadIndicatorListOrder(): string[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const order = getUiPreferences().indicator?.listOrder;
|
||||
if (!order?.length) return null;
|
||||
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
}
|
||||
|
||||
export function saveIndicatorListOrder(types: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
patchUiPreferences({ indicator: { listOrder: types } });
|
||||
}
|
||||
|
||||
/** 저장 순서 + registry 신규 type 병합 */
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
/**
|
||||
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서
|
||||
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서 (DB)
|
||||
*/
|
||||
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
|
||||
import { mergeIndicatorListOrder } from './indicatorListOrder';
|
||||
|
||||
const STORAGE_KEY = 'gc_indicator_main_tab_order';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export function loadMainTabOrder(): string[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const order = getUiPreferences().indicator?.mainTabOrder;
|
||||
if (!order?.length) return null;
|
||||
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
}
|
||||
|
||||
export function saveMainTabOrder(types: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
patchUiPreferences({ indicator: { mainTabOrder: types } });
|
||||
}
|
||||
|
||||
export function getOrderedMainIndicatorTypes(): string[] {
|
||||
|
||||
@@ -1,50 +1,36 @@
|
||||
/**
|
||||
* 관심종목(즐겨찾기) / 보유종목 관리
|
||||
* - DB(gc_watchlist)가 기준 저장소
|
||||
* - localStorage(gc_favorites)는 오프라인 미러·UI 동기화용
|
||||
* - DB(gc_watchlist / gc_holdings)가 기준 저장소
|
||||
* - 메모리 캐시만 사용 (localStorage 미사용)
|
||||
*/
|
||||
|
||||
import { addWatchlistItem, removeWatchlistItem } from './backendApi';
|
||||
|
||||
const FAV_KEY = 'gc_favorites';
|
||||
const LEGACY_FAV_KEY = 'upbit_market_favorites';
|
||||
const HOLD_KEY = 'gc_holdings';
|
||||
|
||||
/** 관심종목 변경 시 MarketSearchPanel·App 등이 동기화하도록 발행 */
|
||||
export const FAVORITES_CHANGED_EVENT = 'gc-favorites-changed';
|
||||
|
||||
// ── 즐겨찾기 (관심종목) ─────────────────────────────────────────────────────
|
||||
let favoritesCache: string[] | null = null;
|
||||
let holdingsCache: string[] | null = null;
|
||||
|
||||
/** 구버전 MarketSearchPanel 키 → gc_favorites 로 1회 마이그레이션 */
|
||||
function migrateLegacyFavorites(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAV_KEY);
|
||||
if (raw) {
|
||||
const current = JSON.parse(raw) as string[];
|
||||
if (Array.isArray(current) && current.length > 0) return;
|
||||
}
|
||||
const legacy = localStorage.getItem(LEGACY_FAV_KEY);
|
||||
if (!legacy) return;
|
||||
const parsed = JSON.parse(legacy) as string[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
saveFavorites(parsed, false);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
/** DB 로드 후 App·워크스페이스 훅에서 호출 */
|
||||
export function initFavoritesFromDb(symbols: string[]): void {
|
||||
favoritesCache = [...symbols];
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: favoritesCache }));
|
||||
}
|
||||
|
||||
export function initHoldingsFromDb(symbols: string[]): void {
|
||||
holdingsCache = [...symbols];
|
||||
}
|
||||
|
||||
export function getFavorites(): string[] {
|
||||
migrateLegacyFavorites();
|
||||
try { return JSON.parse(localStorage.getItem(FAV_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
return favoritesCache ?? [];
|
||||
}
|
||||
|
||||
export function saveFavorites(markets: string[], notify = true): void {
|
||||
try {
|
||||
localStorage.setItem(FAV_KEY, JSON.stringify(markets));
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
favoritesCache = [...markets];
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,15 +62,12 @@ export function isFavorite(market: string): boolean {
|
||||
return getFavorites().includes(market);
|
||||
}
|
||||
|
||||
// ── 보유종목 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getHoldings(): string[] {
|
||||
try { return JSON.parse(localStorage.getItem(HOLD_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
return holdingsCache ?? [];
|
||||
}
|
||||
|
||||
export function saveHoldings(markets: string[]): void {
|
||||
try { localStorage.setItem(HOLD_KEY, JSON.stringify(markets)); } catch { /* ignore */ }
|
||||
holdingsCache = [...markets];
|
||||
}
|
||||
|
||||
/** 추가 → 변경 후 전체 목록 반환 (중복 무시) */
|
||||
|
||||
@@ -12,8 +12,7 @@ interface ChartState {
|
||||
mainChartStyle?: MainChartStyle;
|
||||
}
|
||||
|
||||
const KEY = 'trading_chart_state';
|
||||
const CUR_VERSION = 8; // v8: 기본 지표 제거 — 초기 로딩 시 캔들차트만 표시
|
||||
const CUR_VERSION = 8;
|
||||
|
||||
/** 기본 보조지표 없음 — 처음 로딩 시 캔들차트만 표시 */
|
||||
const DEFAULT_INDICATORS: IndicatorConfig[] = [];
|
||||
@@ -25,8 +24,7 @@ const DEFAULT_WATCHLIST = [
|
||||
|
||||
/**
|
||||
* 절대적 최후 fallback 기본값.
|
||||
* 우선순위: DB(gc_app_settings) > localStorage > 이 값.
|
||||
* 타임프레임을 '1h' 에서 '1D' 로 통일 (ChartSlot 기본값과 일치).
|
||||
* 우선순위: DB(gc_app_settings + gc_chart_workspace) > 이 값.
|
||||
*/
|
||||
const DEFAULT_STATE: ChartState = {
|
||||
version: CUR_VERSION,
|
||||
@@ -39,31 +37,19 @@ const DEFAULT_STATE: ChartState = {
|
||||
alertPrices: [],
|
||||
};
|
||||
|
||||
export function saveState(state: Partial<ChartState>): void {
|
||||
try {
|
||||
const existing = loadState();
|
||||
localStorage.setItem(KEY, JSON.stringify({ ...existing, ...state, version: CUR_VERSION }));
|
||||
} catch { /* ignore */ }
|
||||
/** @deprecated DB·워크스페이스 사용 — no-op */
|
||||
export function saveState(_state: Partial<ChartState>): void {
|
||||
/* chart state는 gc_chart_workspace·gc_app_settings로 저장 */
|
||||
}
|
||||
|
||||
/** 초기 마운트 fallback (DB 로드 전 1회) */
|
||||
export function loadState(): ChartState {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as ChartState;
|
||||
// 버전이 다르면 기본값으로 재설정 (마이그레이션)
|
||||
if (parsed.version !== CUR_VERSION) {
|
||||
localStorage.removeItem(KEY);
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function clearState(): void {
|
||||
localStorage.removeItem(KEY);
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
export const DEFAULT_MAIN_CHART_STYLE: MainChartStyle = {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
export type StrategyCanvasInteractionMode = 'pan' | 'select';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
const STORAGE_KEY = 'gc_se_canvas_interaction';
|
||||
export type CanvasInteractionMode = 'pan' | 'select';
|
||||
/** @deprecated CanvasInteractionMode */
|
||||
export type StrategyCanvasInteractionMode = CanvasInteractionMode;
|
||||
|
||||
export function loadCanvasInteractionMode(): StrategyCanvasInteractionMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'pan' ? 'pan' : 'select';
|
||||
} catch {
|
||||
return 'select';
|
||||
}
|
||||
export function loadCanvasInteractionMode(): CanvasInteractionMode {
|
||||
const mode = getUiPreferences().strategyEditor?.canvasInteraction;
|
||||
return mode === 'pan' ? 'pan' : 'select';
|
||||
}
|
||||
|
||||
export function saveCanvasInteractionMode(mode: StrategyCanvasInteractionMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
export function saveCanvasInteractionMode(mode: CanvasInteractionMode): void {
|
||||
patchUiPreferences({ strategyEditor: { canvasInteraction: mode } });
|
||||
}
|
||||
|
||||
@@ -432,35 +432,33 @@ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
||||
}
|
||||
|
||||
/** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */
|
||||
function pickMergedStartCandleTypes(decoded: string[], stored?: string[]): string[] {
|
||||
const fromDb = normalizeCandleTypesList(decoded);
|
||||
const fromStored = stored?.length ? normalizeCandleTypesList(stored) : [];
|
||||
const dbIsLegacyDefault = fromDb.length === 1 && fromDb[0] === '1m';
|
||||
const storedDiffers = fromStored.length > 0
|
||||
&& (fromStored.length !== fromDb.length || fromStored.some((ct, i) => ct !== fromDb[i]));
|
||||
if (storedDiffers && (dbIsLegacyDefault || fromDb.length === 0)) {
|
||||
return fromStored;
|
||||
}
|
||||
return fromDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 로드 시 startMeta 병합 — DB가 1m 기본값만 있고 localStorage에 다른 분봉이 있으면 localStorage 우선.
|
||||
* 전략 로드 시 startMeta 병합 — flow_layout_json(편집기)이 있으면 DSL TIMEFRAME보다 우선.
|
||||
*/
|
||||
export function mergeStartMetaForLoad(
|
||||
decoded: Record<string, StartNodeMeta>,
|
||||
stored?: Record<string, StartNodeMeta> | null,
|
||||
): Record<string, StartNodeMeta> {
|
||||
if (!stored || Object.keys(stored).length === 0) {
|
||||
const fromDsl: Record<string, StartNodeMeta> = { ...defaultStartMeta(), ...decoded };
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||
fromDsl[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
return fromDsl;
|
||||
}
|
||||
const merged: Record<string, StartNodeMeta> = {
|
||||
...defaultStartMeta(),
|
||||
...stored,
|
||||
};
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
const types = pickMergedStartCandleTypes(
|
||||
getStartCandleTypes(meta),
|
||||
stored?.[startId] ? getStartCandleTypes(stored[startId]) : undefined,
|
||||
);
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
if (stored[startId]) {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(stored[startId]));
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
} else {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export type StrategyFlowLayoutStore = {
|
||||
sell: SignalFlowLayoutSnapshot;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'gc_se_flow_layout_v1';
|
||||
/** @deprecated 레거시 localStorage 키 — 신규 저장은 DB flow_layout_json만 사용 */
|
||||
const LEGACY_STORAGE_KEY = 'gc_se_flow_layout_v1';
|
||||
|
||||
export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot {
|
||||
return {
|
||||
@@ -42,44 +43,67 @@ export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore {
|
||||
return { buy: emptySignalFlowLayout(), sell: emptySignalFlowLayout() };
|
||||
}
|
||||
|
||||
export function loadStrategyFlowLayout(strategyKey: string): StrategyFlowLayoutStore | null {
|
||||
export function normalizeSignalFlowLayout(
|
||||
snap: SignalFlowLayoutSnapshot | null | undefined,
|
||||
): SignalFlowLayoutSnapshot {
|
||||
if (!snap) return emptySignalFlowLayout();
|
||||
return {
|
||||
positions: snap.positions ?? {},
|
||||
edgeHandles: snap.edgeHandles ?? {},
|
||||
orphans: snap.orphans ?? [],
|
||||
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: snap.extraStartIds ?? [],
|
||||
extraRoots: snap.extraRoots ?? {},
|
||||
startCombineOp: snap.startCombineOp,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStrategyFlowLayout(
|
||||
store: StrategyFlowLayoutStore | null | undefined,
|
||||
): StrategyFlowLayoutStore | null {
|
||||
if (!store) return null;
|
||||
return {
|
||||
buy: normalizeSignalFlowLayout(store.buy),
|
||||
sell: normalizeSignalFlowLayout(store.sell),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStrategyFlowLayoutStore(
|
||||
buy: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
|
||||
sell: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
|
||||
buyOrphans: LogicNode[],
|
||||
sellOrphans: LogicNode[],
|
||||
): StrategyFlowLayoutStore {
|
||||
return {
|
||||
buy: { ...buy, orphans: buyOrphans },
|
||||
sell: { ...sell, orphans: sellOrphans },
|
||||
};
|
||||
}
|
||||
|
||||
/** DB에 flow layout이 없을 때만 1회 읽기 — 이후 저장은 서버로 이전 */
|
||||
export function readLegacyFlowLayoutFromLocalStorage(
|
||||
strategyKey: string,
|
||||
): StrategyFlowLayoutStore | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
|
||||
return all[strategyKey] ?? null;
|
||||
return normalizeStrategyFlowLayout(all[strategyKey] ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStrategyFlowLayout(strategyKey: string, layout: StrategyFlowLayoutStore): void {
|
||||
export function clearLegacyFlowLayoutFromLocalStorage(strategyKey: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const all: Record<string, StrategyFlowLayoutStore> = raw ? JSON.parse(raw) : {};
|
||||
all[strategyKey] = layout;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteStrategyFlowLayout(strategyKey: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
|
||||
if (!(strategyKey in all)) return;
|
||||
delete all[strategyKey];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
if (Object.keys(all).length === 0) localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
else localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateStrategyFlowLayout(fromKey: string, toKey: string): void {
|
||||
if (fromKey === toKey) return;
|
||||
const layout = loadStrategyFlowLayout(fromKey);
|
||||
if (!layout) return;
|
||||
saveStrategyFlowLayout(toKey, layout);
|
||||
deleteStrategyFlowLayout(fromKey);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export type StrategyEditorMode = 'graph' | 'list';
|
||||
|
||||
const STORAGE_KEY = 'gc_se_editor_mode';
|
||||
|
||||
export function loadEditorMode(): StrategyEditorMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'list' ? 'list' : 'graph';
|
||||
} catch {
|
||||
return 'graph';
|
||||
}
|
||||
const mode = getUiPreferences().strategyEditor?.editorMode;
|
||||
return mode === 'list' ? 'list' : 'graph';
|
||||
}
|
||||
|
||||
export function saveEditorMode(mode: StrategyEditorMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
patchUiPreferences({ strategyEditor: { editorMode: mode } });
|
||||
}
|
||||
|
||||
@@ -56,12 +56,15 @@ import {
|
||||
} from '../utils/thresholdSymbols';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
|
||||
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
export interface StrategyDto {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
buyCondition?: LogicNode | null;
|
||||
sellCondition?: LogicNode | null;
|
||||
flowLayout?: StrategyFlowLayoutStore;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -78,11 +81,10 @@ export type DefType = typeof DEF_DEFAULTS;
|
||||
let _cnt = 0;
|
||||
export const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||||
|
||||
export const STORAGE_KEY = 'gc_strat_v2';
|
||||
export const loadStratsLocal = (): StrategyDto[] => {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
|
||||
};
|
||||
export const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
/** @deprecated DB(gc_strategy)만 사용 */
|
||||
export const loadStratsLocal = (): StrategyDto[] => [];
|
||||
/** @deprecated */
|
||||
export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ };
|
||||
|
||||
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
|
||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (localStorage) */
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (DB ui_preferences) */
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
|
||||
|
||||
export type PaletteItemKind = 'auxiliary' | 'composite';
|
||||
@@ -18,9 +19,6 @@ export interface PaletteItem {
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_AUX = 'se-palette-auxiliary-v1';
|
||||
const STORAGE_COMP = 'se-palette-composite-v1';
|
||||
|
||||
export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', builtIn: true },
|
||||
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', builtIn: true },
|
||||
@@ -111,32 +109,32 @@ function mergeMissingBuiltIns(
|
||||
}
|
||||
|
||||
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
try {
|
||||
const stored = parseStored(localStorage.getItem(key));
|
||||
if (stored && stored.length > 0) {
|
||||
return mergeMissingBuiltIns(
|
||||
stored.map(i => ({
|
||||
...i,
|
||||
kind,
|
||||
...(i.builtIn
|
||||
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
|
||||
: {}),
|
||||
})),
|
||||
const prefs = getUiPreferences().strategyEditor;
|
||||
const raw = kind === 'auxiliary' ? prefs?.paletteAuxiliary : prefs?.paletteComposite;
|
||||
const stored = raw?.length ? raw : null;
|
||||
if (stored && stored.length > 0) {
|
||||
return mergeMissingBuiltIns(
|
||||
stored.map(i => ({
|
||||
...i,
|
||||
kind,
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
...(i.builtIn
|
||||
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
|
||||
: {}),
|
||||
})),
|
||||
kind,
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
return seedItems(kind, defaults);
|
||||
}
|
||||
|
||||
export function savePaletteItems(kind: PaletteItemKind, items: PaletteItem[]): void {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items));
|
||||
} catch { /* ignore */ }
|
||||
if (kind === 'auxiliary') {
|
||||
patchUiPreferences({ strategyEditor: { paletteAuxiliary: items } });
|
||||
} else {
|
||||
patchUiPreferences({ strategyEditor: { paletteComposite: items } });
|
||||
}
|
||||
}
|
||||
|
||||
export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi';
|
||||
import { loadStrategyFlowLayout } from './strategyEditorLayoutStorage';
|
||||
import {
|
||||
alignBuySellStartCandleTypesForSave,
|
||||
collectDslBranches,
|
||||
@@ -14,8 +13,7 @@ import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNo
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
function collectFromFlowLayout(strategyId: number): string[] {
|
||||
const layout = loadStrategyFlowLayout(String(strategyId));
|
||||
function collectFromFlowLayout(layout: StrategyFlowLayoutStore | null | undefined): string[] {
|
||||
if (!layout) return [];
|
||||
const set = new Set<string>();
|
||||
for (const side of ['buy', 'sell'] as const) {
|
||||
@@ -43,9 +41,9 @@ function collectFromStrategyDto(strategy: StrategyDto | null | undefined): strin
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 (서버 DSL보다 우선) */
|
||||
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 */
|
||||
export function collectUiEvaluationTimeframes(
|
||||
strategyId: number,
|
||||
_strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||
): string[] {
|
||||
@@ -56,7 +54,7 @@ export function collectUiEvaluationTimeframes(
|
||||
].map(normalizeStartCandleType);
|
||||
if (fromEditor.length > 0) return [...new Set(fromEditor)];
|
||||
}
|
||||
const fromLayout = collectFromFlowLayout(strategyId);
|
||||
const fromLayout = collectFromFlowLayout(strategy?.flowLayout);
|
||||
if (fromLayout.length > 0) return fromLayout;
|
||||
return collectFromStrategyDto(strategy);
|
||||
}
|
||||
@@ -85,7 +83,6 @@ export async function warnStrategyTimeframeMismatch(
|
||||
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
|
||||
|
||||
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
|
||||
// 양쪽 모두 1m만 — 일치로 간주
|
||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||
return true;
|
||||
}
|
||||
@@ -94,14 +91,14 @@ export async function warnStrategyTimeframeMismatch(
|
||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||
window.alert(
|
||||
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 자동 저장된 뒤 실시간 체크를 켜 주세요.',
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildEditorStateFromStrategy(
|
||||
strategy: StrategyDto,
|
||||
layout: StrategyFlowLayoutStore | null,
|
||||
layout: StrategyFlowLayoutStore | null | undefined,
|
||||
side: 'buy' | 'sell',
|
||||
): EditorConditionState {
|
||||
const decoded = decodeConditionForEditor(
|
||||
@@ -118,14 +115,16 @@ function buildEditorStateFromStrategy(
|
||||
}
|
||||
|
||||
/**
|
||||
* flow layout(localStorage)의 START 분봉이 서버 DSL과 다르면 편집기 저장 없이 자동 동기화.
|
||||
* @returns false — layout 없음 등으로 동기화 불가
|
||||
* DB flow layout의 START 분봉이 서버 DSL과 다르면 자동 동기화.
|
||||
*/
|
||||
export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
): Promise<boolean> {
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy);
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
if (!strat) return true;
|
||||
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
||||
if (uiTfs.length === 0) return true;
|
||||
|
||||
let serverTfs: string[];
|
||||
@@ -139,14 +138,11 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
if (missingOnServer.length === 0) return true;
|
||||
|
||||
const layout = loadStrategyFlowLayout(String(strategyId));
|
||||
const layout = strat.flowLayout;
|
||||
if (!layout) {
|
||||
return warnStrategyTimeframeMismatch(strategyId, strategy);
|
||||
return warnStrategyTimeframeMismatch(strategyId, strat);
|
||||
}
|
||||
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
if (!strat) return true;
|
||||
|
||||
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
||||
const sellState = buildEditorStateFromStrategy(strat, layout, 'sell');
|
||||
|
||||
@@ -156,6 +152,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strat.description ?? '',
|
||||
buyState,
|
||||
sellState,
|
||||
layout,
|
||||
);
|
||||
try {
|
||||
await repairStrategyTimeframes(strategyId);
|
||||
@@ -165,25 +162,46 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */
|
||||
/** 편집기 상태(DSL + flow layout)를 DB에 저장 — 실시간 평가 캐시는 서버에서 무효화됨 */
|
||||
export async function persistStrategyEditorState(
|
||||
strategyId: number,
|
||||
name: string,
|
||||
description: string,
|
||||
buy: EditorConditionState,
|
||||
sell: EditorConditionState,
|
||||
flowLayout: StrategyFlowLayoutStore,
|
||||
): Promise<StrategyDto> {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
const saved = await saveStrategy({
|
||||
id: strategyId,
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('gc:strategy-saved', {
|
||||
detail: { strategyId, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout },
|
||||
}));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** @deprecated persistStrategyEditorState 사용 */
|
||||
export async function persistStrategyEvaluationTimeframes(
|
||||
strategyId: number,
|
||||
name: string,
|
||||
description: string,
|
||||
buy: EditorConditionState,
|
||||
sell: EditorConditionState,
|
||||
flowLayout?: StrategyFlowLayoutStore,
|
||||
): Promise<void> {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
await saveStrategy({
|
||||
id: strategyId,
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
enabled: true,
|
||||
});
|
||||
if (!flowLayout) return;
|
||||
await persistStrategyEditorState(strategyId, name, description, buy, sell, flowLayout);
|
||||
}
|
||||
|
||||
export type { StrategyFlowLayoutStore };
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
type BullishWeightKey,
|
||||
} from './trendSearchBullishWeights';
|
||||
|
||||
/** 결과 목록 표시: chart | summary(신호) */
|
||||
export type TrendSearchDisplayMode = 'chart' | 'summary';
|
||||
|
||||
export interface TrendSearchAppSettings {
|
||||
/** 결과 UI 표시 모드 (기본 summary) */
|
||||
displayMode?: TrendSearchDisplayMode;
|
||||
weightMaAlignment: number;
|
||||
weightMaSlope: number;
|
||||
weightAdxTrend: number;
|
||||
@@ -32,6 +37,7 @@ export interface TrendSearchAppSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
|
||||
displayMode: 'summary',
|
||||
weightMaAlignment: DEFAULT_BULLISH_WEIGHTS.weightMaAlignment,
|
||||
weightMaSlope: DEFAULT_BULLISH_WEIGHTS.weightMaSlope,
|
||||
weightAdxTrend: DEFAULT_BULLISH_WEIGHTS.weightAdxTrend,
|
||||
@@ -59,7 +65,11 @@ export function resolveTrendSearchAppSettings(
|
||||
const rank = Number(raw.autoAddTopRank);
|
||||
const limit = Number(raw.limit);
|
||||
const scanLimit = Number(raw.scanLimit);
|
||||
const displayMode = raw.displayMode === 'chart' || raw.displayMode === 'summary'
|
||||
? raw.displayMode
|
||||
: d.displayMode ?? 'summary';
|
||||
return {
|
||||
displayMode,
|
||||
weightMaAlignment: clampWeight(raw.weightMaAlignment, d.weightMaAlignment),
|
||||
weightMaSlope: clampWeight(raw.weightMaSlope, d.weightMaSlope),
|
||||
weightAdxTrend: clampWeight(raw.weightAdxTrend, d.weightAdxTrend),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* UI 설정 — gc_app_settings.ui_preferences_json (DB 단일 소스)
|
||||
*/
|
||||
import {
|
||||
getAppSettingsCache,
|
||||
subscribeAppSettings,
|
||||
} from '../hooks/useAppSettings';
|
||||
import { saveAppSettings, type AppSettingsDto } from './backendApi';
|
||||
import {
|
||||
EMPTY_UI_PREFERENCES,
|
||||
UI_PREFERENCES_VERSION,
|
||||
type UiPreferences,
|
||||
} from '../types/uiPreferences';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingPatch: Partial<UiPreferences> | null = null;
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return v != null && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(base: T, patch: Partial<T>): T {
|
||||
const out = { ...base } as T;
|
||||
for (const key of Object.keys(patch) as (keyof T)[]) {
|
||||
const pv = patch[key];
|
||||
if (pv === undefined) continue;
|
||||
const bv = base[key];
|
||||
if (isPlainObject(bv) && isPlainObject(pv)) {
|
||||
out[key] = deepMerge(bv as Record<string, unknown>, pv as Record<string, unknown>) as T[keyof T];
|
||||
} else {
|
||||
out[key] = pv as T[keyof T];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resolveUiPreferences(raw: unknown): UiPreferences {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...EMPTY_UI_PREFERENCES };
|
||||
}
|
||||
const o = raw as UiPreferences;
|
||||
return {
|
||||
...EMPTY_UI_PREFERENCES,
|
||||
...o,
|
||||
v: UI_PREFERENCES_VERSION,
|
||||
strategyEditor: { ...EMPTY_UI_PREFERENCES.strategyEditor, ...o.strategyEditor },
|
||||
indicator: { ...EMPTY_UI_PREFERENCES.indicator, ...o.indicator },
|
||||
chart: { ...EMPTY_UI_PREFERENCES.chart, ...o.chart },
|
||||
panels: { ...EMPTY_UI_PREFERENCES.panels, ...o.panels },
|
||||
virtual: { ...EMPTY_UI_PREFERENCES.virtual, ...o.virtual },
|
||||
tradeNotifications: { ...EMPTY_UI_PREFERENCES.tradeNotifications, ...o.tradeNotifications },
|
||||
};
|
||||
}
|
||||
|
||||
/** DB 캐시 기준 UI 설정 (앱 설정 로드 후 유효) */
|
||||
export function getUiPreferences(): UiPreferences {
|
||||
return resolveUiPreferences(getAppSettingsCache().uiPreferences);
|
||||
}
|
||||
|
||||
/** UI 설정 부분 갱신 — 낙관적 캐시 + 디바운스 DB 저장 */
|
||||
export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = false): void {
|
||||
const current = getUiPreferences();
|
||||
const next = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>) as UiPreferences;
|
||||
next.v = UI_PREFERENCES_VERSION;
|
||||
|
||||
const cache = getAppSettingsCache();
|
||||
const merged: AppSettingsDto = { ...cache, uiPreferences: next };
|
||||
Object.assign(cache, merged);
|
||||
|
||||
pendingPatch = pendingPatch
|
||||
? (deepMerge(pendingPatch as Record<string, unknown>, patch as Record<string, unknown>) as Partial<UiPreferences>)
|
||||
: patch;
|
||||
|
||||
const flush = () => {
|
||||
saveTimer = null;
|
||||
const toSave = pendingPatch
|
||||
? resolveUiPreferences(deepMerge(getUiPreferences() as Record<string, unknown>, pendingPatch as Record<string, unknown>))
|
||||
: next;
|
||||
pendingPatch = null;
|
||||
void saveAppSettings({ uiPreferences: toSave }).catch(err => {
|
||||
console.warn('[uiPreferences] DB 저장 실패:', err);
|
||||
});
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
if (saveTimer != null) clearTimeout(saveTimer);
|
||||
pendingPatch = null;
|
||||
void saveAppSettings({ uiPreferences: next }).catch(err => {
|
||||
console.warn('[uiPreferences] DB 저장 실패:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveTimer != null) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
export function subscribeUiPreferences(listener: () => void): () => void {
|
||||
return subscribeAppSettings(listener);
|
||||
}
|
||||
|
||||
/** 중첩 경로 헬퍼 */
|
||||
export function getUiPref<K extends keyof UiPreferences>(key: K): UiPreferences[K] {
|
||||
return getUiPreferences()[key];
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* localStorage → gc_app_settings.ui_preferences_json 1회 이전
|
||||
*/
|
||||
import type { AppSettingsDto } from './backendApi';
|
||||
import type { UiPreferences } from '../types/uiPreferences';
|
||||
import { resolveUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
const MIGRATED_FLAG = 'gc_ui_prefs_migrated_v1';
|
||||
|
||||
function readJson<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeKeys(keys: string[]): void {
|
||||
for (const k of keys) {
|
||||
try { localStorage.removeItem(k); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 이미 DB에 uiPreferences가 있으면 스킵. 없으면 localStorage 병합 후 키 제거. */
|
||||
export function migrateLocalStorageToUiPreferences(
|
||||
current: AppSettingsDto,
|
||||
): { uiPreferences: UiPreferences; changed: boolean } | null {
|
||||
try {
|
||||
if (localStorage.getItem(MIGRATED_FLAG) === '1' && current.uiPreferences) {
|
||||
return null;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const base = resolveUiPreferences(current.uiPreferences);
|
||||
const patch: UiPreferences = { ...base };
|
||||
let changed = false;
|
||||
|
||||
const editorMode = localStorage.getItem('gc_se_editor_mode');
|
||||
if (editorMode === 'list' || editorMode === 'graph') {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, editorMode };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const canvasMode = localStorage.getItem('gc_se_canvas_interaction');
|
||||
if (canvasMode) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, canvasInteraction: canvasMode };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const paletteAux = readJson<unknown[]>('se-palette-auxiliary-v1');
|
||||
if (paletteAux?.length) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, paletteAuxiliary: paletteAux as NonNullable<UiPreferences['strategyEditor']>['paletteAuxiliary'] };
|
||||
changed = true;
|
||||
}
|
||||
const paletteComp = readJson<unknown[]>('se-palette-composite-v1');
|
||||
if (paletteComp?.length) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, paletteComposite: paletteComp as NonNullable<UiPreferences['strategyEditor']>['paletteComposite'] };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const customTabs = readJson<NonNullable<UiPreferences['indicator']>['customTabs']>('gc-indicator-custom-tabs-v1' as never);
|
||||
if (customTabs?.length) {
|
||||
patch.indicator = { ...patch.indicator, customTabs };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const mainTabOrder = readJson<string[]>('gc_indicator_main_tab_order');
|
||||
if (mainTabOrder?.length) {
|
||||
patch.indicator = { ...patch.indicator, mainTabOrder };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const listOrder = readJson<string[]>('gc_indicator_settings_list_order');
|
||||
if (listOrder?.length) {
|
||||
patch.indicator = { ...patch.indicator, listOrder };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const chartState = readJson<{ alertPrices?: number[]; indicators?: unknown[] }>('trading_chart_state');
|
||||
if (chartState?.alertPrices?.length || chartState?.indicators?.length) {
|
||||
patch.chart = {
|
||||
...patch.chart,
|
||||
alertPrices: chartState.alertPrices,
|
||||
legacyIndicators: chartState.indicators as NonNullable<UiPreferences['chart']>['legacyIndicators'],
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const panels: Record<string, number | boolean | string> = { ...patch.panels };
|
||||
for (const key of ['se-left-width', 'se-terminal-height', 'btd-left-width', 'btd-right-width']) {
|
||||
const v = localStorage.getItem(key);
|
||||
if (v != null) {
|
||||
const n = Number(v);
|
||||
panels[key] = Number.isFinite(n) ? n : v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const virtualTargets = readJson<unknown[]>('gc_virtual_targets_v1');
|
||||
const virtualSession = readJson<unknown>('gc_virtual_session_v1');
|
||||
const cardView = localStorage.getItem('gc_virtual_card_view_v1');
|
||||
if (virtualTargets?.length || virtualSession || cardView) {
|
||||
patch.virtual = {
|
||||
...patch.virtual,
|
||||
...(virtualTargets?.length ? { targets: virtualTargets as NonNullable<UiPreferences['virtual']>['targets'] } : {}),
|
||||
...(virtualSession ? { session: virtualSession as NonNullable<UiPreferences['virtual']>['session'] } : {}),
|
||||
...(cardView === 'detail' || cardView === 'summary' ? { cardViewMode: cardView } : {}),
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const readIds = readJson<string[]>('gc_trade_notify_read_v1');
|
||||
const hiddenIds = readJson<string[]>('gc_trade_notify_hidden_v1');
|
||||
if (readIds?.length || hiddenIds?.length) {
|
||||
patch.tradeNotifications = {
|
||||
readIds: readIds ?? [],
|
||||
hiddenIds: hiddenIds ?? [],
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const displayMode = localStorage.getItem('tsd-display-mode');
|
||||
if (displayMode === 'chart' || displayMode === 'summary') {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed && current.uiPreferences) {
|
||||
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
removeKeys([
|
||||
'gc_se_editor_mode',
|
||||
'gc_se_canvas_interaction',
|
||||
'se-palette-auxiliary-v1',
|
||||
'se-palette-composite-v1',
|
||||
'gc-indicator-custom-tabs-v1',
|
||||
'gc_indicator_main_tab_order',
|
||||
'gc_indicator_settings_list_order',
|
||||
'trading_chart_state',
|
||||
'chartLayout',
|
||||
'chartLayoutSync',
|
||||
'gc_virtual_targets_v1',
|
||||
'gc_virtual_session_v1',
|
||||
'gc_virtual_card_view_v1',
|
||||
'gc_trade_notify_read_v1',
|
||||
'gc_trade_notify_hidden_v1',
|
||||
'tsd-display-mode',
|
||||
'gc_se_flow_layout_v1',
|
||||
'gc_strat_v2',
|
||||
'gc_strategies_v1',
|
||||
'se-left-width',
|
||||
'se-terminal-height',
|
||||
'btd-left-width',
|
||||
'btd-right-width',
|
||||
]);
|
||||
|
||||
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
|
||||
|
||||
return { uiPreferences: resolveUiPreferences(patch), changed: true };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
/** 가상투자 대상 종목·세션 설정 localStorage */
|
||||
/** 가상투자 대상 종목·세션 설정 (DB ui_preferences + per-market live settings) */
|
||||
|
||||
export interface VirtualTargetItem {
|
||||
market: string;
|
||||
@@ -22,68 +23,9 @@ export interface VirtualSessionConfig {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const TARGETS_KEY = 'gc_virtual_targets_v1';
|
||||
const SESSION_KEY = 'gc_virtual_session_v1';
|
||||
const CARD_VIEW_KEY = 'gc_virtual_card_view_v1';
|
||||
|
||||
/** 카드 표시: summary=이퀄라이저·신호등·푸터만, detail=지표 테이블 포함 전체 */
|
||||
export type VirtualCardViewMode = 'summary' | 'detail';
|
||||
|
||||
export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(TARGETS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as VirtualTargetItem[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map(t => {
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||
t.market,
|
||||
t.koreanName,
|
||||
t.englishName,
|
||||
);
|
||||
return { ...t, koreanName, englishName };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
try {
|
||||
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
|
||||
notifyVirtualSessionChanged();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function loadVirtualSession(): VirtualSessionConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return defaultSession();
|
||||
const parsed = JSON.parse(raw) as Partial<VirtualSessionConfig>;
|
||||
return {
|
||||
globalStrategyId: parsed.globalStrategyId ?? null,
|
||||
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
running: parsed.running === true,
|
||||
};
|
||||
} catch {
|
||||
return defaultSession();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
|
||||
notifyVirtualSessionChanged();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
|
||||
|
||||
export function notifyVirtualSessionChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
function defaultSession(): VirtualSessionConfig {
|
||||
return {
|
||||
globalStrategyId: null,
|
||||
@@ -93,19 +35,57 @@ function defaultSession(): VirtualSessionConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
||||
return items.map(t => {
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||
t.market,
|
||||
t.koreanName,
|
||||
t.englishName,
|
||||
);
|
||||
return { ...t, koreanName, englishName };
|
||||
});
|
||||
}
|
||||
|
||||
export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
const raw = getUiPreferences().virtual?.targets;
|
||||
if (!raw?.length) return [];
|
||||
return normalizeTargets(raw);
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
patchUiPreferences({ virtual: { targets: items } });
|
||||
notifyVirtualSessionChanged();
|
||||
}
|
||||
|
||||
export function loadVirtualSession(): VirtualSessionConfig {
|
||||
const parsed = getUiPreferences().virtual?.session;
|
||||
if (!parsed) return defaultSession();
|
||||
return {
|
||||
globalStrategyId: parsed.globalStrategyId ?? null,
|
||||
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
running: parsed.running === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||
patchUiPreferences({ virtual: { session: cfg } });
|
||||
notifyVirtualSessionChanged();
|
||||
}
|
||||
|
||||
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
|
||||
|
||||
export function notifyVirtualSessionChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export function loadVirtualCardViewMode(): VirtualCardViewMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(CARD_VIEW_KEY);
|
||||
return raw === 'detail' ? 'detail' : 'summary';
|
||||
} catch {
|
||||
return 'summary';
|
||||
}
|
||||
const mode = getUiPreferences().virtual?.cardViewMode;
|
||||
return mode === 'detail' ? 'detail' : 'summary';
|
||||
}
|
||||
|
||||
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||
try {
|
||||
localStorage.setItem(CARD_VIEW_KEY, mode);
|
||||
} catch { /* ignore */ }
|
||||
patchUiPreferences({ virtual: { cardViewMode: mode } });
|
||||
}
|
||||
|
||||
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
|
||||
|
||||
Reference in New Issue
Block a user