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

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
+32 -15
View File
@@ -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)}
+72 -16
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 {
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>
+255 -29
View File
@@ -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">