전략편집기 템플릿 저장 기능 추가

This commit is contained in:
Macbook
2026-06-11 23:24:29 +09:00
parent 05c15ec92b
commit eb6cf894da
9 changed files with 558 additions and 81 deletions
+41 -11
View File
@@ -2259,15 +2259,45 @@ html.theme-blue {
/* 컬럼 헤더 */ /* 컬럼 헤더 */
.msp-header { .msp-header {
display: grid; display: grid;
grid-template-columns: 32px 1fr 90px 74px 72px; grid-template-columns: 32px minmax(120px, 1fr) 90px 74px 72px;
padding: 5px 12px; padding: 5px 12px;
font-size: 11px; font-size: 11px;
color: var(--text3); color: var(--text3);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg2); background: var(--bg2);
user-select: none; 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 { .msp-list {
flex: 1; flex: 1;
@@ -5601,15 +5631,15 @@ html.theme-blue {
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1); transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
} }
.market-panel--open { .market-panel--open {
width: 268px; width: 320px;
} }
/* ── 패널 본문 (스크롤 포함) ──────────────────────────────────── */ /* ── 패널 본문 (스크롤 포함) ──────────────────────────────────── */
.mp-body { .mp-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 268px; width: 320px;
min-width: 268px; min-width: 320px;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg2); background: var(--bg2);
@@ -5687,9 +5717,9 @@ html.theme-blue {
flex-shrink: 0; flex-shrink: 0;
background: var(--bg3); background: var(--bg3);
} }
.mp-h-name { flex: 1; } .mp-h-name { flex: 1; min-width: 0; }
.mp-h-price { width: 80px; text-align: right; } .mp-h-price { width: 88px; text-align: right; }
.mp-h-change { width: 62px; text-align: right; padding-right: 4px; } .mp-h-change { width: 68px; text-align: right; padding-right: 4px; }
/* 정렬 버튼 공통 */ /* 정렬 버튼 공통 */
.mp-h-sort-btn { .mp-h-sort-btn {
@@ -5855,8 +5885,8 @@ html.theme-blue {
color: var(--text); color: var(--text);
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: visible;
text-overflow: ellipsis; text-overflow: clip;
} }
.mp-code { .mp-code {
font-size: 10px; font-size: 10px;
@@ -5866,7 +5896,7 @@ html.theme-blue {
/* 가격 / 등락 */ /* 가격 / 등락 */
.mp-price { .mp-price {
width: 80px; width: 88px;
text-align: right; text-align: right;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -5874,7 +5904,7 @@ html.theme-blue {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.mp-change { .mp-change {
width: 58px; width: 64px;
text-align: right; text-align: right;
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
+3 -1
View File
@@ -107,6 +107,7 @@ export interface ChartWorkspaceViewProps {
mobileRightOpen: boolean; mobileRightOpen: boolean;
mobileRightTab: 'trade' | 'orderbook'; mobileRightTab: 'trade' | 'orderbook';
marketTickers: Map<string, TickerData>; marketTickers: Map<string, TickerData>;
marketInfos: import('../hooks/useMarketTicker').MarketInfo[];
marketLoading: boolean; marketLoading: boolean;
usdRate: number; usdRate: number;
formalLogin: boolean; formalLogin: boolean;
@@ -316,7 +317,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode, drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode,
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch, snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel, showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
mobileRightOpen, mobileRightTab, marketTickers, marketLoading, usdRate, mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
formalLogin, requireFormalLogin, chartVisible, menuPage, isMobile, formalLogin, requireFormalLogin, chartVisible, menuPage, isMobile,
indicators, effectiveIndicators, mainChartStyle, showMainChartModal, indicators, effectiveIndicators, mainChartStyle, showMainChartModal,
showBulkIndSettings, settingsModalId, ctxToolbar, drawingCtxToolbar, showBulkIndSettings, settingsModalId, ctxToolbar, drawingCtxToolbar,
@@ -520,6 +521,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
<MarketPanel <MarketPanel
open={showMarketPanel} open={showMarketPanel}
tickers={marketTickers} tickers={marketTickers}
marketInfos={marketInfos}
loading={marketLoading} loading={marketLoading}
usdRate={usdRate} usdRate={usdRate}
activeSymbol={layoutDef.count > 1 ? activeSlotSymbol : symbol} activeSymbol={layoutDef.count > 1 ? activeSlotSymbol : symbol}
+4 -3
View File
@@ -581,7 +581,7 @@ export function useChartWorkspace({
loadFull: showMarketPanel && tickerFeedActive, loadFull: showMarketPanel && tickerFeedActive,
getPriorityMarkets, getPriorityMarkets,
}), [tickerFeedActive, isCompactDevice, showMarketPanel, 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 chartTicker = marketTickers.get(chartSymbol);
const orderbookPrevClose = chartTicker const orderbookPrevClose = chartTicker
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0) ? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
@@ -1877,13 +1877,14 @@ export function useChartWorkspace({
priorityMarkets, priorityMarkets,
marketPanelOpen: showMarketPanel, marketPanelOpen: showMarketPanel,
marketTickers, marketTickers,
marketInfos,
marketLoading, marketLoading,
usdRate, usdRate,
settingsPageProps, settingsPageProps,
}), [ }), [
symbol, indicators, goToMarketChart, refreshPaperAccount, symbol, indicators, goToMarketChart, refreshPaperAccount,
paperCashBalance, paperCoinQty, handlePaperOrderFilled, paperRefreshKey, mode, paperCashBalance, paperCoinQty, handlePaperOrderFilled, paperRefreshKey, mode,
priorityMarkets, showMarketPanel, marketTickers, marketLoading, usdRate, priorityMarkets, showMarketPanel, marketTickers, marketInfos, marketLoading, usdRate,
settingsPageProps, settingsPageProps,
]); ]);
@@ -1898,7 +1899,7 @@ export function useChartWorkspace({
drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode, drawingsLocked, drawingsVisible, indicatorsVisible, magnetMode,
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch, snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel, showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
mobileRightOpen, mobileRightTab, marketTickers, marketLoading, usdRate, mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
formalLogin, requireFormalLogin, chartVisible, menuPage, formalLogin, requireFormalLogin, chartVisible, menuPage,
isMobile, indicators, effectiveIndicators, mainChartStyle, isMobile, indicators, effectiveIndicators, mainChartStyle,
showMainChartModal, showBulkIndSettings, settingsModalId, ctxToolbar, showMainChartModal, showBulkIndSettings, settingsModalId, ctxToolbar,
+32 -15
View File
@@ -8,7 +8,7 @@
* 관심 - 즐겨찾기 목록 (localStorage 관리) * 관심 - 즐겨찾기 목록 (localStorage 관리)
*/ */
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react'; 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 { import {
getFavorites, saveFavorites, toggleFavorite, getFavorites, saveFavorites, toggleFavorite,
getHoldings, addHolding, removeHolding, getHoldings, addHolding, removeHolding,
@@ -22,6 +22,8 @@ type SortDir = 'asc' | 'desc';
export interface MarketPanelProps { export interface MarketPanelProps {
open: boolean; open: boolean;
tickers: Map<string, TickerData>; tickers: Map<string, TickerData>;
/** 업비트 KRW 전 종목 (종목검색 드롭다운과 동일 소스) */
marketInfos: MarketInfo[];
loading: boolean; loading: boolean;
usdRate: number; // KRW per 1 USD (from KRW-USDT) usdRate: number; // KRW per 1 USD (from KRW-USDT)
activeSymbol: string; activeSymbol: string;
@@ -59,6 +61,7 @@ const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir })
interface RowProps { interface RowProps {
ticker: TickerData | undefined; ticker: TickerData | undefined;
market: string; market: string;
koreanName: string;
isActive: boolean; isActive: boolean;
isFav: boolean; isFav: boolean;
isHold: boolean; isHold: boolean;
@@ -72,7 +75,7 @@ interface RowProps {
} }
const MarketRow = memo(function MarketRow({ const MarketRow = memo(function MarketRow({
ticker, market, isActive, isFav, isHold, ticker, market, koreanName, isActive, isFav, isHold,
showUsd, showHoldBtn, usdRate, showUsd, showHoldBtn, usdRate,
onSelect, onToggleFav, onToggleHold, onRemoveHold, onSelect, onToggleFav, onToggleHold, onRemoveHold,
}: RowProps) { }: RowProps) {
@@ -116,7 +119,7 @@ const MarketRow = memo(function MarketRow({
<div className="mp-name"> <div className="mp-name">
<span className="mp-korean-row"> <span className="mp-korean-row">
<ChangeArrow change={change} /> <ChangeArrow change={change} />
<span className="mp-korean">{ticker?.koreanName ?? code}</span> <span className="mp-korean">{koreanName}</span>
</span> </span>
<span className="mp-code">{code}/KRW</span> <span className="mp-code">{code}/KRW</span>
</div> </div>
@@ -151,7 +154,7 @@ const MarketRow = memo(function MarketRow({
// ─── 메인 패널 ───────────────────────────────────────────────────────────── // ─── 메인 패널 ─────────────────────────────────────────────────────────────
export const MarketPanel: React.FC<MarketPanelProps> = ({ 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 [tab, setTab] = useState<Tab>('krw');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -214,14 +217,27 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
setShowHoldAdd(false); 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 allKrwMarkets = useMemo(() => {
const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-')); const keys = marketInfos.length > 0
// 24h 거래대금 내림차순 정렬 ? marketInfos.map(m => m.market)
: Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
return keys.sort((a, b) => return keys.sort((a, b) =>
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0) (tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
); );
}, [tickers]); }, [marketInfos, tickers]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let base: string[] = []; let base: string[] = [];
@@ -234,7 +250,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
const q = search.toUpperCase(); const q = search.toUpperCase();
base = base.filter(m => { base = base.filter(m => {
const code = m.replace(/^KRW-/, ''); const code = m.replace(/^KRW-/, '');
const name = tickers.get(m)?.koreanName ?? ''; const name = resolveKoreanName(m);
return code.includes(q) || name.includes(search); return code.includes(q) || name.includes(search);
}); });
} }
@@ -245,8 +261,8 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
const tb = tickers.get(b); const tb = tickers.get(b);
switch (sortKey) { switch (sortKey) {
case 'name': { case 'name': {
const na = ta?.koreanName ?? a.replace(/^KRW-/, ''); const na = resolveKoreanName(a);
const nb = tb?.koreanName ?? b.replace(/^KRW-/, ''); const nb = resolveKoreanName(b);
return mul * na.localeCompare(nb, 'ko'); return mul * na.localeCompare(nb, 'ko');
} }
case 'price': case 'price':
@@ -257,7 +273,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
return 0; return 0;
} }
}); });
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]); }, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir, resolveKoreanName]);
// 보유 탭 "추가" 검색 결과 // 보유 탭 "추가" 검색 결과
const holdAddResults = useMemo(() => { const holdAddResults = useMemo(() => {
@@ -266,11 +282,11 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
return allKrwMarkets return allKrwMarkets
.filter(m => { .filter(m => {
const code = m.replace(/^KRW-/, ''); const code = m.replace(/^KRW-/, '');
const name = tickers.get(m)?.koreanName ?? ''; const name = resolveKoreanName(m);
return code.includes(q) || name.includes(holdSearch); return code.includes(q) || name.includes(holdSearch);
}) })
.slice(0, 10); .slice(0, 10);
}, [holdSearch, allKrwMarkets, tickers]); }, [holdSearch, allKrwMarkets, resolveKoreanName]);
const tabLabels: Record<Tab, string> = { const tabLabels: Record<Tab, string> = {
krw: '원화', btc: 'BTC', hold: '보유', fav: '관심', krw: '원화', btc: 'BTC', hold: '보유', fav: '관심',
@@ -352,7 +368,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
<div className="mp-hold-add-dropdown"> <div className="mp-hold-add-dropdown">
{holdAddResults.map(m => ( {holdAddResults.map(m => (
<div key={m} className="mp-hold-add-item" onClick={() => handleAddHolding(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> <span className="mp-hold-add-code">{m.replace(/^KRW-/, '')}</span>
</div> </div>
))} ))}
@@ -388,6 +404,7 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
<MarketRow <MarketRow
key={market} key={market}
market={market} market={market}
koreanName={resolveKoreanName(market)}
ticker={tickers.get(market)} ticker={tickers.get(market)}
isActive={market === activeSymbol} isActive={market === activeSymbol}
isFav={favorites.includes(market)} isFav={favorites.includes(market)}
+65 -9
View File
@@ -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 { setMarketNames } from '../utils/marketNameCache';
import { import {
getFavorites, getFavorites,
@@ -45,6 +45,9 @@ function formatChangeRate(raw: number | string | null | undefined): number | nul
return rate * 100; return rate * 100;
} }
type SortKey = 'name' | 'price' | 'change' | 'volume';
type SortDir = 'asc' | 'desc';
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
interface MarketSearchPanelProps { interface MarketSearchPanelProps {
currentMarket: string; currentMarket: string;
@@ -94,6 +97,8 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
}, [controlledQuery, onQueryChange]); }, [controlledQuery, onQueryChange]);
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites())); const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('desc');
const refreshFavs = useCallback(() => { const refreshFavs = useCallback(() => {
setFavs(new Set(getFavorites())); setFavs(new Set(getFavorites()));
@@ -215,6 +220,32 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
tryAddMarket(market); tryAddMarket(market);
}, [tryAddMarket]); }, [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. 필터 + 정렬 ─────────────────────────────────────────────────────── // ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
const q = query.trim(); const q = query.trim();
const filtered = markets.filter(m => { const filtered = markets.filter(m => {
@@ -227,15 +258,42 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
); );
}); });
const sorted = [...filtered].sort((a, b) => { const sorted = useMemo(() => {
if (!sortKey) {
return [...filtered].sort((a, b) => {
const fa = favs.has(a.market) ? 1 : 0; const fa = favs.has(a.market) ? 1 : 0;
const fb = favs.has(b.market) ? 1 : 0; const fb = favs.has(b.market) ? 1 : 0;
if (fb !== fa) return fb - fa; if (fb !== fa) return fb - fa;
// 즐겨찾기 동순위: 24h 거래대금 내림차순
const va = tickers[a.market]?.acc_trade_price_24h ?? 0; const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0; const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
return vb - va; 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 ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
// anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다. // anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다.
@@ -362,14 +420,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
{!isCompactDropdown && ( {!isCompactDropdown && (
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span> <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 && ( {!embedded && (
<> <>
<span className="msp-col msp-col-price"></span> {renderSortHeader('price', '현재가', 'msp-col-price')}
<span className="msp-col msp-col-change"></span> {renderSortHeader('change', '전일대비', 'msp-col-change')}
{!isCompactDropdown && ( {!isCompactDropdown && renderSortHeader('volume', '거래대금', 'msp-col-vol')}
<span className="msp-col msp-col-vol"></span>
)}
</> </>
)} )}
</div> </div>
+251 -25
View File
@@ -83,6 +83,12 @@ import {
simpleTemplateToNode, simpleTemplateToNode,
type StrategyTemplateDef, type StrategyTemplateDef,
} from '../utils/strategyPresets'; } from '../utils/strategyPresets';
import {
addUserStrategyTemplate,
deleteUserStrategyTemplate,
loadUserStrategyTemplates,
type UserStrategyTemplate,
} from '../utils/strategyTemplateStorage';
import { import {
buildStrategyExportPayload, buildStrategyExportPayload,
buildStrategyListExportPayload, buildStrategyListExportPayload,
@@ -193,6 +199,12 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary')); const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite')); const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [paletteSearch, setPaletteSearch] = useState(''); 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 [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
const [stratName, setStratName] = useState(''); const [stratName, setStratName] = useState('');
const [stratDesc, setStratDesc] = useState(''); const [stratDesc, setStratDesc] = useState('');
@@ -1027,6 +1039,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); 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) => { const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
if (editorMode === 'graph') layoutFlushRef.current?.(); if (editorMode === 'graph') layoutFlushRef.current?.();
@@ -1047,6 +1093,93 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]); }, [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 () => { const handleQuickRun = useCallback(async () => {
if (!selectedId) { if (!selectedId) {
window.alert('백테스팅할 전략을 먼저 선택해주세요.'); window.alert('백테스팅할 전략을 먼저 선택해주세요.');
@@ -1216,23 +1349,6 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div> </div>
<div className="se-header-actions"> <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 <button
type="button" type="button"
className="se-btn se-btn--ghost se-btn--icon se-btn--desc" 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>
<button type="button" className="se-btn se-btn--gold" onClick={() => { setSaveNameError(false); setSaveOpen(true); }}></button>
</div> </div>
</header> </header>
@@ -1383,6 +1498,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
onChange={handleStartCombineOpChange} 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> </div>
{editorMode === 'graph' ? ( {editorMode === 'graph' ? (
@@ -1616,17 +1765,62 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</> </>
)} )}
{rightTab === 'templates' && ( {rightTab === 'templates' && (
<>
<input
className="se-palette-search"
placeholder="템플릿 검색 (RSI, MA, 매수…)"
value={templateSearch}
onChange={e => setTemplateSearch(e.target.value)}
/>
<div className="se-template-list"> <div className="se-template-list">
{templates.map((t, i) => ( {filteredTemplateRows.length === 0 ? (
<button key={i} type="button" className="se-template-item" onClick={() => handleTemplate(t)}> <div className="se-template-empty"> </div>
<span className="se-template-label">{t.label}</span> ) : (
{'description' in t && t.description && ( filteredTemplateRows.map(row => (
<span className="se-template-desc">{t.description}</span> <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--${t.signal}`}>{t.signal === 'buy' ? '매수' : '매도'}</span> <span className={`se-template-signal se-template-signal--${row.signal}`}>
{row.signal === 'buy' ? '매수' : '매도'}
</span>
</button> </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> </div>
</div> </div>
@@ -1635,6 +1829,38 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div> </div>
</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 && ( {saveOpen && (
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장"> <DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
<div className="se-modal-body"> <div className="se-modal-body">
+83 -6
View File
@@ -186,6 +186,15 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--se-accent) 35%, transparent); 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 { .se-list-editor {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -712,18 +721,34 @@
.se-center-head { .se-center-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 8px 12px 0; padding: 8px 12px 0;
flex-shrink: 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 { .se-start-combine {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-left: auto; }
.se-center-head .se-start-combine {
margin-left: 0;
} }
.se-start-combine-label { .se-start-combine-label {
@@ -1877,15 +1902,27 @@
} }
.se-palette-sync { color: var(--se-success); font-size: 0.65rem; } .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 { .se-template-item {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 6px 8px; gap: 6px 8px;
width: 100%; flex: 1;
min-width: 0;
padding: 10px 12px; padding: 10px 12px;
margin-bottom: 6px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--se-border); border: 1px solid var(--se-border);
background: var(--se-palette-card-bg); background: var(--se-palette-card-bg);
@@ -1898,7 +1935,47 @@
border-color: var(--se-palette-hover-border); border-color: var(--se-palette-hover-border);
box-shadow: 0 0 12px color-mix(in srgb, var(--se-gold) 10%, transparent); 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 { .se-template-desc {
flex: 1 1 100%; flex: 1 1 100%;
font-size: 0.66rem; font-size: 0.66rem;
+2
View File
@@ -3,6 +3,7 @@ import type { PaletteItem } from '../utils/strategyPaletteStorage';
import type { IndicatorCustomTab } from '../utils/indicatorCustomTabsStorage'; import type { IndicatorCustomTab } from '../utils/indicatorCustomTabsStorage';
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage'; import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage'; import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage';
/** gc_app_settings.ui_preferences_json 구조 */ /** gc_app_settings.ui_preferences_json 구조 */
export interface UiPreferences { export interface UiPreferences {
@@ -12,6 +13,7 @@ export interface UiPreferences {
canvasInteraction?: string; canvasInteraction?: string;
paletteAuxiliary?: PaletteItem[]; paletteAuxiliary?: PaletteItem[];
paletteComposite?: PaletteItem[]; paletteComposite?: PaletteItem[];
userTemplates?: UserStrategyTemplate[];
}; };
indicator?: { indicator?: {
customTabs?: IndicatorCustomTab[]; 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;
}