전략편집기 템플릿 저장 기능 추가
This commit is contained in:
+41
-11
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user