전략편집기 템플릿 저장 기능 추가
This commit is contained in:
+41
-11
@@ -2259,15 +2259,45 @@ html.theme-blue {
|
||||
/* 컬럼 헤더 */
|
||||
.msp-header {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 90px 74px 72px;
|
||||
grid-template-columns: 32px minmax(120px, 1fr) 90px 74px 72px;
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg2);
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.msp-h-sort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
transition: color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.msp-h-sort-btn.msp-col-name {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.msp-h-sort-btn:hover { color: var(--text); }
|
||||
.msp-h-sort-btn--active { color: var(--accent, #2196f3); }
|
||||
.msp-sort-icon {
|
||||
font-size: 9px;
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
}
|
||||
.msp-h-sort-btn--active .msp-sort-icon { opacity: 1; }
|
||||
|
||||
/* 목록 */
|
||||
.msp-list {
|
||||
flex: 1;
|
||||
@@ -5601,15 +5631,15 @@ html.theme-blue {
|
||||
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.market-panel--open {
|
||||
width: 268px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
/* ── 패널 본문 (스크롤 포함) ──────────────────────────────────── */
|
||||
.mp-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 268px;
|
||||
min-width: 268px;
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg2);
|
||||
@@ -5687,9 +5717,9 @@ html.theme-blue {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg3);
|
||||
}
|
||||
.mp-h-name { flex: 1; }
|
||||
.mp-h-price { width: 80px; text-align: right; }
|
||||
.mp-h-change { width: 62px; text-align: right; padding-right: 4px; }
|
||||
.mp-h-name { flex: 1; min-width: 0; }
|
||||
.mp-h-price { width: 88px; text-align: right; }
|
||||
.mp-h-change { width: 68px; text-align: right; padding-right: 4px; }
|
||||
|
||||
/* 정렬 버튼 공통 */
|
||||
.mp-h-sort-btn {
|
||||
@@ -5855,8 +5885,8 @@ html.theme-blue {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.mp-code {
|
||||
font-size: 10px;
|
||||
@@ -5866,7 +5896,7 @@ html.theme-blue {
|
||||
|
||||
/* 가격 / 등락 */
|
||||
.mp-price {
|
||||
width: 80px;
|
||||
width: 88px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -5874,7 +5904,7 @@ html.theme-blue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mp-change {
|
||||
width: 58px;
|
||||
width: 64px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface ChartWorkspaceViewProps {
|
||||
mobileRightOpen: boolean;
|
||||
mobileRightTab: 'trade' | 'orderbook';
|
||||
marketTickers: Map<string, TickerData>;
|
||||
marketInfos: import('../hooks/useMarketTicker').MarketInfo[];
|
||||
marketLoading: boolean;
|
||||
usdRate: number;
|
||||
formalLogin: boolean;
|
||||
@@ -316,7 +317,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
||||
drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode,
|
||||
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
|
||||
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
|
||||
mobileRightOpen, mobileRightTab, marketTickers, marketLoading, usdRate,
|
||||
mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
|
||||
formalLogin, requireFormalLogin, chartVisible, menuPage, isMobile,
|
||||
indicators, effectiveIndicators, mainChartStyle, showMainChartModal,
|
||||
showBulkIndSettings, settingsModalId, ctxToolbar, drawingCtxToolbar,
|
||||
@@ -520,6 +521,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
||||
<MarketPanel
|
||||
open={showMarketPanel}
|
||||
tickers={marketTickers}
|
||||
marketInfos={marketInfos}
|
||||
loading={marketLoading}
|
||||
usdRate={usdRate}
|
||||
activeSymbol={layoutDef.count > 1 ? activeSlotSymbol : symbol}
|
||||
|
||||
@@ -581,7 +581,7 @@ export function useChartWorkspace({
|
||||
loadFull: showMarketPanel && tickerFeedActive,
|
||||
getPriorityMarkets,
|
||||
}), [tickerFeedActive, isCompactDevice, showMarketPanel, getPriorityMarkets]);
|
||||
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
|
||||
const { tickers: marketTickers, marketInfos, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
|
||||
const chartTicker = marketTickers.get(chartSymbol);
|
||||
const orderbookPrevClose = chartTicker
|
||||
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
|
||||
@@ -1877,13 +1877,14 @@ export function useChartWorkspace({
|
||||
priorityMarkets,
|
||||
marketPanelOpen: showMarketPanel,
|
||||
marketTickers,
|
||||
marketInfos,
|
||||
marketLoading,
|
||||
usdRate,
|
||||
settingsPageProps,
|
||||
}), [
|
||||
symbol, indicators, goToMarketChart, refreshPaperAccount,
|
||||
paperCashBalance, paperCoinQty, handlePaperOrderFilled, paperRefreshKey, mode,
|
||||
priorityMarkets, showMarketPanel, marketTickers, marketLoading, usdRate,
|
||||
priorityMarkets, showMarketPanel, marketTickers, marketInfos, marketLoading, usdRate,
|
||||
settingsPageProps,
|
||||
]);
|
||||
|
||||
@@ -1898,7 +1899,7 @@ export function useChartWorkspace({
|
||||
drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode,
|
||||
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
|
||||
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
|
||||
mobileRightOpen, mobileRightTab, marketTickers, marketLoading, usdRate,
|
||||
mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
|
||||
formalLogin, requireFormalLogin, chartVisible, menuPage,
|
||||
isMobile, indicators, effectiveIndicators, mainChartStyle,
|
||||
showMainChartModal, showBulkIndSettings, settingsModalId, ctxToolbar,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 관심 - 즐겨찾기 목록 (localStorage 관리)
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
|
||||
import type { TickerData, ChangeDir } from '../hooks/useMarketTicker';
|
||||
import type { TickerData, ChangeDir, MarketInfo } from '../hooks/useMarketTicker';
|
||||
import {
|
||||
getFavorites, saveFavorites, toggleFavorite,
|
||||
getHoldings, addHolding, removeHolding,
|
||||
@@ -22,6 +22,8 @@ type SortDir = 'asc' | 'desc';
|
||||
export interface MarketPanelProps {
|
||||
open: boolean;
|
||||
tickers: Map<string, TickerData>;
|
||||
/** 업비트 KRW 전 종목 (종목검색 드롭다운과 동일 소스) */
|
||||
marketInfos: MarketInfo[];
|
||||
loading: boolean;
|
||||
usdRate: number; // KRW per 1 USD (from KRW-USDT)
|
||||
activeSymbol: string;
|
||||
@@ -59,6 +61,7 @@ const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir })
|
||||
interface RowProps {
|
||||
ticker: TickerData | undefined;
|
||||
market: string;
|
||||
koreanName: string;
|
||||
isActive: boolean;
|
||||
isFav: boolean;
|
||||
isHold: boolean;
|
||||
@@ -72,7 +75,7 @@ interface RowProps {
|
||||
}
|
||||
|
||||
const MarketRow = memo(function MarketRow({
|
||||
ticker, market, isActive, isFav, isHold,
|
||||
ticker, market, koreanName, isActive, isFav, isHold,
|
||||
showUsd, showHoldBtn, usdRate,
|
||||
onSelect, onToggleFav, onToggleHold, onRemoveHold,
|
||||
}: RowProps) {
|
||||
@@ -116,7 +119,7 @@ const MarketRow = memo(function MarketRow({
|
||||
<div className="mp-name">
|
||||
<span className="mp-korean-row">
|
||||
<ChangeArrow change={change} />
|
||||
<span className="mp-korean">{ticker?.koreanName ?? code}</span>
|
||||
<span className="mp-korean">{koreanName}</span>
|
||||
</span>
|
||||
<span className="mp-code">{code}/KRW</span>
|
||||
</div>
|
||||
@@ -151,7 +154,7 @@ const MarketRow = memo(function MarketRow({
|
||||
|
||||
// ─── 메인 패널 ─────────────────────────────────────────────────────────────
|
||||
export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
open, tickers, loading, usdRate, activeSymbol, onSymbolSelect,
|
||||
open, tickers, marketInfos, loading, usdRate, activeSymbol, onSymbolSelect,
|
||||
}) => {
|
||||
const [tab, setTab] = useState<Tab>('krw');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -214,14 +217,27 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
setShowHoldAdd(false);
|
||||
}, []);
|
||||
|
||||
// 탭별 마켓 목록 계산
|
||||
const koreanNameByMarket = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
marketInfos.forEach(m => map.set(m.market, m.koreanName));
|
||||
return map;
|
||||
}, [marketInfos]);
|
||||
|
||||
const resolveKoreanName = useCallback((market: string) =>
|
||||
tickers.get(market)?.koreanName
|
||||
?? koreanNameByMarket.get(market)
|
||||
?? market.replace(/^KRW-/, ''),
|
||||
[tickers, koreanNameByMarket]);
|
||||
|
||||
// 탭별 마켓 목록 — market/all 전 종목 (종목검색 드롭다운과 동일)
|
||||
const allKrwMarkets = useMemo(() => {
|
||||
const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
|
||||
// 24h 거래대금 내림차순 정렬
|
||||
const keys = marketInfos.length > 0
|
||||
? marketInfos.map(m => m.market)
|
||||
: Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
|
||||
return keys.sort((a, b) =>
|
||||
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
|
||||
);
|
||||
}, [tickers]);
|
||||
}, [marketInfos, tickers]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let base: string[] = [];
|
||||
@@ -234,7 +250,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
const q = search.toUpperCase();
|
||||
base = base.filter(m => {
|
||||
const code = m.replace(/^KRW-/, '');
|
||||
const name = tickers.get(m)?.koreanName ?? '';
|
||||
const name = resolveKoreanName(m);
|
||||
return code.includes(q) || name.includes(search);
|
||||
});
|
||||
}
|
||||
@@ -245,8 +261,8 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
const tb = tickers.get(b);
|
||||
switch (sortKey) {
|
||||
case 'name': {
|
||||
const na = ta?.koreanName ?? a.replace(/^KRW-/, '');
|
||||
const nb = tb?.koreanName ?? b.replace(/^KRW-/, '');
|
||||
const na = resolveKoreanName(a);
|
||||
const nb = resolveKoreanName(b);
|
||||
return mul * na.localeCompare(nb, 'ko');
|
||||
}
|
||||
case 'price':
|
||||
@@ -257,7 +273,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]);
|
||||
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir, resolveKoreanName]);
|
||||
|
||||
// 보유 탭 "추가" 검색 결과
|
||||
const holdAddResults = useMemo(() => {
|
||||
@@ -266,11 +282,11 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
return allKrwMarkets
|
||||
.filter(m => {
|
||||
const code = m.replace(/^KRW-/, '');
|
||||
const name = tickers.get(m)?.koreanName ?? '';
|
||||
const name = resolveKoreanName(m);
|
||||
return code.includes(q) || name.includes(holdSearch);
|
||||
})
|
||||
.slice(0, 10);
|
||||
}, [holdSearch, allKrwMarkets, tickers]);
|
||||
}, [holdSearch, allKrwMarkets, resolveKoreanName]);
|
||||
|
||||
const tabLabels: Record<Tab, string> = {
|
||||
krw: '원화', btc: 'BTC', hold: '보유', fav: '관심',
|
||||
@@ -352,7 +368,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
<div className="mp-hold-add-dropdown">
|
||||
{holdAddResults.map(m => (
|
||||
<div key={m} className="mp-hold-add-item" onClick={() => handleAddHolding(m)}>
|
||||
<span>{tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')}</span>
|
||||
<span>{resolveKoreanName(m)}</span>
|
||||
<span className="mp-hold-add-code">{m.replace(/^KRW-/, '')}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -388,6 +404,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
<MarketRow
|
||||
key={market}
|
||||
market={market}
|
||||
koreanName={resolveKoreanName(market)}
|
||||
ticker={tickers.get(market)}
|
||||
isActive={market === activeSymbol}
|
||||
isFav={favorites.includes(market)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { setMarketNames } from '../utils/marketNameCache';
|
||||
import {
|
||||
getFavorites,
|
||||
@@ -45,6 +45,9 @@ function formatChangeRate(raw: number | string | null | undefined): number | nul
|
||||
return rate * 100;
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'price' | 'change' | 'volume';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
interface MarketSearchPanelProps {
|
||||
currentMarket: string;
|
||||
@@ -94,6 +97,8 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
}, [controlledQuery, onQueryChange]);
|
||||
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const refreshFavs = useCallback(() => {
|
||||
setFavs(new Set(getFavorites()));
|
||||
@@ -215,6 +220,32 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
tryAddMarket(market);
|
||||
}, [tryAddMarket]);
|
||||
|
||||
const handleSort = useCallback((key: SortKey) => {
|
||||
setSortKey(prev => {
|
||||
if (prev === key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
return key;
|
||||
}
|
||||
setSortDir('desc');
|
||||
return key;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderSortHeader = (key: SortKey, label: string, className: string) => {
|
||||
const active = sortKey === key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`msp-col ${className} msp-h-sort-btn ${active ? 'msp-h-sort-btn--active' : ''}`}
|
||||
onClick={() => handleSort(key)}
|
||||
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
|
||||
>
|
||||
{label}
|
||||
<span className="msp-sort-icon">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
|
||||
const q = query.trim();
|
||||
const filtered = markets.filter(m => {
|
||||
@@ -227,15 +258,42 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
);
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const fa = favs.has(a.market) ? 1 : 0;
|
||||
const fb = favs.has(b.market) ? 1 : 0;
|
||||
if (fb !== fa) return fb - fa;
|
||||
// 즐겨찾기 동순위: 24h 거래대금 내림차순
|
||||
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
|
||||
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
|
||||
return vb - va;
|
||||
});
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) {
|
||||
return [...filtered].sort((a, b) => {
|
||||
const fa = favs.has(a.market) ? 1 : 0;
|
||||
const fb = favs.has(b.market) ? 1 : 0;
|
||||
if (fb !== fa) return fb - fa;
|
||||
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
|
||||
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
|
||||
return vb - va;
|
||||
});
|
||||
}
|
||||
const mul = sortDir === 'asc' ? 1 : -1;
|
||||
return [...filtered].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'name':
|
||||
return mul * a.korean_name.localeCompare(b.korean_name, 'ko');
|
||||
case 'price': {
|
||||
const pa = tickers[a.market]?.trade_price ?? 0;
|
||||
const pb = tickers[b.market]?.trade_price ?? 0;
|
||||
return mul * (pa - pb);
|
||||
}
|
||||
case 'change': {
|
||||
const ca = tickers[a.market]?.signed_change_rate ?? 0;
|
||||
const cb = tickers[b.market]?.signed_change_rate ?? 0;
|
||||
return mul * (ca - cb);
|
||||
}
|
||||
case 'volume': {
|
||||
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
|
||||
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
|
||||
return mul * (va - vb);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [filtered, sortKey, sortDir, favs, tickers]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
// anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다.
|
||||
@@ -362,14 +420,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
{!isCompactDropdown && (
|
||||
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
|
||||
)}
|
||||
<span className="msp-col msp-col-name">{isCompactDropdown ? '종목명' : '한글명'}</span>
|
||||
{renderSortHeader('name', isCompactDropdown ? '종목명' : '한글명', 'msp-col-name')}
|
||||
{!embedded && (
|
||||
<>
|
||||
<span className="msp-col msp-col-price">현재가</span>
|
||||
<span className="msp-col msp-col-change">전일대비</span>
|
||||
{!isCompactDropdown && (
|
||||
<span className="msp-col msp-col-vol">거래대금</span>
|
||||
)}
|
||||
{renderSortHeader('price', '현재가', 'msp-col-price')}
|
||||
{renderSortHeader('change', '전일대비', 'msp-col-change')}
|
||||
{!isCompactDropdown && renderSortHeader('volume', '거래대금', 'msp-col-vol')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,12 @@ import {
|
||||
simpleTemplateToNode,
|
||||
type StrategyTemplateDef,
|
||||
} from '../utils/strategyPresets';
|
||||
import {
|
||||
addUserStrategyTemplate,
|
||||
deleteUserStrategyTemplate,
|
||||
loadUserStrategyTemplates,
|
||||
type UserStrategyTemplate,
|
||||
} from '../utils/strategyTemplateStorage';
|
||||
import {
|
||||
buildStrategyExportPayload,
|
||||
buildStrategyListExportPayload,
|
||||
@@ -193,6 +199,12 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||
const [paletteSearch, setPaletteSearch] = useState('');
|
||||
const [templateSearch, setTemplateSearch] = useState('');
|
||||
const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
|
||||
const [templateSaveOpen, setTemplateSaveOpen] = useState(false);
|
||||
const [templateSaveLabel, setTemplateSaveLabel] = useState('');
|
||||
const [templateSaveDesc, setTemplateSaveDesc] = useState('');
|
||||
const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false);
|
||||
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
|
||||
const [stratName, setStratName] = useState('');
|
||||
const [stratDesc, setStratDesc] = useState('');
|
||||
@@ -1027,6 +1039,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
|
||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||
|
||||
const templateRows = useMemo(() => {
|
||||
const userRows = userTemplates.map(t => ({
|
||||
key: t.id,
|
||||
source: 'user' as const,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
signal: t.signal,
|
||||
user: t,
|
||||
}));
|
||||
const builtinRows = templates.map((t, i) => ({
|
||||
key: `builtin-${i}`,
|
||||
source: 'builtin' as const,
|
||||
label: t.label,
|
||||
description: 'description' in t ? t.description : undefined,
|
||||
signal: t.signal,
|
||||
builtin: t,
|
||||
}));
|
||||
return [...userRows, ...builtinRows];
|
||||
}, [userTemplates, templates]);
|
||||
|
||||
const filteredTemplateRows = useMemo(() => {
|
||||
const q = templateSearch.trim().toLowerCase();
|
||||
if (!q) return templateRows;
|
||||
return templateRows.filter(row => {
|
||||
const signalLabel = row.signal === 'buy' ? '매수' : '매도';
|
||||
return (
|
||||
row.label.toLowerCase().includes(q)
|
||||
|| row.description?.toLowerCase().includes(q)
|
||||
|| signalLabel.includes(q)
|
||||
|| row.signal.includes(q)
|
||||
);
|
||||
});
|
||||
}, [templateRows, templateSearch]);
|
||||
|
||||
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
|
||||
@@ -1047,6 +1093,93 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
|
||||
|
||||
const applyUserTemplate = useCallback((tmpl: UserStrategyTemplate) => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
|
||||
const targetTab = tmpl.signal;
|
||||
const layoutFromState = {
|
||||
startMeta: tmpl.editorState.startMeta,
|
||||
extraStartIds: tmpl.editorState.extraStartIds,
|
||||
extraRoots: tmpl.editorState.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(tmpl.editorState.startCombineOp),
|
||||
positions: {},
|
||||
edgeHandles: {},
|
||||
};
|
||||
|
||||
setBuyCondition(null);
|
||||
setSellCondition(null);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
setSelectedNodeId(null);
|
||||
|
||||
if (targetTab === 'buy') {
|
||||
setBuyCondition(tmpl.editorState.root);
|
||||
setBuyLayout(prev => ({ ...prev, ...layoutFromState }));
|
||||
setBuyOrphans(tmpl.orphans);
|
||||
setSellLayout(normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
} else {
|
||||
setSellCondition(tmpl.editorState.root);
|
||||
setSellLayout(prev => ({ ...prev, ...layoutFromState }));
|
||||
setSellOrphans(tmpl.orphans);
|
||||
setBuyLayout(normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
}
|
||||
|
||||
bumpLayoutSeed(layoutStrategyKey, targetTab);
|
||||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||||
scheduleStrategyPersist();
|
||||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||||
}, [
|
||||
editorMode,
|
||||
layoutStrategyKey,
|
||||
bumpLayoutSeed,
|
||||
signalTab,
|
||||
scheduleStrategyPersist,
|
||||
]);
|
||||
|
||||
const handleDeleteUserTemplate = useCallback((id: string, label: string) => {
|
||||
if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return;
|
||||
setUserTemplates(deleteUserStrategyTemplate(id));
|
||||
showSnack('템플릿을 삭제했습니다');
|
||||
}, []);
|
||||
|
||||
const openTemplateSaveModal = useCallback(() => {
|
||||
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
|
||||
if (!hasContent) {
|
||||
showSnack('저장할 조건이 없습니다', false);
|
||||
return;
|
||||
}
|
||||
setTemplateSaveLabel(stratName.trim() || `${signalTab === 'buy' ? '매수' : '매도'} 템플릿`);
|
||||
setTemplateSaveDesc('');
|
||||
setTemplateSaveLabelError(false);
|
||||
setTemplateSaveOpen(true);
|
||||
}, [currentEditorState, currentOrphans.length, stratName, signalTab]);
|
||||
|
||||
const handleTemplateSaveConfirm = useCallback(() => {
|
||||
const label = templateSaveLabel.trim();
|
||||
if (!label) {
|
||||
setTemplateSaveLabelError(true);
|
||||
return;
|
||||
}
|
||||
const saved = addUserStrategyTemplate({
|
||||
label,
|
||||
description: templateSaveDesc.trim() || undefined,
|
||||
signal: signalTab,
|
||||
editorState: toEditorState(currentRoot, currentLayout),
|
||||
orphans: currentOrphans,
|
||||
});
|
||||
setUserTemplates(prev => [...prev, saved]);
|
||||
setTemplateSaveOpen(false);
|
||||
setRightTab('templates');
|
||||
showSnack(`"${saved.label}" 템플릿을 저장했습니다`);
|
||||
}, [
|
||||
templateSaveLabel,
|
||||
templateSaveDesc,
|
||||
signalTab,
|
||||
currentRoot,
|
||||
currentLayout,
|
||||
currentOrphans,
|
||||
]);
|
||||
|
||||
const handleQuickRun = useCallback(async () => {
|
||||
if (!selectedId) {
|
||||
window.alert('백테스팅할 전략을 먼저 선택해주세요.');
|
||||
@@ -1216,23 +1349,6 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
</div>
|
||||
|
||||
<div className="se-header-actions">
|
||||
<div className="se-editor-mode" role="group" aria-label="편집 방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-editor-mode-btn${editorMode === 'graph' ? ' se-editor-mode-btn--on' : ''}`}
|
||||
onClick={() => handleEditorModeChange('graph')}
|
||||
>
|
||||
그래프 방식
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-editor-mode-btn${editorMode === 'list' ? ' se-editor-mode-btn--on' : ''}`}
|
||||
onClick={() => handleEditorModeChange('list')}
|
||||
>
|
||||
목록 방식
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={handleNew}>+ 새 전략</button>
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
|
||||
@@ -1282,7 +1398,6 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
>
|
||||
⬆
|
||||
</button>
|
||||
<button type="button" className="se-btn se-btn--gold" onClick={() => { setSaveNameError(false); setSaveOpen(true); }}>저장하기</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1383,6 +1498,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
onChange={handleStartCombineOpChange}
|
||||
/>
|
||||
)}
|
||||
<div className="se-center-head-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost se-btn--sm" onClick={handleNew}>+ 새 전략</button>
|
||||
<div className="se-editor-mode se-editor-mode--compact" role="group" aria-label="편집 방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-editor-mode-btn${editorMode === 'graph' ? ' se-editor-mode-btn--on' : ''}`}
|
||||
onClick={() => handleEditorModeChange('graph')}
|
||||
>
|
||||
그래프 방식
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-editor-mode-btn${editorMode === 'list' ? ' se-editor-mode-btn--on' : ''}`}
|
||||
onClick={() => handleEditorModeChange('list')}
|
||||
>
|
||||
목록 방식
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--ghost se-btn--sm"
|
||||
onClick={openTemplateSaveModal}
|
||||
title="현재 탭(매수/매도) 조건을 템플릿 목록에 저장"
|
||||
>
|
||||
템플릿 저장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--gold se-btn--sm"
|
||||
onClick={() => { setSaveNameError(false); setSaveOpen(true); }}
|
||||
>
|
||||
저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editorMode === 'graph' ? (
|
||||
@@ -1616,17 +1765,62 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
</>
|
||||
)}
|
||||
{rightTab === 'templates' && (
|
||||
<div className="se-template-list">
|
||||
{templates.map((t, i) => (
|
||||
<button key={i} type="button" className="se-template-item" onClick={() => handleTemplate(t)}>
|
||||
<span className="se-template-label">{t.label}</span>
|
||||
{'description' in t && t.description && (
|
||||
<span className="se-template-desc">{t.description}</span>
|
||||
)}
|
||||
<span className={`se-template-signal se-template-signal--${t.signal}`}>{t.signal === 'buy' ? '매수' : '매도'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<input
|
||||
className="se-palette-search"
|
||||
placeholder="템플릿 검색 (RSI, MA, 매수…)"
|
||||
value={templateSearch}
|
||||
onChange={e => setTemplateSearch(e.target.value)}
|
||||
/>
|
||||
<div className="se-template-list">
|
||||
{filteredTemplateRows.length === 0 ? (
|
||||
<div className="se-template-empty">검색 결과가 없습니다</div>
|
||||
) : (
|
||||
filteredTemplateRows.map(row => (
|
||||
<div key={row.key} className="se-template-row">
|
||||
<button
|
||||
type="button"
|
||||
className="se-template-item"
|
||||
onClick={() => {
|
||||
if (row.source === 'user') applyUserTemplate(row.user);
|
||||
else handleTemplate(row.builtin);
|
||||
}}
|
||||
>
|
||||
<span className="se-template-label">
|
||||
{row.source === 'user' && <span className="se-template-user-badge">내 템플릿</span>}
|
||||
{row.label}
|
||||
</span>
|
||||
{row.description && (
|
||||
<span className="se-template-desc">{row.description}</span>
|
||||
)}
|
||||
<span className={`se-template-signal se-template-signal--${row.signal}`}>
|
||||
{row.signal === 'buy' ? '매수' : '매도'}
|
||||
</span>
|
||||
</button>
|
||||
{row.source === 'user' && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-template-delete"
|
||||
title="템플릿 삭제"
|
||||
aria-label={`${row.label} 템플릿 삭제`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleDeleteUserTemplate(row.user.id, row.label);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1635,6 +1829,38 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templateSaveOpen && (
|
||||
<DraggableModalFrame onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title="템플릿 저장">
|
||||
<div className="se-modal-body">
|
||||
<p className="se-template-save-hint">
|
||||
현재 <strong>{signalTab === 'buy' ? '매수' : '매도'}</strong> 탭 조건을 템플릿 목록에 저장합니다.
|
||||
</p>
|
||||
<label className="se-field-lbl">템플릿명 *</label>
|
||||
<input
|
||||
className={`se-field-inp${templateSaveLabelError ? ' se-field-inp--error' : ''}`}
|
||||
value={templateSaveLabel}
|
||||
onChange={e => { setTemplateSaveLabel(e.target.value); setTemplateSaveLabelError(false); }}
|
||||
placeholder="예: RSI 과매도 + MA 골든"
|
||||
/>
|
||||
{templateSaveLabelError && (
|
||||
<p className="se-field-error" role="alert">템플릿 이름을 입력하세요</p>
|
||||
)}
|
||||
<label className="se-field-lbl">설명</label>
|
||||
<textarea
|
||||
className="se-field-ta"
|
||||
value={templateSaveDesc}
|
||||
onChange={e => setTemplateSaveDesc(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="템플릿 설명 (선택)"
|
||||
/>
|
||||
<div className="se-modal-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={() => setTemplateSaveOpen(false)}>취소</button>
|
||||
<button type="button" className="se-btn se-btn--gold" onClick={handleTemplateSaveConfirm}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
)}
|
||||
|
||||
{saveOpen && (
|
||||
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
|
||||
<div className="se-modal-body">
|
||||
|
||||
@@ -186,6 +186,15 @@
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--se-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.se-editor-mode--compact {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-editor-mode--compact .se-editor-mode-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.se-list-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -712,18 +721,34 @@
|
||||
.se-center-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-center-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-btn--sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.se-start-combine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.se-center-head .se-start-combine {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.se-start-combine-label {
|
||||
@@ -1877,15 +1902,27 @@
|
||||
}
|
||||
.se-palette-sync { color: var(--se-success); font-size: 0.65rem; }
|
||||
|
||||
.se-template-list { padding: 10px; overflow-y: auto; flex: 1; }
|
||||
.se-template-list { padding: 0 10px 10px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
.se-template-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.se-template-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.se-template-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-palette-card-bg);
|
||||
@@ -1898,7 +1935,47 @@
|
||||
border-color: var(--se-palette-hover-border);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--se-gold) 10%, transparent);
|
||||
}
|
||||
.se-template-label { font-size: 0.76rem; flex: 1 1 auto; }
|
||||
.se-template-delete {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
border: 1px solid var(--se-border);
|
||||
border-radius: 8px;
|
||||
background: var(--se-palette-card-bg);
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.se-template-delete:hover {
|
||||
color: var(--se-danger);
|
||||
border-color: color-mix(in srgb, var(--se-danger) 45%, var(--se-border));
|
||||
background: color-mix(in srgb, var(--se-danger) 8%, var(--se-palette-card-bg));
|
||||
}
|
||||
.se-template-label {
|
||||
font-size: 0.76rem;
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.se-template-user-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
color: var(--se-gold);
|
||||
background: color-mix(in srgb, var(--se-gold) 14%, transparent);
|
||||
}
|
||||
.se-template-save-hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--se-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.se-template-desc {
|
||||
flex: 1 1 100%;
|
||||
font-size: 0.66rem;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage';
|
||||
|
||||
/** gc_app_settings.ui_preferences_json 구조 */
|
||||
export interface UiPreferences {
|
||||
@@ -12,6 +13,7 @@ export interface UiPreferences {
|
||||
canvasInteraction?: string;
|
||||
paletteAuxiliary?: PaletteItem[];
|
||||
paletteComposite?: PaletteItem[];
|
||||
userTemplates?: UserStrategyTemplate[];
|
||||
};
|
||||
indicator?: {
|
||||
customTabs?: IndicatorCustomTab[];
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/** 전략편집기 — 사용자 저장 템플릿 (DB ui_preferences) */
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { EditorConditionState } from './strategyConditionSerde';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export type UserStrategyTemplate = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
signal: 'buy' | 'sell';
|
||||
editorState: EditorConditionState;
|
||||
orphans: LogicNode[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function createUserTemplateId(): string {
|
||||
return `tmpl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function parseStored(raw: unknown): UserStrategyTemplate[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(t): t is UserStrategyTemplate =>
|
||||
t != null
|
||||
&& typeof t === 'object'
|
||||
&& typeof (t as UserStrategyTemplate).id === 'string'
|
||||
&& typeof (t as UserStrategyTemplate).label === 'string'
|
||||
&& ((t as UserStrategyTemplate).signal === 'buy' || (t as UserStrategyTemplate).signal === 'sell')
|
||||
&& typeof (t as UserStrategyTemplate).editorState === 'object'
|
||||
&& Array.isArray((t as UserStrategyTemplate).orphans),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadUserStrategyTemplates(): UserStrategyTemplate[] {
|
||||
const raw = getUiPreferences().strategyEditor?.userTemplates;
|
||||
return parseStored(raw);
|
||||
}
|
||||
|
||||
export function saveUserStrategyTemplates(templates: UserStrategyTemplate[]): void {
|
||||
patchUiPreferences({ strategyEditor: { userTemplates: templates } });
|
||||
}
|
||||
|
||||
export function addUserStrategyTemplate(
|
||||
input: Omit<UserStrategyTemplate, 'id' | 'createdAt'>,
|
||||
): UserStrategyTemplate {
|
||||
const next: UserStrategyTemplate = {
|
||||
...input,
|
||||
id: createUserTemplateId(),
|
||||
editorState: cloneJson(input.editorState),
|
||||
orphans: cloneJson(input.orphans),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const templates = [...loadUserStrategyTemplates(), next];
|
||||
saveUserStrategyTemplates(templates);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function deleteUserStrategyTemplate(id: string): UserStrategyTemplate[] {
|
||||
const templates = loadUserStrategyTemplates().filter(t => t.id !== id);
|
||||
saveUserStrategyTemplates(templates);
|
||||
return templates;
|
||||
}
|
||||
Reference in New Issue
Block a user