추세검색 설정 추가

This commit is contained in:
Macbook
2026-05-27 01:36:06 +09:00
parent c1bcf88c6c
commit 2e08c6b16f
42 changed files with 1507 additions and 226 deletions
@@ -9,6 +9,7 @@ public final class MenuIds {
public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "virtual", "trend-search", "strategy", "strategy-editor", "backtest", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin"
"settings_strategy", "settings_paper", "settings_trend-search",
"settings_alert", "settings_network", "settings_admin"
);
}
@@ -50,4 +50,8 @@ public class LiveStrategySettingsDto {
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
private Boolean skipGlobalTemplate;
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 (JSON: isPinned) */
@JsonProperty("isPinned")
private Boolean pinned;
}
@@ -184,6 +184,11 @@ public class GcAppSettings {
@Builder.Default
private BigDecimal paperAutoTradeBudgetPct = BigDecimal.valueOf(95);
/** 가상매매 투자대상 목록 최대 종목 수 */
@Column(name = "virtual_target_max_count", nullable = false)
@Builder.Default
private Integer virtualTargetMaxCount = 20;
/** PAPER | LIVE | BOTH — 자동매매 실행 대상 */
@Column(name = "trading_mode", nullable = false, length = 10)
@Builder.Default
@@ -213,6 +218,11 @@ public class GcAppSettings {
@Builder.Default
private Boolean fcmPushEnabled = false;
/** 추세검색 기본 설정 JSON */
@Column(name = "trend_search_settings_json", columnDefinition = "JSON")
@JdbcTypeCode(SqlTypes.JSON)
private String trendSearchSettingsJson;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -56,6 +56,11 @@ public class GcLiveStrategySettings {
@Builder.Default
private String positionMode = "LONG_ONLY";
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 */
@Column(name = "is_pinned", nullable = false)
@Builder.Default
private Boolean isPinned = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -138,6 +138,10 @@ public class AppSettingsService {
Boolean.parseBoolean(d.get("paperAutoTradeEnabled").toString()));
if (d.containsKey("paperAutoTradeBudgetPct")) s.setPaperAutoTradeBudgetPct(
new java.math.BigDecimal(d.get("paperAutoTradeBudgetPct").toString()));
if (d.containsKey("virtualTargetMaxCount")) {
int max = Integer.parseInt(d.get("virtualTargetMaxCount").toString());
s.setVirtualTargetMaxCount(Math.min(100, Math.max(1, max)));
}
if (d.containsKey("tradingMode")) {
String mode = d.get("tradingMode").toString().toUpperCase();
s.setTradingMode("LIVE".equals(mode) || "BOTH".equals(mode) ? mode : "PAPER");
@@ -164,6 +168,9 @@ public class AppSettingsService {
new java.math.BigDecimal(d.get("liveAutoTradeBudgetPct").toString()));
if (d.containsKey("fcmPushEnabled")) s.setFcmPushEnabled(
Boolean.parseBoolean(d.get("fcmPushEnabled").toString()));
if (d.containsKey("trendSearchSettings")) {
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
}
}
private Map<String, Object> toMap(GcAppSettings s) {
@@ -202,6 +209,8 @@ public class AppSettingsService {
m.put("paperAutoTradeEnabled", s.getPaperAutoTradeEnabled() != null ? s.getPaperAutoTradeEnabled() : false);
m.put("paperAutoTradeBudgetPct", s.getPaperAutoTradeBudgetPct() != null
? s.getPaperAutoTradeBudgetPct().doubleValue() : 95);
m.put("virtualTargetMaxCount", s.getVirtualTargetMaxCount() != null
? s.getVirtualTargetMaxCount() : 20);
m.put("tradingMode", s.getTradingMode() != null ? s.getTradingMode() : "PAPER");
m.put("liveAutoTradeEnabled", s.getLiveAutoTradeEnabled() != null ? s.getLiveAutoTradeEnabled() : false);
UpbitApiCredentials creds = resolveUpbitCredentials(s);
@@ -211,6 +220,7 @@ public class AppSettingsService {
m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
return m;
}
@@ -224,6 +224,9 @@ public class LiveStrategySettingsService {
"REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE");
entity.setPositionMode(
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
if (dto.getPinned() != null) {
entity.setIsPinned(Boolean.TRUE.equals(dto.getPinned()));
}
repo.save(entity);
liveStrategyEvaluator.invalidateCache(market);
@@ -278,6 +281,7 @@ public class LiveStrategySettingsService {
.liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck()))
.executionType(e.getExecutionType())
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
.pinned(Boolean.TRUE.equals(e.getIsPinned()))
.build();
enrichStrategyTimeframes(dto);
return dto;
@@ -64,10 +64,11 @@ public class TrendSearchService {
int wantBars = wantBarsForScan(r);
int minBars = requiredMinBars(r);
List<String> markets = fetchKrwMarketsByVolume(scanLimit);
KrwMarketSlice slice = fetchKrwMarketsByVolumeSlice(scanLimit);
List<String> markets = slice.markets();
if (markets.isEmpty()) return List.of();
Map<String, TickerSnap> tickers = fetchTickers(markets);
Map<String, TickerSnap> tickers = fetchTickers(markets, slice.koreanNames());
int minMatchRate = Math.min(100, Math.max(0, r.getMinMatchRate()));
int enabledFilters = countEnabledFilters(r);
boolean useBullishScore = r.isBullishTrendEnabled();
@@ -84,7 +85,8 @@ public class TrendSearchService {
BarSeries series = toSeries(candles, tf);
if (series.getBarCount() < minBars) continue;
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
TickerSnap tick = tickers.getOrDefault(market,
TickerSnap.empty(market, slice.koreanNames().get(market)));
MetricValues m = computeMetrics(series, candles, tick, r);
if (useBullishScore) {
m.bullishTrendScore = computeBullishTrendScore(m, r);
@@ -133,8 +135,9 @@ public class TrendSearchService {
.market(market).matchRate(0).conditions(List.of()).build();
}
Map<String, TickerSnap> tickers = fetchTickers(List.of(market));
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
Map<String, String> koreanNames = fetchKoreanNamesForMarkets(List.of(market));
Map<String, TickerSnap> tickers = fetchTickers(List.of(market), koreanNames);
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market, koreanNames.get(market)));
BarSeries series = toSeries(candles, tf);
MetricValues m = computeMetrics(series, candles, tick, r);
if (r.isBullishTrendEnabled()) {
@@ -675,7 +678,37 @@ public class TrendSearchService {
// ── Upbit helpers ───────────────────────────────────────────────────────
private List<String> fetchKrwMarketsByVolume(int limit) {
private record KrwMarketSlice(List<String> markets, Map<String, String> koreanNames) {}
/** 거래대금 상위 KRW 마켓 + 한글명 (market/all 기준) */
private KrwMarketSlice fetchKrwMarketsByVolumeSlice(int limit) {
try {
Map<String, String> allNames = fetchKoreanNamesFromMarketAll();
if (allNames.isEmpty()) return new KrwMarketSlice(List.of(), Map.of());
List<String> krw = new ArrayList<>(allNames.keySet());
Map<String, TickerSnap> tickers = fetchTickers(krw, allNames);
List<String> sorted = krw.stream()
.sorted(Comparator.comparingDouble((String m) ->
tickers.getOrDefault(m, TickerSnap.empty(m, allNames.get(m))).accTradePrice24h).reversed())
.limit(limit)
.collect(Collectors.toList());
Map<String, String> names = new HashMap<>();
for (String m : sorted) {
names.put(m, allNames.getOrDefault(m, m.replace("KRW-", "")));
}
return new KrwMarketSlice(sorted, names);
} catch (Exception e) {
log.warn("[TrendSearch] market fetch fail: {}", e.getMessage());
List<String> fallback = List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA");
Map<String, String> names = new HashMap<>();
for (String m : fallback) names.put(m, m.replace("KRW-", ""));
return new KrwMarketSlice(fallback, names);
}
}
private Map<String, String> fetchKoreanNamesFromMarketAll() {
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
String marketsJson = client.get()
@@ -683,29 +716,36 @@ public class TrendSearchService {
.retrieve()
.bodyToMono(String.class)
.block();
if (marketsJson == null) return List.of();
if (marketsJson == null) return Map.of();
List<Map<String, Object>> all = objectMapper.readValue(marketsJson, new TypeReference<>() {});
List<String> krw = all.stream()
.map(m -> (String) m.get("market"))
.filter(m -> m != null && m.startsWith("KRW-"))
.collect(Collectors.toList());
if (krw.isEmpty()) return List.of();
Map<String, TickerSnap> tickers = fetchTickers(krw);
return krw.stream()
.sorted(Comparator.comparingDouble((String m) ->
tickers.getOrDefault(m, TickerSnap.empty(m)).accTradePrice24h).reversed())
.limit(limit)
.collect(Collectors.toList());
Map<String, String> names = new HashMap<>();
for (Map<String, Object> m : all) {
String market = (String) m.get("market");
if (market == null || !market.startsWith("KRW-")) continue;
Object kn = m.get("korean_name");
if (kn != null && !kn.toString().isBlank()) {
names.put(market, kn.toString());
}
}
return names;
} catch (Exception e) {
log.warn("[TrendSearch] market fetch fail: {}", e.getMessage());
return List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA");
log.warn("[TrendSearch] korean names fetch fail: {}", e.getMessage());
return Map.of();
}
}
private Map<String, TickerSnap> fetchTickers(List<String> markets) {
private Map<String, String> fetchKoreanNamesForMarkets(List<String> markets) {
if (markets.isEmpty()) return Map.of();
Map<String, String> all = fetchKoreanNamesFromMarketAll();
Map<String, String> out = new HashMap<>();
for (String m : markets) {
out.put(m, all.getOrDefault(m, m.replace("KRW-", "")));
}
return out;
}
private Map<String, TickerSnap> fetchTickers(List<String> markets, Map<String, String> koreanNames) {
if (markets.isEmpty()) return Map.of();
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
@@ -722,9 +762,12 @@ public class TrendSearchService {
JsonNode arr = objectMapper.readTree(json);
for (JsonNode n : arr) {
String market = n.path("market").asText();
String ko = koreanNames != null
? koreanNames.getOrDefault(market, market.replace("KRW-", ""))
: market.replace("KRW-", "");
map.put(market, new TickerSnap(
market,
n.path("korean_name").asText(market),
ko,
n.path("trade_price").asDouble(0),
n.path("signed_change_rate").asDouble(0) * 100,
n.path("acc_trade_price_24h").asDouble(0)
@@ -777,8 +820,11 @@ public class TrendSearchService {
private record TickerSnap(String market, String koreanName, double tradePrice,
double changeRate, double accTradePrice24h) {
static TickerSnap empty(String market) {
return new TickerSnap(market, market.replace("KRW-", ""), 0, 0, 0);
static TickerSnap empty(String market, String koreanName) {
String ko = koreanName != null && !koreanName.isBlank()
? koreanName
: market.replace("KRW-", "");
return new TickerSnap(market, ko, 0, 0, 0);
}
}
@@ -0,0 +1,5 @@
-- 가상투자 투자대상 고정(삭제 방지)
ALTER TABLE gc_live_strategy_settings
ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0
COMMENT '투자대상 고정 ON(1)=목록에서 삭제 불가'
AFTER position_mode;
@@ -0,0 +1,5 @@
-- 가상매매 투자대상 목록 최대 개수
ALTER TABLE gc_app_settings
ADD COLUMN virtual_target_max_count INT NOT NULL DEFAULT 20
COMMENT '가상매매 투자대상 목록 최대 종목 수'
AFTER paper_auto_trade_budget_pct;
@@ -0,0 +1,5 @@
-- 추세검색 기본 설정 (배점·합격점·결과수·자동갱신·자동추가 등)
ALTER TABLE gc_app_settings
ADD COLUMN trend_search_settings_json JSON NULL
COMMENT '추세검색 화면 기본 설정 JSON'
AFTER virtual_target_max_count;
@@ -0,0 +1,6 @@
-- 설정 · 추세검색 메뉴 권한
INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES
('ADMIN', 'settings_trend-search', 1),
('USER', 'settings_trend-search', 1),
('GUEST', 'settings_trend-search', 0)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed);
+8
View File
@@ -52,6 +52,8 @@ import {
splitIndicatorPane,
} from './utils/indicatorPaneMerge';
import { useAppSettings } from './hooks/useAppSettings';
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
import MarketPanel from './components/MarketPanel';
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
import StrategyPage from './components/StrategyPage';
@@ -1723,6 +1725,12 @@ function App() {
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })}
virtualTargetMaxCount={appDefaults.virtualTargetMaxCount}
onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })}
trendSearchSettings={appDefaults.trendSearchSettings}
onTrendSearchSettingsChange={s => saveAppDef({
trendSearchSettings: resolveTrendSearchAppSettings(s),
})}
onPaperAccountReset={async () => {
await resetPaperAccount();
await refreshPaperAccount();
+15 -5
View File
@@ -65,12 +65,14 @@ interface MarketSearchPanelProps {
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
/** 이미 추가된 종목 (add 모드에서 + 비활성) */
addedMarkets?: Set<string>;
/** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */
maxTargetsReached?: boolean;
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
variant = 'overlay', query: controlledQuery, onQueryChange,
actionMode = 'favorite', onAdd, addedMarkets,
actionMode = 'favorite', onAdd, addedMarkets, maxTargetsReached = false,
}) => {
const embedded = variant === 'embedded';
const [markets, setMarkets] = useState<MarketInfo[]>([]);
@@ -186,6 +188,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
if (embedded) onClose();
return;
}
if (maxTargetsReached) return;
const row = markets.find(m => m.market === market);
onAdd?.(market, {
koreanName: row?.korean_name ?? market,
@@ -196,7 +199,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
}
onSelect(market);
if (!embedded) onClose();
}, [actionMode, onSelect, onAdd, addedMarkets, markets, embedded, onClose]);
}, [actionMode, onSelect, onAdd, addedMarkets, maxTargetsReached, markets, embedded, onClose]);
const handleAddClick = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
@@ -374,6 +377,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
const tk = tickers[m.market];
const isFav = favs.has(m.market);
const isAdded = addedMarkets?.has(m.market) ?? false;
const addBlocked = maxTargetsReached && !isAdded;
const isActive = m.market === currentMarket;
const rate = tk ? formatChangeRate(tk.signed_change_rate) : null;
const isUp = rate !== null && rate >= 0;
@@ -393,10 +397,16 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
actionMode === 'add' ? (
<button
type="button"
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
onClick={e => handleAddClick(m.market, e)}
disabled={isAdded}
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
disabled={isAdded || addBlocked}
title={
isAdded
? '이미 추가됨'
: addBlocked
? '투자대상 최대 개수에 도달했습니다'
: '투자대상에 추가'
}
>
{isAdded ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
+51 -3
View File
@@ -27,6 +27,8 @@ import {
normalizeTradeAlertPopupPosition,
} from '../utils/tradeAlertPopupLayout';
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
import {
CHART_LEGEND_SETTING_ITEMS,
type ChartLegendVisibility,
@@ -114,6 +116,8 @@ interface SettingsPageProps {
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void;
tradingMode?: string;
onTradingMode?: (v: string) => void;
@@ -132,6 +136,8 @@ interface SettingsPageProps {
displayTimezone?: string;
onDisplayTimezoneChange?: (tz: string) => void;
menuPermissions?: Record<string, boolean>;
trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
}
// ── 카테고리 정의 ────────────────────────────────────────────────────────────
@@ -199,6 +205,13 @@ const IcPaper = () => (
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
</svg>
);
const IcTrendSearch = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,17 9,11 13,14 18,6"/>
<line x1="4" y1="19" x2="18" y2="19"/>
<circle cx="18" cy="6" r="2" fill="currentColor" stroke="none"/>
</svg>
);
const IcBacktest = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,17 6,11 9,14 13,7 16,10 20,4"/>
@@ -223,7 +236,8 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
{ id: 'paper', label: '가상투자', icon: <IcPaper />, desc: '가상 자본, 수수료, 자동매매 ON/OFF 등 가상투자 설정' },
{ id: 'paper', label: '가상매매', icon: <IcPaper />, desc: '가상 자본, 수수료, 투자대상 한도, 자동매매 ON/OFF 등 가상매매 설정' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
@@ -292,6 +306,8 @@ interface PaperPanelProps {
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void;
tradingMode?: string;
onTradingMode?: (v: string) => void;
@@ -319,6 +335,8 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
tradingMode = 'PAPER',
onTradingMode,
@@ -344,13 +362,27 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
</select>
</SettingRow>
</SettingSection>
<SettingSection title="가상투자 사용">
<SettingRow label="가상투자 활성화" desc="켜면 가상투자·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
<SettingSection title="가상매매">
<SettingRow label="가상매매 활성화" desc="켜면 가상매매·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
<label className="stg-toggle">
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
<SettingSection title="자동매매">
<SettingRow
@@ -1398,6 +1430,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
tradingMode = 'PAPER',
onTradingMode,
@@ -1416,6 +1450,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
displayTimezone = 'Asia/Seoul',
onDisplayTimezoneChange,
menuPermissions,
trendSearchSettings,
onTrendSearchSettingsChange,
}) => {
const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>('general');
@@ -1510,6 +1546,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset}
tradingMode={tradingMode}
onTradingMode={onTradingMode}
@@ -1522,6 +1560,16 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onLiveAutoTradeBudgetPct={onLiveAutoTradeBudgetPct}
/>
);
case 'trend-search': return (
trendSearchSettings && onTrendSearchSettingsChange ? (
<TrendSearchSettingsPanel
settings={trendSearchSettings}
onChange={onTrendSearchSettingsChange}
/>
) : (
<p className="stg-ind-unavailable"> .</p>
)
);
case 'strategy': return (
<StrategyPanel
liveMarket={liveMarket}
+109 -31
View File
@@ -18,10 +18,19 @@ import {
type TrendSearchResultDto,
} from '../utils/trendSearchApi';
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry';
import { useAppSettings } from '../hooks/useAppSettings';
import {
extractTrendSearchAppSettings,
mergeTrendSearchRequest,
resolveTrendSearchAppSettings,
type TrendSearchAppSettings,
} from '../utils/trendSearchAppSettings';
import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css';
const REFRESH_MS = 3000;
const DISPLAY_MODE_KEY = 'tsd-display-mode';
function loadDisplayMode(): TrendSearchDisplayMode {
@@ -45,7 +54,16 @@ const TrendSearchPage: React.FC<Props> = ({
chartSeriesPriceLabels = true,
tickers,
}) => {
const [filters, setFilters] = useState<TrendSearchRequest>(() => ({ ...DEFAULT_TREND_SEARCH_REQUEST }));
const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings();
const [pageSettings, setPageSettings] = useState<TrendSearchAppSettings>(() =>
resolveTrendSearchAppSettings(),
);
const [filters, setFilters] = useState<TrendSearchRequest>(() =>
mergeTrendSearchRequest(
{ ...DEFAULT_TREND_SEARCH_REQUEST },
resolveTrendSearchAppSettings(),
),
);
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
@@ -55,21 +73,50 @@ const TrendSearchPage: React.FC<Props> = ({
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
const filtersRef = useRef(filters);
filtersRef.current = filters;
const pageSettingsRef = useRef(pageSettings);
pageSettingsRef.current = pageSettings;
const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry();
useEffect(() => {
if (!appSettingsLoaded) return;
const resolved = defaults.trendSearchSettings;
setPageSettings(resolved);
setFilters(prev => mergeTrendSearchRequest(prev, resolved));
}, [appSettingsLoaded, defaults.trendSearchSettings]);
const persistPageSettings = useCallback((patch: Partial<TrendSearchAppSettings>) => {
const next = resolveTrendSearchAppSettings({ ...pageSettingsRef.current, ...patch });
setPageSettings(next);
pageSettingsRef.current = next;
saveAppSettings({ trendSearchSettings: next });
}, [saveAppSettings]);
const handleFiltersChange = useCallback((next: TrendSearchRequest) => {
setFilters(next);
const extracted = extractTrendSearchAppSettings(next);
persistPageSettings(extracted);
}, [persistPageSettings]);
const runSearch = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setSearching(true);
try {
const list = await scanTrendSearch(filtersRef.current);
setResults(sortTrendSearchByStrength(list));
const sorted = sortTrendSearchByStrength(list);
setResults(sorted);
setLastUpdatedAt(Date.now());
setSelectedMarket(prev => {
if (prev && list.some(r => r.market === prev)) return prev;
return list[0]?.market ?? null;
if (prev && sorted.some(r => r.market === prev)) return prev;
return sorted[0]?.market ?? null;
});
if (list.length > 0) {
setFlashMarkets(new Set(list.slice(0, 5).map(r => r.market)));
if (sorted.length > 0) {
setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market)));
setTimeout(() => setFlashMarkets(new Set()), 600);
}
await autoAddTrendSearchTargets(
sorted,
pageSettingsRef.current,
filtersRef.current.timeframe,
);
} catch (e) {
console.warn('[TrendSearch]', e);
if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
@@ -78,13 +125,17 @@ const TrendSearchPage: React.FC<Props> = ({
}
}, []);
useEffect(() => { void runSearch(); }, []);
useEffect(() => {
if (!appSettingsLoaded) return;
void runSearch();
}, [appSettingsLoaded, runSearch]);
useEffect(() => {
if (!autoRefresh) return;
const id = window.setInterval(() => void runSearch({ silent: true }), REFRESH_MS);
const ms = Math.max(1, pageSettings.autoRefreshSeconds) * 1000;
const id = window.setInterval(() => void runSearch({ silent: true }), ms);
return () => clearInterval(id);
}, [autoRefresh, runSearch]);
}, [autoRefresh, pageSettings.autoRefreshSeconds, runSearch]);
useEffect(() => {
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
@@ -102,19 +153,54 @@ const TrendSearchPage: React.FC<Props> = ({
} catch { /* keep row */ }
}, [filters.timeframe]);
const handleAddTarget = useCallback((row: TrendSearchResultDto) => {
const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName);
void add(row.market, {
koreanName,
englishName,
candleType: filtersRef.current.timeframe,
});
}, [add]);
const handleRemoveTarget = useCallback((market: string) => {
void remove(market);
}, [remove]);
const handleAutoAddToggle = useCallback((enabled: boolean) => {
persistPageSettings({ autoAddTargetsEnabled: enabled });
if (enabled && results.length > 0) {
void autoAddTrendSearchTargets(results, { ...pageSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe);
}
}, [persistPageSettings, results]);
const handleRefreshSecondsChange = useCallback((sec: number) => {
persistPageSettings({ autoRefreshSeconds: sec });
}, [persistPageSettings]);
return (
<>
<BuilderPageShell
theme={theme}
title="추세검색"
subtitle="Golden Analysis · Trend Search"
pageClassName="bps-page--tsd bps-page--vtd"
headerActions={(
<BuilderPageShell
theme={theme}
title="추세검색"
subtitle="Golden Analysis · Trend Search"
pageClassName="bps-page--tsd bps-page--vtd"
banner={searching ? (
<div className="tsd-search-loading" role="status" aria-live="polite" aria-busy="true">
<div className="tsd-search-loading-track" role="progressbar" aria-label="추세검색 스캔 중">
<div className="tsd-search-loading-fill" />
</div>
<span className="tsd-search-loading-label"> </span>
</div>
) : undefined}
headerActions={(
<TrendSearchViewHeaderControls
displayMode={displayMode}
onDisplayModeChange={setDisplayMode}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
refreshSeconds={pageSettings.autoRefreshSeconds}
onRefreshSecondsChange={handleRefreshSecondsChange}
autoAddTargets={pageSettings.autoAddTargetsEnabled}
onAutoAddTargetsChange={handleAutoAddToggle}
searching={searching}
onRefresh={() => void runSearch()}
resultCount={results.length}
@@ -127,7 +213,7 @@ const TrendSearchPage: React.FC<Props> = ({
left={(
<TrendSearchFilterPanel
filters={filters}
onChange={setFilters}
onChange={handleFiltersChange}
onSearch={() => void runSearch()}
searching={searching}
/>
@@ -145,22 +231,14 @@ const TrendSearchPage: React.FC<Props> = ({
chartSeriesPriceLabels={chartSeriesPriceLabels}
tickers={tickers}
lastUpdatedAt={lastUpdatedAt}
isInTargets={isInTargets}
isPinned={isPinned}
onAddTarget={handleAddTarget}
onRemoveTarget={handleRemoveTarget}
targetBusyMarket={busyMarket}
/>
)}
/>
{searching && (
<div className="tsd-search-overlay" role="status" aria-live="polite" aria-busy="true">
<div className="tsd-search-overlay-card">
<div className="loading-spinner tsd-search-spinner" />
<p className="tsd-search-overlay-text"> </p>
<div className="tsd-search-progress" aria-hidden="true">
<div className="tsd-search-progress-indeterminate" />
</div>
</div>
</div>
)}
</>
);
};
+45 -1
View File
@@ -50,6 +50,8 @@ import {
syncVirtualTargetsToBackend,
stopVirtualLiveOnBackend,
} from '../utils/virtualLiveStrategySync';
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook' | 'history';
@@ -95,6 +97,7 @@ const VirtualTradingPage: React.FC<Props> = ({
?? 'BACKEND_STOMP') as ChartRealtimeSource;
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
useEffect(() => {
void Promise.all([
@@ -104,6 +107,29 @@ const VirtualTradingPage: React.FC<Props> = ({
]).finally(() => setLoading(false));
}, []);
/** DB is_pinned → localStorage 투자대상 목록 동기화 (최초 1회) */
useEffect(() => {
let cancelled = false;
const initial = loadVirtualTargets();
if (initial.length === 0) return;
void (async () => {
const merged = await Promise.all(
initial.map(async t => {
try {
const s = await loadLiveStrategySettings(t.market);
const pinned = !!s.isPinned;
if (!!t.pinned === pinned) return t;
return { ...t, pinned };
} catch {
return t;
}
}),
);
if (!cancelled) setTargets(merged);
})();
return () => { cancelled = true; };
}, []);
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
useEffect(() => { saveVirtualSession(session); }, [session]);
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
@@ -185,11 +211,27 @@ const VirtualTradingPage: React.FC<Props> = ({
}, [tickers]);
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
if (items.length > virtualTargetMaxCount) {
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
return;
}
setTargets(items);
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
handleSelectMarket(items[0].market);
}
}, [selectedMarket, handleSelectMarket]);
}, [selectedMarket, handleSelectMarket, virtualTargetMaxCount]);
const handleTogglePin = useCallback((market: string) => {
setTargets(prev => {
const item = prev.find(t => t.market === market);
if (!item) return prev;
const pinned = !item.pinned;
void persistVirtualTargetPinned(market, pinned).catch(() => {
window.alert('고정 설정 저장에 실패했습니다.');
});
return prev.map(t => (t.market === market ? { ...t, pinned } : t));
});
}, []);
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
const next = { ...session, globalStrategyId: strategyId };
@@ -386,6 +428,8 @@ const VirtualTradingPage: React.FC<Props> = ({
onTargetsChange={handleTargetsChange}
onSelectMarket={handleSelectMarket}
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
onTogglePin={handleTogglePin}
virtualTargetMaxCount={virtualTargetMaxCount}
/>
)}
center={(
@@ -0,0 +1,93 @@
import React, { useMemo } from 'react';
import {
BULLISH_WEIGHT_ITEMS,
WEIGHT_STEP,
adjustBullishWeight,
snapToWeightStep,
sumWeights,
weightFromSliderPointer,
type BullishWeightKey,
} from '../../utils/trendSearchBullishWeights';
export type BullishWeightsRecord = Record<BullishWeightKey, number>;
interface Props {
weights: BullishWeightsRecord;
onChange: (next: BullishWeightsRecord) => void;
/** 설정 화면용 컴팩트 레이아웃 */
variant?: 'panel' | 'settings';
}
const TrendSearchBullishWeightsSection: React.FC<Props> = ({
weights,
onChange,
variant = 'panel',
}) => {
const weightTotal = useMemo(() => sumWeights(Object.values(weights)), [weights]);
const handleWeightChange = (key: BullishWeightKey, value: number) => {
onChange(adjustBullishWeight(weights, key, snapToWeightStep(value)));
};
const handleSliderPointer = (
e: React.PointerEvent<HTMLInputElement>,
key: BullishWeightKey,
) => {
const rect = e.currentTarget.getBoundingClientRect();
handleWeightChange(key, weightFromSliderPointer(e.clientX, rect));
};
const cardCls = variant === 'settings' ? 'tsd-bullish-card tsd-bullish-card--settings' : 'tsd-bullish-card';
return (
<div className={cardCls}>
<div className="tsd-bullish-card-head">
<div>
<span className="tsd-bullish-card-en">Bullish Trend Score</span>
<span className="tsd-bullish-card-ko"> </span>
</div>
<span className="tsd-bullish-card-total">{weightTotal}</span>
</div>
<div className="tsd-bullish-items">
{BULLISH_WEIGHT_ITEMS.map(item => {
const value = weights[item.key];
return (
<div key={item.key} className="tsd-bullish-item">
<div className="tsd-bullish-item-head">
<span className="tsd-bullish-item-label">{item.label}</span>
<span className="tsd-bullish-item-desc" title={item.desc}>{item.desc}</span>
</div>
<div className="tsd-bullish-item-controls">
<input
type="range"
className="tsd-slider tsd-bullish-slider"
min={0}
max={100}
step={WEIGHT_STEP}
value={value}
onPointerDown={e => handleSliderPointer(e, item.key)}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점`}
/>
<input
type="number"
className="tsd-num tsd-bullish-score"
min={0}
max={100}
step={WEIGHT_STEP}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
onBlur={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점 입력`}
/>
<span className="tsd-bullish-score-unit"></span>
</div>
</div>
);
})}
</div>
</div>
);
};
export default TrendSearchBullishWeightsSection;
@@ -55,7 +55,7 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
</div>
</div>
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
<VirtualIndicatorCompareTable metrics={metrics} />
<VirtualIndicatorCompareTable metrics={metrics} showStrategyCondition={false} />
</div>
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {updatedLabel}</span>
@@ -1,12 +1,8 @@
import React, { useMemo } from 'react';
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
import {
BULLISH_WEIGHT_ITEMS,
adjustBullishWeight,
sumWeights,
type BullishWeightKey,
} from '../../utils/trendSearchBullishWeights';
import { sumWeights } from '../../utils/trendSearchBullishWeights';
import TrendSearchBullishWeightsSection from './TrendSearchBullishWeightsSection';
interface Props {
filters: TrendSearchRequest;
@@ -43,11 +39,6 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
const weightTotal = sumWeights(Object.values(weights));
const handleWeightChange = (key: BullishWeightKey, value: number) => {
const next = adjustBullishWeight(weights, key, value);
patch(next);
};
return (
<div className="tsd-filter-panel">
<div className="tsd-panel-head">
@@ -60,51 +51,10 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
</div>
</div>
<div className="tsd-bullish-card">
<div className="tsd-bullish-card-head">
<div>
<span className="tsd-bullish-card-en">Bullish Trend Score</span>
<span className="tsd-bullish-card-ko"> </span>
</div>
<span className="tsd-bullish-card-total">{weightTotal}</span>
</div>
<div className="tsd-bullish-items">
{BULLISH_WEIGHT_ITEMS.map(item => {
const value = weights[item.key];
return (
<div key={item.key} className="tsd-bullish-item">
<div className="tsd-bullish-item-head">
<span className="tsd-bullish-item-label">{item.label}</span>
<span className="tsd-bullish-item-desc" title={item.desc}>{item.desc}</span>
</div>
<div className="tsd-bullish-item-controls">
<input
type="range"
className="tsd-slider tsd-bullish-slider"
min={0}
max={100}
step={1}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점`}
/>
<input
type="number"
className="tsd-num tsd-bullish-score"
min={0}
max={100}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점 입력`}
/>
<span className="tsd-bullish-score-unit"></span>
</div>
</div>
);
})}
</div>
</div>
<TrendSearchBullishWeightsSection
weights={weights}
onChange={w => patch(w)}
/>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
@@ -9,6 +9,7 @@ import VirtualTargetQuote from '../virtual/VirtualTargetQuote';
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
import TrendSearchCardChart from './TrendSearchCardChart';
import TrendSearchTargetActions from './TrendSearchTargetActions';
export type TrendSearchDisplayMode = 'summary' | 'chart';
@@ -24,6 +25,11 @@ interface Props {
ticker?: TickerData;
updatedAt?: number;
flash?: boolean;
inTargets?: boolean;
targetPinned?: boolean;
targetBusy?: boolean;
onAddTarget?: () => void;
onRemoveTarget?: () => void;
}
function resultToTicker(result: TrendSearchResultDto): TickerData {
@@ -55,6 +61,11 @@ const TrendSearchResultCard: React.FC<Props> = ({
ticker,
updatedAt,
flash = false,
inTargets = false,
targetPinned = false,
targetBusy = false,
onAddTarget,
onRemoveTarget,
}) => {
const en = result.market.replace(/^KRW-/, '');
const quoteTicker = ticker ?? resultToTicker(result);
@@ -97,6 +108,18 @@ const TrendSearchResultCard: React.FC<Props> = ({
<span className="tsd-card-score">{result.matchRate}</span>
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
</div>
{(onAddTarget || onRemoveTarget) && (
<div className="vtd-card-head-actions">
<TrendSearchTargetActions
market={result.market}
inTargets={inTargets}
targetPinned={targetPinned}
busy={targetBusy}
onAdd={() => onAddTarget?.()}
onRemove={() => onRemoveTarget?.()}
/>
</div>
)}
</div>
<div className="vtd-card-meta">
@@ -19,6 +19,11 @@ interface Props {
chartSeriesPriceLabels?: boolean;
tickers?: Map<string, TickerData>;
lastUpdatedAt?: number;
isInTargets?: (market: string) => boolean;
isPinned?: (market: string) => boolean;
onAddTarget?: (row: TrendSearchResultDto) => void;
onRemoveTarget?: (market: string) => void;
targetBusyMarket?: string | null;
}
const TrendSearchResultsCardGrid: React.FC<Props> = ({
@@ -34,6 +39,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
chartSeriesPriceLabels = true,
tickers,
lastUpdatedAt,
isInTargets,
isPinned,
onAddTarget,
onRemoveTarget,
targetBusyMarket = null,
}) => {
const sortedResults = useMemo(
() => sortTrendSearchByStrength(results),
@@ -77,6 +87,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
ticker={tickers?.get(row.market)}
updatedAt={lastUpdatedAt}
flash={flashMarkets?.has(row.market)}
inTargets={isInTargets?.(row.market) ?? false}
targetPinned={isPinned?.(row.market) ?? false}
targetBusy={targetBusyMarket === row.market}
onAddTarget={onAddTarget ? () => onAddTarget(row) : undefined}
onRemoveTarget={onRemoveTarget ? () => onRemoveTarget(row.market) : undefined}
/>
))}
</div>
@@ -0,0 +1,154 @@
import React, { useMemo } from 'react';
import type { TrendSearchAppSettings } from '../../utils/trendSearchAppSettings';
import {
TREND_SEARCH_REFRESH_OPTIONS,
formatTrendRefreshLabel,
} from '../../utils/trendSearchAppSettings';
import TrendSearchBullishWeightsSection from './TrendSearchBullishWeightsSection';
interface Props {
settings: TrendSearchAppSettings;
onChange: (next: TrendSearchAppSettings) => void;
}
const SettingRow: React.FC<{
label: string;
desc?: string;
children: React.ReactNode;
}> = ({ label, desc, children }) => (
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title">{label}</span>
{desc && <span className="stg-row-desc">{desc}</span>}
</div>
<div className="stg-row-ctrl">{children}</div>
</div>
);
const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = ({
title, children,
}) => (
<div className="stg-section">
<div className="stg-section-title">{title}</div>
<div className="stg-section-body">{children}</div>
</div>
);
const TrendSearchSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
const patch = (p: Partial<TrendSearchAppSettings>) => onChange({ ...settings, ...p });
const weights = useMemo(() => ({
weightMaAlignment: settings.weightMaAlignment,
weightMaSlope: settings.weightMaSlope,
weightAdxTrend: settings.weightAdxTrend,
weightMacdMomentum: settings.weightMacdMomentum,
weightPricePosition: settings.weightPricePosition,
}), [settings]);
return (
<div className="tsd-settings-panel">
<SettingSection title="상승추세 검색그룹 배점">
<TrendSearchBullishWeightsSection
variant="settings"
weights={weights}
onChange={w => patch(w)}
/>
</SettingSection>
<SettingSection title="검색 조건">
<SettingRow label="최소 합격 점수" desc="이 점수 이상인 종목만 결과에 포함됩니다.">
<input
type="number"
className="stg-input stg-input--num"
min={0}
max={100}
step={5}
value={settings.minTrendScore}
onChange={e => patch({ minTrendScore: Number(e.target.value) })}
/>
</SettingRow>
<SettingRow label="결과 목록 최대 개수" desc="스캔 후 화면에 표시할 최대 종목 수입니다.">
<input
type="number"
className="stg-input stg-input--num"
min={5}
max={50}
value={settings.limit}
onChange={e => patch({ limit: Number(e.target.value) })}
/>
</SettingRow>
<SettingRow label="스캔 대상 종목 수" desc="거래대금 상위 N종목을 평가합니다.">
<input
type="number"
className="stg-input stg-input--num"
min={20}
max={120}
value={settings.scanLimit}
onChange={e => patch({ scanLimit: Number(e.target.value) })}
/>
</SettingRow>
</SettingSection>
<SettingSection title="자동 갱신">
<SettingRow label="기본 갱신 간격" desc="추세검색 화면 자동 갱신 드롭다운의 기본값입니다.">
<select
className="stg-select"
value={settings.autoRefreshSeconds}
onChange={e => patch({ autoRefreshSeconds: Number(e.target.value) })}
>
{TREND_SEARCH_REFRESH_OPTIONS.map(sec => (
<option key={sec} value={sec}>{formatTrendRefreshLabel(sec)}</option>
))}
</select>
</SettingRow>
</SettingSection>
<SettingSection title="투자대상 자동추가">
<SettingRow
label="자동추가 사용"
desc="추세검색 갱신 후 상위 순위 종목을 가상매매 투자대상에 자동 등록합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={settings.autoAddTargetsEnabled}
onChange={e => patch({ autoAddTargetsEnabled: e.target.checked })}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="자동추가 순위 (상위 N위)"
desc="추세점수 순위 1위부터 N위까지 자동추가 후보입니다. 투자대상 최대 개수를 초과하면 추가하지 않습니다."
>
<input
type="number"
className="stg-input stg-input--num"
min={1}
max={50}
disabled={!settings.autoAddTargetsEnabled}
value={settings.autoAddTopRank}
onChange={e => patch({ autoAddTopRank: Number(e.target.value) })}
/>
</SettingRow>
<SettingRow
label="자동추가 최소 점수"
desc="상위 순위 안에서도 추세점수가 이 값 이상인 종목만 투자대상에 자동추가합니다."
>
<input
type="number"
className="stg-input stg-input--num"
min={0}
max={100}
step={5}
disabled={!settings.autoAddTargetsEnabled}
value={settings.autoAddMinScore}
onChange={e => patch({ autoAddMinScore: Number(e.target.value) })}
/>
</SettingRow>
</SettingSection>
</div>
);
};
export default TrendSearchSettingsPanel;
@@ -0,0 +1,62 @@
import React from 'react';
interface Props {
market: string;
inTargets: boolean;
targetPinned?: boolean;
busy?: boolean;
onAdd: () => void;
onRemove: () => void;
}
const TrendSearchTargetActions: React.FC<Props> = ({
market,
inTargets,
targetPinned = false,
busy = false,
onAdd,
onRemove,
}) => (
<div
className="tsd-card-target-actions"
role="group"
aria-label={`${market} 투자대상`}
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
<button
type="button"
className="tsd-card-target-btn tsd-card-target-btn--add"
title={inTargets ? '이미 투자대상에 등록됨' : '가상매매 투자대상에 추가'}
aria-label="투자대상 추가"
disabled={inTargets || busy}
onClick={onAdd}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="7" y1="2" x2="7" y2="12" />
<line x1="2" y1="7" x2="12" y2="7" />
</svg>
</button>
<button
type="button"
className="tsd-card-target-btn tsd-card-target-btn--remove"
title={
!inTargets
? '투자대상에 없음'
: targetPinned
? '고정된 종목은 삭제할 수 없습니다'
: '투자대상에서 제거'
}
aria-label="투자대상 제거"
disabled={!inTargets || targetPinned || busy}
onClick={onRemove}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="3" y1="3" x2="11" y2="11" />
<line x1="11" y1="3" x2="3" y2="11" />
</svg>
</button>
</div>
);
export default TrendSearchTargetActions;
@@ -1,11 +1,19 @@
import React from 'react';
import type { TrendSearchDisplayMode } from './TrendSearchResultCard';
import {
TREND_SEARCH_REFRESH_OPTIONS,
formatTrendRefreshLabel,
} from '../../utils/trendSearchAppSettings';
interface Props {
displayMode: TrendSearchDisplayMode;
onDisplayModeChange: (mode: TrendSearchDisplayMode) => void;
autoRefresh: boolean;
onAutoRefreshChange: (v: boolean) => void;
refreshSeconds: number;
onRefreshSecondsChange: (sec: number) => void;
autoAddTargets: boolean;
onAutoAddTargetsChange: (v: boolean) => void;
searching?: boolean;
onRefresh: () => void;
resultCount?: number;
@@ -16,6 +24,10 @@ const TrendSearchViewHeaderControls: React.FC<Props> = ({
onDisplayModeChange,
autoRefresh,
onAutoRefreshChange,
refreshSeconds,
onRefreshSecondsChange,
autoAddTargets,
onAutoAddTargetsChange,
searching,
onRefresh,
resultCount = 0,
@@ -42,10 +54,34 @@ const TrendSearchViewHeaderControls: React.FC<Props> = ({
<span className="vtd-grid-head-divider" aria-hidden="true" />
<span className="tsd-result-count">{resultCount}</span>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<label className="tsd-auto-refresh">
<input type="checkbox" checked={autoRefresh} onChange={e => onAutoRefreshChange(e.target.checked)} />
<span>3 </span>
<label className="tsd-auto-add-toggle" title="검색 결과 상위 순위를 가상매매 투자대상에 자동 등록">
<input
type="checkbox"
checked={autoAddTargets}
onChange={e => onAutoAddTargetsChange(e.target.checked)}
/>
<span> </span>
</label>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<label className="tsd-auto-refresh">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => onAutoRefreshChange(e.target.checked)}
/>
<span> </span>
</label>
<select
className="tsd-refresh-interval-select"
value={refreshSeconds}
disabled={!autoRefresh}
aria-label="자동 갱신 간격"
onChange={e => onRefreshSecondsChange(Number(e.target.value))}
>
{TREND_SEARCH_REFRESH_OPTIONS.map(sec => (
<option key={sec} value={sec}>{formatTrendRefreshLabel(sec)}</option>
))}
</select>
<button type="button" className="tsd-refresh-btn" disabled={searching} onClick={onRefresh}>
</button>
@@ -7,16 +7,21 @@ import ConditionStatusSignal from './ConditionStatusSignal';
interface Props {
metrics: ConditionMetric[];
/** false — 전략 조건 칼럼 숨김 (추세검색 등) */
showStrategyCondition?: boolean;
}
const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
const VirtualIndicatorCompareTable: React.FC<Props> = ({
metrics,
showStrategyCondition = true,
}) => (
<div className="vtd-sig-table-wrap">
<div className="vtd-sig-table-head"> </div>
<table className="vtd-sig-table">
<table className={`vtd-sig-table${showStrategyCondition ? '' : ' vtd-sig-table--no-threshold'}`}>
<colgroup>
<col className="vtd-sig-col vtd-sig-col--ind" />
<col className="vtd-sig-col vtd-sig-col--val" />
<col className="vtd-sig-col vtd-sig-col--threshold" />
{showStrategyCondition && <col className="vtd-sig-col vtd-sig-col--threshold" />}
<col className="vtd-sig-col vtd-sig-col--progress" />
<col className="vtd-sig-col vtd-sig-col--status" />
</colgroup>
@@ -24,7 +29,9 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
<tr>
<th className="vtd-sig-table-th--ind"> </th>
<th className="vtd-sig-table-th--val"></th>
<th className="vtd-sig-table-th--threshold"> </th>
{showStrategyCondition && (
<th className="vtd-sig-table-th--threshold"> </th>
)}
<th className="vtd-sig-table-th--progress"></th>
<th className="vtd-sig-table-th--status"></th>
</tr>
@@ -37,10 +44,12 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
<tr key={row.id}>
<td className="vtd-sig-table-ind">{row.displayName}</td>
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
<td className="vtd-sig-table-threshold">
{row.thresholdLabel
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
</td>
{showStrategyCondition && (
<td className="vtd-sig-table-threshold">
{row.thresholdLabel
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
</td>
)}
<td className="vtd-sig-table-progress">
<div className="vtd-sig-row-bar vtd-sig-row-bar--heat">
<div
@@ -1,10 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MarketSearchPanel } from '../MarketSearchPanel';
import { getKoreanName } from '../../utils/marketNameCache';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
import type { StrategyDto } from '../../utils/backendApi';
import type { TickerData } from '../../hooks/useMarketTicker';
import VirtualTargetQuote from './VirtualTargetQuote';
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
import {
isVirtualTargetAddAllowed,
virtualTargetLimitMessage,
} from '../../utils/virtualTargetLimits';
interface Props {
targets: VirtualTargetItem[];
@@ -15,6 +19,8 @@ interface Props {
onTargetsChange: (items: VirtualTargetItem[]) => void;
onSelectMarket: (market: string) => void;
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
onTogglePin?: (market: string) => void;
virtualTargetMaxCount?: number;
}
const VirtualLeftTargetPanel: React.FC<Props> = ({
@@ -26,12 +32,15 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
onTargetsChange,
onSelectMarket,
onTargetStrategyChange,
onTogglePin,
virtualTargetMaxCount = 20,
}) => {
const [query, setQuery] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
const atTargetLimit = !isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount);
const closeDropdown = useCallback(() => {
setShowDropdown(false);
@@ -54,21 +63,28 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => {
if (addedSet.has(market)) return;
if (!isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount)) {
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
return;
}
const names = resolveVirtualTargetNames(market, meta.koreanName, meta.englishName);
onTargetsChange([
...targets,
{
market,
strategyId: globalStrategyId,
koreanName: meta.koreanName,
englishName: meta.englishName,
koreanName: names.koreanName,
englishName: names.englishName,
},
]);
onSelectMarket(market);
setQuery('');
setShowDropdown(false);
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket]);
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket, virtualTargetMaxCount]);
const handleRemove = useCallback((market: string) => {
const item = targets.find(t => t.market === market);
if (item?.pinned) return;
onTargetsChange(targets.filter(t => t.market !== market));
}, [targets, onTargetsChange]);
@@ -96,6 +112,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
onQueryChange={setQuery}
actionMode="add"
addedMarkets={addedSet}
maxTargetsReached={atTargetLimit}
onAdd={handleAdd}
onSelect={onSelectMarket}
onClose={closeDropdown}
@@ -107,7 +124,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
<section className="vtd-target-section">
<div className="vtd-target-list-head">
<span></span>
<span className="vtd-target-count">{targets.length}</span>
<span className="vtd-target-count">{targets.length}/{virtualTargetMaxCount}</span>
</div>
<div className="vtd-target-list">
@@ -115,13 +132,20 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
<p className="vtd-muted"> + .</p>
)}
{targets.map(item => {
const ko = item.koreanName ?? getKoreanName(item.market);
const en = item.englishName ?? item.market.replace(/^KRW-/, '');
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
item.market,
item.koreanName,
item.englishName,
);
const active = item.market === selectedMarket;
return (
<div
key={item.market}
className={`vtd-target-item${active ? ' vtd-target-item--active' : ''}`}
className={[
'vtd-target-item',
active ? 'vtd-target-item--active' : '',
item.pinned ? 'vtd-target-item--pinned' : '',
].filter(Boolean).join(' ')}
onClick={() => onSelectMarket(item.market)}
>
<div className="vtd-target-item-main">
@@ -129,14 +153,30 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
<span className="vtd-target-ko">{ko}</span>
<span className="vtd-target-en">{en}</span>
</div>
<button
type="button"
className="vtd-target-remove"
title="목록에서 제거"
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
>
</button>
<div className="vtd-target-item-actions">
<button
type="button"
className={`vtd-target-pin${item.pinned ? ' vtd-target-pin--on' : ''}`}
title={item.pinned ? '고정 해제' : '투자대상 고정'}
aria-label={item.pinned ? '고정 해제' : '투자대상 고정'}
aria-pressed={!!item.pinned}
onClick={e => { e.stopPropagation(); onTogglePin?.(item.market); }}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden>
<path d="M6 0C4.5 0 3.5 1.2 3.5 2.5c0 .6.2 1.1.5 1.5L2 7v1h8V7L8 4c.3-.4.5-.9.5-1.5C8.5 1.2 7.5 0 6 0zm0 10.5a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5z" />
</svg>
</button>
<button
type="button"
className="vtd-target-remove"
title={item.pinned ? '고정된 종목은 삭제할 수 없습니다' : '목록에서 제거'}
aria-label="목록에서 제거"
disabled={!!item.pinned}
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
>
</button>
</div>
</div>
<VirtualTargetQuote
market={item.market}
@@ -15,20 +15,25 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
);
const segments = useMemo(() => {
const items: Array<{ lit: boolean; tone: 'blue' | 'gold' | 'peak' }> = [];
const items: Array<{ lit: boolean; tone: 'red' | 'gold' | 'blue' | 'peak' }> = [];
const third = SEGMENTS / 3;
for (let i = 0; i < SEGMENTS; i++) {
const lit = i < litCount;
if (!lit) {
items.push({ lit: false, tone: 'blue' });
continue;
}
let tone: 'red' | 'gold' | 'blue' | 'peak';
if (rate >= 100 && i === SEGMENTS - 1) {
items.push({ lit: true, tone: 'peak' });
} else if (i >= Math.floor(SEGMENTS * 0.55)) {
items.push({ lit: true, tone: 'gold' });
tone = 'peak';
} else if (i < third) {
tone = 'red';
} else if (i < third * 2) {
tone = 'gold';
} else {
items.push({ lit: true, tone: 'blue' });
tone = 'blue';
}
items.push({ lit: true, tone });
}
return items;
}, [litCount, rate]);
@@ -15,9 +15,9 @@ const LABELS: Record<TrafficLightState, string> = {
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
<div className="vtd-sig-light vtd-sig-light--card-right">
<div className="vtd-sig-light-housing">
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--red${state === 'red' ? ' vtd-sig-light-bulb--on' : ''}`} />
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--yellow${state === 'yellow' ? ' vtd-sig-light-bulb--on' : ''}`} />
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--blue${state === 'blue' ? ' vtd-sig-light-bulb--on' : ''}`} />
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--yellow${state === 'yellow' ? ' vtd-sig-light-bulb--on' : ''}`} />
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--red${state === 'red' ? ' vtd-sig-light-bulb--on' : ''}`} />
</div>
<div className="vtd-sig-light-info vtd-sig-light-info--below">
<span className="vtd-sig-light-rate">{matchRate}%</span>
+18
View File
@@ -37,6 +37,14 @@ import type { ChartLegendVisibility } from '../types/chartLegend';
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
import {
clampVirtualTargetMax,
DEFAULT_VIRTUAL_TARGET_MAX,
} from '../utils/virtualTargetLimits';
import {
resolveTrendSearchAppSettings,
type TrendSearchAppSettings,
} from '../utils/trendSearchAppSettings';
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
let _cache: AppSettingsDto | null = null;
@@ -96,6 +104,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95,
virtualTargetMaxCount: clampVirtualTargetMax(s.virtualTargetMaxCount ?? DEFAULT_VIRTUAL_TARGET_MAX),
tradingMode: s.tradingMode ?? 'PAPER',
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
hasUpbitKeys: s.hasUpbitKeys ?? false,
@@ -104,6 +113,9 @@ export function resolveAppDefaults(s: AppSettingsDto) {
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
fcmPushEnabled: s.fcmPushEnabled ?? false,
displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE),
trendSearchSettings: resolveTrendSearchAppSettings(
s.trendSearchSettings as Partial<TrendSearchAppSettings> | null | undefined,
),
};
}
@@ -158,3 +170,9 @@ export function useAppSettings(sessionKey = 0) {
return { settings, defaults, isLoaded, save };
}
/** 앱 설정 캐시 기준 투자대상 최대 개수 (훅 외부·추가 API 등) */
export function getVirtualTargetMaxCount(): number {
const n = _cache?.virtualTargetMaxCount;
return clampVirtualTargetMax(n ?? DEFAULT_VIRTUAL_TARGET_MAX);
}
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
loadVirtualTargets,
VIRTUAL_SESSION_CHANGED_EVENT,
} from '../utils/virtualTradingStorage';
import {
addVirtualTarget,
removeVirtualTarget,
type VirtualTargetMeta,
} from '../utils/virtualTargetMutations';
import { getVirtualTargetMaxCount } from './useAppSettings';
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
/** 가상투자 투자대상 마켓 집합 (추세검색 카드 등) */
export function useVirtualTargetRegistry() {
const [targets, setTargets] = useState(() => loadVirtualTargets());
const [busyMarket, setBusyMarket] = useState<string | null>(null);
const refresh = useCallback(() => {
setTargets(loadVirtualTargets());
}, []);
useEffect(() => {
refresh();
const onChange = () => refresh();
window.addEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onChange);
return () => window.removeEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onChange);
}, [refresh]);
const addedSet = useMemo(
() => new Set(targets.map(t => t.market)),
[targets],
);
const isInTargets = useCallback(
(market: string) => addedSet.has(market),
[addedSet],
);
const isPinned = useCallback(
(market: string) => targets.find(t => t.market === market)?.pinned === true,
[targets],
);
const add = useCallback(async (market: string, meta?: VirtualTargetMeta) => {
if (addedSet.has(market) || busyMarket === market) return;
const maxCount = getVirtualTargetMaxCount();
if (targets.length >= maxCount) {
window.alert(virtualTargetLimitMessage(maxCount));
return;
}
setBusyMarket(market);
try {
const next = await addVirtualTarget(market, meta);
setTargets(next);
} catch (e) {
console.warn('[VirtualTarget] add failed', market, e);
window.alert('투자대상 추가에 실패했습니다.');
} finally {
setBusyMarket(null);
}
}, [addedSet, busyMarket, targets]);
const remove = useCallback(async (market: string) => {
if (!addedSet.has(market) || busyMarket === market) return;
if (targets.find(t => t.market === market)?.pinned) {
window.alert('고정된 종목은 투자대상에서 삭제할 수 없습니다.');
return;
}
setBusyMarket(market);
try {
const next = await removeVirtualTarget(market);
setTargets(next);
} catch (e) {
console.warn('[VirtualTarget] remove failed', market, e);
window.alert('투자대상 제거에 실패했습니다.');
} finally {
setBusyMarket(null);
}
}, [addedSet, busyMarket, targets]);
return {
addedSet,
isInTargets,
isPinned,
add,
remove,
busyMarket,
};
}
+113 -39
View File
@@ -66,6 +66,48 @@
accent-color: var(--tsd-gold);
}
.tsd-auto-add-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--tsd-muted);
cursor: pointer;
white-space: nowrap;
}
.tsd-auto-add-toggle input {
accent-color: var(--tsd-gold);
}
.tsd-refresh-interval-select {
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--tsd-border);
background: var(--tsd-input-bg);
color: var(--tsd-text);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.tsd-refresh-interval-select:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tsd-settings-panel .tsd-bullish-card--settings {
margin-bottom: 0;
border: none;
background: transparent;
padding: 0;
}
.tsd-settings-panel .tsd-bullish-card--settings .tsd-bullish-card-head {
padding-left: 0;
padding-right: 0;
}
.tsd-refresh-btn {
padding: 6px 14px;
border-radius: 8px;
@@ -907,6 +949,55 @@
display: none !important;
}
.tsd-card-target-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.tsd-card-target-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border-radius: 8px;
border: 1px solid var(--se-border);
background: transparent;
color: var(--se-text-muted);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
}
.tsd-card-target-btn--add:not(:disabled) {
border-color: color-mix(in srgb, var(--tsd-match) 45%, var(--se-border));
background: color-mix(in srgb, var(--tsd-match) 10%, transparent);
color: var(--tsd-match);
}
.tsd-card-target-btn--add:not(:disabled):hover {
background: color-mix(in srgb, var(--tsd-match) 22%, transparent);
border-color: var(--tsd-match);
}
.tsd-card-target-btn--remove:not(:disabled) {
border-color: color-mix(in srgb, var(--tsd-mismatch) 45%, var(--se-border));
background: color-mix(in srgb, var(--tsd-mismatch) 10%, transparent);
color: var(--tsd-mismatch);
}
.tsd-card-target-btn--remove:not(:disabled):hover {
background: color-mix(in srgb, var(--tsd-mismatch) 22%, transparent);
border-color: var(--tsd-mismatch);
}
.tsd-card-target-btn:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.tsd-card-score {
font-size: 11px;
font-weight: 700;
@@ -951,55 +1042,38 @@
}
}
/* 검색 실행 — 화면 중앙 백드롭 프로그레스 */
.tsd-search-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
}
.tsd-search-overlay-card {
/* 검색 실행 — 상단 프로그레스바 로딩 */
.tsd-search-loading {
flex-shrink: 0;
margin: 0 12px 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 28px 40px;
min-width: 240px;
border-radius: 12px;
background: var(--se-bg-elevated, var(--bg2));
border: 1px solid var(--tsd-border);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
gap: 4px;
}
.tsd-search-spinner {
width: 36px;
height: 36px;
}
.tsd-search-overlay-text {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--tsd-text);
}
.tsd-search-progress {
width: 200px;
height: 4px;
.tsd-search-loading-track {
height: 3px;
border-radius: 2px;
background: var(--tsd-track-bg);
overflow: hidden;
position: relative;
}
.tsd-search-progress-indeterminate {
width: 45%;
.tsd-search-loading-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 38%;
border-radius: 2px;
background: linear-gradient(90deg, var(--tsd-gold), var(--se-accent, var(--accent)));
animation: tsd-search-progress-slide 1.1s ease-in-out infinite;
animation: tsd-search-loading-slide 1.15s ease-in-out infinite;
}
@keyframes tsd-search-progress-slide {
0% { transform: translateX(-120%); }
.tsd-search-loading-label {
font-size: 10px;
font-weight: 600;
color: var(--tsd-muted);
text-align: center;
}
@keyframes tsd-search-loading-slide {
0% { transform: translateX(-110%); }
100% { transform: translateX(320%); }
}
@@ -348,6 +348,10 @@
background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, var(--se-panel-card-bg));
}
.vtd-target-item--pinned {
border-color: color-mix(in srgb, var(--se-gold) 35%, var(--se-border));
}
.vtd-target-item-main {
display: flex;
align-items: flex-start;
@@ -452,6 +456,39 @@
animation: vtd-target-flash-fall 0.7s ease-out forwards;
}
.vtd-target-item-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.vtd-target-pin {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--se-border);
border-radius: 6px;
background: transparent;
color: var(--se-text-muted);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.vtd-target-pin:hover {
border-color: color-mix(in srgb, var(--tsd-gold, var(--se-gold)) 45%, var(--se-border));
color: var(--tsd-gold, var(--se-gold));
}
.vtd-target-pin--on {
border-color: color-mix(in srgb, var(--tsd-gold, var(--se-gold)) 55%, var(--se-border));
background: color-mix(in srgb, var(--tsd-gold, var(--se-gold)) 14%, transparent);
color: var(--tsd-gold, var(--se-gold));
}
.vtd-target-remove {
flex-shrink: 0;
width: 22px;
@@ -464,11 +501,16 @@
font-size: 12px;
}
.vtd-target-remove:hover {
.vtd-target-remove:hover:not(:disabled) {
background: color-mix(in srgb, var(--down, #ef5350) 15%, transparent);
color: var(--down, #ef5350);
}
.vtd-target-remove:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.vtd-target-strategy-field {
display: flex;
align-items: center;
@@ -1569,6 +1611,11 @@
transition: background 0.25s ease, box-shadow 0.25s ease;
}
.vtd-sig-eq-seg--lit.vtd-sig-eq-seg--red {
background: linear-gradient(90deg, #c62828, #ff8a80);
box-shadow: 0 0 6px color-mix(in srgb, #ef5350 50%, transparent);
}
.vtd-sig-eq-seg--lit.vtd-sig-eq-seg--blue {
background: linear-gradient(90deg, #1e6fd9, #4fc3f7);
box-shadow: 0 0 6px color-mix(in srgb, #4fc3f7 50%, transparent);
@@ -1992,6 +2039,11 @@
.vtd-sig-col--progress { width: 16%; }
.vtd-sig-col--status { width: 18%; min-width: 4.75rem; }
.vtd-sig-table--no-threshold .vtd-sig-col--ind { width: 38%; }
.vtd-sig-table--no-threshold .vtd-sig-col--val { width: 22%; }
.vtd-sig-table--no-threshold .vtd-sig-col--progress { width: 22%; }
.vtd-sig-table--no-threshold .vtd-sig-col--status { width: 18%; }
.vtd-sig-table th {
text-align: left;
padding: 6px 6px;
+6
View File
@@ -418,6 +418,8 @@ export interface AppSettingsDto {
paperMinOrderKrw?: number;
paperAutoTradeEnabled?: boolean;
paperAutoTradeBudgetPct?: number;
/** 가상매매 투자대상 목록 최대 종목 수 */
virtualTargetMaxCount?: number;
/** PAPER | LIVE | BOTH */
tradingMode?: string;
liveAutoTradeEnabled?: boolean;
@@ -431,6 +433,8 @@ export interface AppSettingsDto {
fcmPushEnabled?: boolean;
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
displayTimezone?: string;
/** 추세검색 기본 설정 */
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
}
export interface LiveSummaryDto {
@@ -1025,6 +1029,8 @@ export interface LiveStrategySettingsDto {
skipWatchlistSync?: boolean;
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
skipGlobalTemplate?: boolean;
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 */
isPinned?: boolean;
}
/** 실시간 전략 체크 설정 로드 */
+4 -3
View File
@@ -9,14 +9,14 @@ export type TopMenuId = MenuPage;
export type SettingsCategoryId =
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
| 'paper' | 'alert' | 'network' | 'admin';
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'alert', 'network', 'admin',
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'trend-search', 'alert', 'network', 'admin',
];
export const ALL_MENU_IDS = [
@@ -42,7 +42,8 @@ export const MENU_LABELS: Record<string, string> = {
settings_indicators: '설정 · 보조지표',
settings_backtest: '설정 · 백테스팅',
settings_strategy: '설정 · 전략',
settings_paper: '설정 · 가상투자',
settings_paper: '설정 · 가상매매',
'settings_trend-search': '설정 · 추세검색',
settings_alert: '설정 · 알림',
settings_network: '설정 · 네트워크',
settings_admin: '설정 · 관리자',
@@ -0,0 +1,128 @@
/**
* 추세검색 앱 설정 (gc_app_settings.trend_search_settings_json)
*/
import type { TrendSearchRequest } from './backendApi';
import { DEFAULT_TREND_SEARCH_REQUEST } from './backendApi';
import {
BULLISH_WEIGHT_ITEMS,
DEFAULT_BULLISH_WEIGHTS,
type BullishWeightKey,
} from './trendSearchBullishWeights';
export interface TrendSearchAppSettings {
weightMaAlignment: number;
weightMaSlope: number;
weightAdxTrend: number;
weightMacdMomentum: number;
weightPricePosition: number;
minTrendScore: number;
/** 결과 목록 최대 개수 */
limit: number;
/** 스캔 대상 종목 수 (거래대금 상위) */
scanLimit: number;
/** 자동 갱신 간격(초) */
autoRefreshSeconds: number;
/** 검색 후 상위 N위까지 투자대상 자동추가 */
autoAddTargetsEnabled: boolean;
autoAddTopRank: number;
/** 자동추가 시 추세점수(matchRate) 하한 */
autoAddMinScore: number;
}
export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
weightMaAlignment: DEFAULT_BULLISH_WEIGHTS.weightMaAlignment,
weightMaSlope: DEFAULT_BULLISH_WEIGHTS.weightMaSlope,
weightAdxTrend: DEFAULT_BULLISH_WEIGHTS.weightAdxTrend,
weightMacdMomentum: DEFAULT_BULLISH_WEIGHTS.weightMacdMomentum,
weightPricePosition: DEFAULT_BULLISH_WEIGHTS.weightPricePosition,
minTrendScore: DEFAULT_TREND_SEARCH_REQUEST.minTrendScore,
limit: DEFAULT_TREND_SEARCH_REQUEST.limit,
scanLimit: DEFAULT_TREND_SEARCH_REQUEST.scanLimit,
autoRefreshSeconds: 3,
autoAddTargetsEnabled: false,
autoAddTopRank: 5,
autoAddMinScore: DEFAULT_TREND_SEARCH_REQUEST.minTrendScore,
};
/** 자동 갱신 간격 선택지 (초) */
export const TREND_SEARCH_REFRESH_OPTIONS = [3, 5, 10, 30, 60] as const;
export function resolveTrendSearchAppSettings(
raw?: Partial<TrendSearchAppSettings> | null,
): TrendSearchAppSettings {
const d = DEFAULT_TREND_SEARCH_APP_SETTINGS;
if (!raw || typeof raw !== 'object') return { ...d };
const sec = Number(raw.autoRefreshSeconds);
const rank = Number(raw.autoAddTopRank);
const limit = Number(raw.limit);
const scanLimit = Number(raw.scanLimit);
return {
weightMaAlignment: clampWeight(raw.weightMaAlignment, d.weightMaAlignment),
weightMaSlope: clampWeight(raw.weightMaSlope, d.weightMaSlope),
weightAdxTrend: clampWeight(raw.weightAdxTrend, d.weightAdxTrend),
weightMacdMomentum: clampWeight(raw.weightMacdMomentum, d.weightMacdMomentum),
weightPricePosition: clampWeight(raw.weightPricePosition, d.weightPricePosition),
minTrendScore: clampInt(raw.minTrendScore, 0, 100, d.minTrendScore),
limit: clampInt(limit, 5, 50, d.limit),
scanLimit: clampInt(scanLimit, 20, 120, d.scanLimit),
autoRefreshSeconds: TREND_SEARCH_REFRESH_OPTIONS.includes(sec as typeof TREND_SEARCH_REFRESH_OPTIONS[number])
? sec
: d.autoRefreshSeconds,
autoAddTargetsEnabled: raw.autoAddTargetsEnabled === true,
autoAddTopRank: clampInt(rank, 1, 50, d.autoAddTopRank),
autoAddMinScore: clampInt(raw.autoAddMinScore, 0, 100, d.autoAddMinScore),
};
}
function clampWeight(v: unknown, fallback: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.max(0, Math.min(100, Math.round(n / 5) * 5));
}
function clampInt(v: unknown, min: number, max: number, fallback: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}
export function mergeTrendSearchRequest(
base: TrendSearchRequest,
settings: TrendSearchAppSettings,
): TrendSearchRequest {
return {
...base,
weightMaAlignment: settings.weightMaAlignment,
weightMaSlope: settings.weightMaSlope,
weightAdxTrend: settings.weightAdxTrend,
weightMacdMomentum: settings.weightMacdMomentum,
weightPricePosition: settings.weightPricePosition,
minTrendScore: settings.minTrendScore,
limit: settings.limit,
scanLimit: settings.scanLimit,
};
}
/** 검색 요청 → 앱 설정에 반영할 필드 */
export function extractTrendSearchAppSettings(
req: Pick<TrendSearchRequest, BullishWeightKey | 'minTrendScore' | 'limit' | 'scanLimit'>,
): Pick<
TrendSearchAppSettings,
BullishWeightKey | 'minTrendScore' | 'limit' | 'scanLimit'
> {
return {
weightMaAlignment: req.weightMaAlignment,
weightMaSlope: req.weightMaSlope,
weightAdxTrend: req.weightAdxTrend,
weightMacdMomentum: req.weightMacdMomentum,
weightPricePosition: req.weightPricePosition,
minTrendScore: req.minTrendScore,
limit: req.limit,
scanLimit: req.scanLimit,
};
}
export function formatTrendRefreshLabel(seconds: number): string {
if (seconds < 60) return `${seconds}`;
return `${seconds / 60}`;
}
@@ -0,0 +1,39 @@
import { sortTrendSearchByStrength } from './trendSearchMetrics';
import { resolveVirtualTargetNames } from './virtualTargetNames';
import type { TrendSearchResultDto } from './backendApi';
import type { TrendSearchAppSettings } from './trendSearchAppSettings';
import { addVirtualTarget } from './virtualTargetMutations';
import { loadVirtualTargets } from './virtualTradingStorage';
import { isVirtualTargetAddAllowed } from './virtualTargetLimits';
import { getVirtualTargetMaxCount } from '../hooks/useAppSettings';
/** 추세검색 결과 상위 순위 → 가상매매 투자대상 자동추가 */
export async function autoAddTrendSearchTargets(
results: TrendSearchResultDto[],
settings: Pick<TrendSearchAppSettings, 'autoAddTargetsEnabled' | 'autoAddTopRank' | 'autoAddMinScore'>,
timeframe: string,
): Promise<void> {
if (!settings.autoAddTargetsEnabled || results.length === 0) return;
const sorted = sortTrendSearchByStrength(results);
const topN = Math.max(1, Math.min(50, settings.autoAddTopRank));
const minScore = Math.max(0, Math.min(100, settings.autoAddMinScore));
const candidates = sorted
.slice(0, topN)
.filter(r => r.matchRate >= minScore);
let targets = loadVirtualTargets();
const maxCount = getVirtualTargetMaxCount();
for (const row of candidates) {
if (!isVirtualTargetAddAllowed(targets.length, maxCount)) break;
if (targets.some(t => t.market === row.market)) continue;
const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName);
targets = await addVirtualTarget(
row.market,
{ koreanName, englishName, candleType: timeframe },
{ showLimitAlert: false },
);
}
}
+41 -24
View File
@@ -55,6 +55,18 @@ export function bullishWeightsFromRequest(
return BULLISH_WEIGHT_ITEMS.map(item => req[item.key] ?? DEFAULT_BULLISH_WEIGHTS[item.key]);
}
export const WEIGHT_STEP = 5;
export function snapToWeightStep(value: number): number {
return Math.max(0, Math.min(100, Math.round(value / WEIGHT_STEP) * WEIGHT_STEP));
}
/** range 슬라이더 트랙 클릭 위치 → 5점 단위 배점 */
export function weightFromSliderPointer(clientX: number, rect: DOMRect): number {
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
return snapToWeightStep(ratio * 100);
}
export function sumWeights(weights: number[]): number {
return weights.reduce((a, b) => a + b, 0);
}
@@ -71,43 +83,48 @@ export function adjustBullishWeight(
const idx = keys.indexOf(changedKey);
if (idx < 0) return current;
const clamped = Math.max(0, Math.min(100, Math.round(newValue)));
const oldValue = current[changedKey];
const clamped = snapToWeightStep(newValue);
const oldValue = snapToWeightStep(current[changedKey]);
if (clamped === oldValue) return current;
const result: Record<BullishWeightKey, number> = { ...current };
for (const k of keys) result[k] = snapToWeightStep(result[k]);
result[changedKey] = clamped;
const delta = clamped - oldValue;
const otherKeys = keys.filter(k => k !== changedKey);
if (delta > 0) {
let remaining = delta;
for (let i = 0; i < otherKeys.length; i++) {
const k = otherKeys[i];
const take = i === otherKeys.length - 1
? remaining
: Math.floor(delta / otherKeys.length);
result[k] = Math.max(0, result[k] - take);
remaining -= take;
const distribute = (amount: number, mode: 'take' | 'give') => {
let remaining = amount;
let idx = 0;
let guard = 0;
while (remaining > 0 && guard < 200) {
const k = otherKeys[idx % otherKeys.length];
if (mode === 'take') {
const take = Math.min(WEIGHT_STEP, remaining, result[k]);
if (take > 0) {
result[k] -= take;
remaining -= take;
}
} else {
const give = Math.min(WEIGHT_STEP, remaining, 100 - result[k]);
if (give > 0) {
result[k] += give;
remaining -= give;
}
}
idx += 1;
guard += 1;
}
} else {
const add = Math.abs(delta);
let remaining = add;
for (let i = 0; i < otherKeys.length; i++) {
const k = otherKeys[i];
const give = i === otherKeys.length - 1
? remaining
: Math.floor(add / otherKeys.length);
result[k] += give;
remaining -= give;
}
}
};
if (delta > 0) distribute(delta, 'take');
else if (delta < 0) distribute(-delta, 'give');
const total = keys.reduce((s, k) => s + result[k], 0);
if (total !== 100) {
const fixKey = otherKeys[0] ?? changedKey;
result[fixKey] = Math.max(0, result[fixKey] + (100 - total));
result[fixKey] = snapToWeightStep(Math.max(0, Math.min(100, result[fixKey] + (100 - total))));
}
return result;
@@ -24,6 +24,7 @@ export async function syncVirtualTargetsToBackend(
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
candleType: normalizeStartCandleType(resolveTargetCandleType(t)),
isPinned: !!t.pinned,
...shared,
}),
),
+21
View File
@@ -0,0 +1,21 @@
/** 가상매매 투자대상 목록 최대 개수 (설정 · gc_app_settings.virtual_target_max_count) */
export const DEFAULT_VIRTUAL_TARGET_MAX = 20;
export const MIN_VIRTUAL_TARGET_MAX = 1;
export const MAX_VIRTUAL_TARGET_MAX = 100;
export function clampVirtualTargetMax(n: number): number {
if (!Number.isFinite(n)) return DEFAULT_VIRTUAL_TARGET_MAX;
return Math.min(MAX_VIRTUAL_TARGET_MAX, Math.max(MIN_VIRTUAL_TARGET_MAX, Math.floor(n)));
}
export function virtualTargetLimitMessage(max: number): string {
return `투자대상은 최대 ${max}종목까지 등록할 수 있습니다. 설정 › 가상매매에서 한도를 확인하세요.`;
}
export function isVirtualTargetAddAllowed(
currentCount: number,
maxCount: number = DEFAULT_VIRTUAL_TARGET_MAX,
): boolean {
return currentCount < clampVirtualTargetMax(maxCount);
}
@@ -0,0 +1,122 @@
import { saveLiveStrategySettings } from './backendApi';
import { getVirtualTargetMaxCount } from '../hooks/useAppSettings';
import {
isVirtualTargetAddAllowed,
virtualTargetLimitMessage,
} from '../utils/virtualTargetLimits';
import { normalizeStartCandleType } from './strategyStartNodes';
import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync';
import {
loadVirtualSession,
loadVirtualTargets,
notifyVirtualSessionChanged,
saveVirtualTargets,
type VirtualTargetItem,
} from './virtualTradingStorage';
export interface VirtualTargetMeta {
koreanName?: string;
englishName?: string;
/** 추세검색 캔들 주기 → 투자대상 평가 분봉 기본값 */
candleType?: string;
}
export interface AddVirtualTargetOptions {
/** false면 한도 초과 시 알림 없이 스킵 (자동추가용) */
showLimitAlert?: boolean;
}
/** 가상투자 투자대상 목록에 종목 추가 (localStorage + DB) */
export async function addVirtualTarget(
market: string,
meta: VirtualTargetMeta = {},
opts: AddVirtualTargetOptions = {},
): Promise<VirtualTargetItem[]> {
const showLimitAlert = opts.showLimitAlert !== false;
const targets = loadVirtualTargets();
if (targets.some(t => t.market === market)) return targets;
const maxCount = getVirtualTargetMaxCount();
if (!isVirtualTargetAddAllowed(targets.length, maxCount)) {
if (showLimitAlert) window.alert(virtualTargetLimitMessage(maxCount));
return targets;
}
const session = loadVirtualSession();
const en = meta.englishName ?? market.replace(/^KRW-/, '');
const item: VirtualTargetItem = {
market,
strategyId: session.globalStrategyId,
koreanName: meta.koreanName,
englishName: en,
candleType: meta.candleType ? normalizeStartCandleType(meta.candleType) : undefined,
};
const next = [...targets, item];
saveVirtualTargets(next);
await saveLiveStrategySettings({
market,
strategyId: session.globalStrategyId,
isLiveCheck: session.running,
isPinned: false,
executionType: session.executionType,
positionMode: session.positionMode,
candleType: item.candleType ?? '1m',
skipWatchlistSync: true,
skipGlobalTemplate: true,
});
if (session.running && session.globalStrategyId) {
await syncVirtualTargetsToBackend(next, session, true);
}
notifyVirtualSessionChanged();
return next;
}
/** 가상투자 투자대상 목록에서 종목 제거 (localStorage + DB 비활성화) */
/** 투자대상 고정 상태 DB 저장 */
export async function persistVirtualTargetPinned(market: string, pinned: boolean): Promise<void> {
const session = loadVirtualSession();
const item = loadVirtualTargets().find(t => t.market === market);
await saveLiveStrategySettings({
market,
strategyId: item?.strategyId ?? session.globalStrategyId,
isLiveCheck: session.running,
isPinned: pinned,
executionType: session.executionType,
positionMode: session.positionMode,
skipWatchlistSync: true,
skipGlobalTemplate: true,
});
}
export async function removeVirtualTarget(market: string): Promise<VirtualTargetItem[]> {
const targets = loadVirtualTargets();
const item = targets.find(t => t.market === market);
if (!item) return targets;
if (item.pinned) return targets;
const session = loadVirtualSession();
const next = targets.filter(t => t.market !== market);
saveVirtualTargets(next);
await saveLiveStrategySettings({
market,
strategyId: session.globalStrategyId,
isLiveCheck: false,
isPinned: false,
executionType: session.executionType,
positionMode: session.positionMode,
skipWatchlistSync: true,
skipGlobalTemplate: true,
});
if (session.running) {
await syncVirtualTargetsToBackend(next, session, true);
}
notifyVirtualSessionChanged();
return next;
}
+25
View File
@@ -0,0 +1,25 @@
import { getKoreanName } from './marketNameCache';
/** 백엔드/캐시에 마켓 코드가 한글명으로 들어온 경우 보정 */
export function isInvalidStoredKoreanName(market: string, name?: string | null): boolean {
if (!name || !name.trim()) return true;
const trimmed = name.trim();
if (trimmed === market) return true;
if (trimmed.startsWith('KRW-')) return true;
const sym = market.replace(/^KRW-/, '');
if (trimmed === sym) return true;
return false;
}
/** 투자대상 표시·저장용 한글명·영문 심볼 */
export function resolveVirtualTargetNames(
market: string,
koreanName?: string | null,
englishName?: string | null,
): { koreanName: string; englishName: string } {
const en = (englishName?.trim() || market.replace(/^KRW-/, '')).replace(/^KRW-/, '');
const ko = !isInvalidStoredKoreanName(market, koreanName)
? koreanName!.trim()
: getKoreanName(market);
return { koreanName: ko, englishName: en };
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { normalizeStartCandleType } from './strategyStartNodes';
import { resolveVirtualTargetNames } from './virtualTargetNames';
/** 가상투자 대상 종목·세션 설정 localStorage */
@@ -9,6 +10,8 @@ export interface VirtualTargetItem {
candleType?: string;
koreanName?: string;
englishName?: string;
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */
pinned?: boolean;
}
export interface VirtualSessionConfig {
@@ -31,7 +34,15 @@ export function loadVirtualTargets(): VirtualTargetItem[] {
const raw = localStorage.getItem(TARGETS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as VirtualTargetItem[];
return Array.isArray(parsed) ? parsed : [];
if (!Array.isArray(parsed)) return [];
return parsed.map(t => {
const { koreanName, englishName } = resolveVirtualTargetNames(
t.market,
t.koreanName,
t.englishName,
);
return { ...t, koreanName, englishName };
});
} catch {
return [];
}