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
+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 */