diff --git a/frontend/src/App.css b/frontend/src/App.css index 123cfd8..23f6f50 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/chart/ChartWorkspaceView.tsx b/frontend/src/chart/ChartWorkspaceView.tsx index c8da318..39f4af0 100644 --- a/frontend/src/chart/ChartWorkspaceView.tsx +++ b/frontend/src/chart/ChartWorkspaceView.tsx @@ -107,6 +107,7 @@ export interface ChartWorkspaceViewProps { mobileRightOpen: boolean; mobileRightTab: 'trade' | 'orderbook'; marketTickers: Map; + 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) { 1 ? activeSlotSymbol : symbol} diff --git a/frontend/src/chart/useChartWorkspace.ts b/frontend/src/chart/useChartWorkspace.ts index 4c7cf9a..5c7c688 100644 --- a/frontend/src/chart/useChartWorkspace.ts +++ b/frontend/src/chart/useChartWorkspace.ts @@ -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, diff --git a/frontend/src/components/MarketPanel.tsx b/frontend/src/components/MarketPanel.tsx index 54a1d17..5ea01de 100644 --- a/frontend/src/components/MarketPanel.tsx +++ b/frontend/src/components/MarketPanel.tsx @@ -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; + /** 업비트 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({
- {ticker?.koreanName ?? code} + {koreanName} {code}/KRW
@@ -151,7 +154,7 @@ const MarketRow = memo(function MarketRow({ // ─── 메인 패널 ───────────────────────────────────────────────────────────── export const MarketPanel: React.FC = ({ - open, tickers, loading, usdRate, activeSymbol, onSymbolSelect, + open, tickers, marketInfos, loading, usdRate, activeSymbol, onSymbolSelect, }) => { const [tab, setTab] = useState('krw'); const [search, setSearch] = useState(''); @@ -214,14 +217,27 @@ export const MarketPanel: React.FC = ({ setShowHoldAdd(false); }, []); - // 탭별 마켓 목록 계산 + const koreanNameByMarket = useMemo(() => { + const map = new Map(); + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = { krw: '원화', btc: 'BTC', hold: '보유', fav: '관심', @@ -352,7 +368,7 @@ export const MarketPanel: React.FC = ({
{holdAddResults.map(m => (
handleAddHolding(m)}> - {tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')} + {resolveKoreanName(m)} {m.replace(/^KRW-/, '')}
))} @@ -388,6 +404,7 @@ export const MarketPanel: React.FC = ({ = ({ }, [controlledQuery, onQueryChange]); const [favs, setFavs] = useState>(() => new Set(getFavorites())); const [loading, setLoading] = useState(true); + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState('desc'); const refreshFavs = useCallback(() => { setFavs(new Set(getFavorites())); @@ -215,6 +220,32 @@ export const MarketSearchPanel: React.FC = ({ 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 ( + + ); + }; + // ── 4. 필터 + 정렬 ─────────────────────────────────────────────────────── const q = query.trim(); const filtered = markets.filter(m => { @@ -227,15 +258,42 @@ export const MarketSearchPanel: React.FC = ({ ); }); - 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 = ({ {!isCompactDropdown && ( {embedded ? '추가' : ''} )} - {isCompactDropdown ? '종목명' : '한글명'} + {renderSortHeader('name', isCompactDropdown ? '종목명' : '한글명', 'msp-col-name')} {!embedded && ( <> - 현재가 - 전일대비 - {!isCompactDropdown && ( - 거래대금 - )} + {renderSortHeader('price', '현재가', 'msp-col-price')} + {renderSortHeader('change', '전일대비', 'msp-col-change')} + {!isCompactDropdown && renderSortHeader('volume', '거래대금', 'msp-col-vol')} )}
diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index f4439c8..90c5c15 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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(() => loadPaletteItems('auxiliary')); const [compositePalette, setCompositePalette] = useState(() => loadPaletteItems('composite')); const [paletteSearch, setPaletteSearch] = useState(''); + const [templateSearch, setTemplateSearch] = useState(''); + const [userTemplates, setUserTemplates] = useState(() => loadUserStrategyTemplates()); + const [templateSaveOpen, setTemplateSaveOpen] = useState(false); + const [templateSaveLabel, setTemplateSaveLabel] = useState(''); + const [templateSaveDesc, setTemplateSaveDesc] = useState(''); + const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false); const [selectedPaletteKey, setSelectedPaletteKey] = useState(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
-
- - -
- -
@@ -1383,6 +1498,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop onChange={handleStartCombineOpChange} /> )} +
+ +
+ + +
+ + +
{editorMode === 'graph' ? ( @@ -1616,17 +1765,62 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop )} {rightTab === 'templates' && ( -
- {templates.map((t, i) => ( - - ))} -
+ <> + setTemplateSearch(e.target.value)} + /> +
+ {filteredTemplateRows.length === 0 ? ( +
검색 결과가 없습니다
+ ) : ( + filteredTemplateRows.map(row => ( +
+ + {row.source === 'user' && ( + + )} +
+ )) + )} +
+ )} @@ -1635,6 +1829,38 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop + {templateSaveOpen && ( + { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title="템플릿 저장"> +
+

+ 현재 {signalTab === 'buy' ? '매수' : '매도'} 탭 조건을 템플릿 목록에 저장합니다. +

+ + { setTemplateSaveLabel(e.target.value); setTemplateSaveLabelError(false); }} + placeholder="예: RSI 과매도 + MA 골든" + /> + {templateSaveLabelError && ( +

템플릿 이름을 입력하세요

+ )} + +