추세검색 설정 추가
This commit is contained in:
@@ -9,6 +9,7 @@ public final class MenuIds {
|
|||||||
public static final List<String> ALL = List.of(
|
public static final List<String> ALL = List.of(
|
||||||
"dashboard", "chart", "paper", "virtual", "trend-search", "strategy", "strategy-editor", "backtest", "notifications", "settings",
|
"dashboard", "chart", "paper", "virtual", "trend-search", "strategy", "strategy-editor", "backtest", "notifications", "settings",
|
||||||
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
|
"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 전역 템플릿 갱신 생략 */
|
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
|
||||||
private Boolean skipGlobalTemplate;
|
private Boolean skipGlobalTemplate;
|
||||||
|
|
||||||
|
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 (JSON: isPinned) */
|
||||||
|
@JsonProperty("isPinned")
|
||||||
|
private Boolean pinned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ public class GcAppSettings {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private BigDecimal paperAutoTradeBudgetPct = BigDecimal.valueOf(95);
|
private BigDecimal paperAutoTradeBudgetPct = BigDecimal.valueOf(95);
|
||||||
|
|
||||||
|
/** 가상매매 투자대상 목록 최대 종목 수 */
|
||||||
|
@Column(name = "virtual_target_max_count", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Integer virtualTargetMaxCount = 20;
|
||||||
|
|
||||||
/** PAPER | LIVE | BOTH — 자동매매 실행 대상 */
|
/** PAPER | LIVE | BOTH — 자동매매 실행 대상 */
|
||||||
@Column(name = "trading_mode", nullable = false, length = 10)
|
@Column(name = "trading_mode", nullable = false, length = 10)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@@ -213,6 +218,11 @@ public class GcAppSettings {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean fcmPushEnabled = false;
|
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)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ public class GcLiveStrategySettings {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private String positionMode = "LONG_ONLY";
|
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)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ public class AppSettingsService {
|
|||||||
Boolean.parseBoolean(d.get("paperAutoTradeEnabled").toString()));
|
Boolean.parseBoolean(d.get("paperAutoTradeEnabled").toString()));
|
||||||
if (d.containsKey("paperAutoTradeBudgetPct")) s.setPaperAutoTradeBudgetPct(
|
if (d.containsKey("paperAutoTradeBudgetPct")) s.setPaperAutoTradeBudgetPct(
|
||||||
new java.math.BigDecimal(d.get("paperAutoTradeBudgetPct").toString()));
|
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")) {
|
if (d.containsKey("tradingMode")) {
|
||||||
String mode = d.get("tradingMode").toString().toUpperCase();
|
String mode = d.get("tradingMode").toString().toUpperCase();
|
||||||
s.setTradingMode("LIVE".equals(mode) || "BOTH".equals(mode) ? mode : "PAPER");
|
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()));
|
new java.math.BigDecimal(d.get("liveAutoTradeBudgetPct").toString()));
|
||||||
if (d.containsKey("fcmPushEnabled")) s.setFcmPushEnabled(
|
if (d.containsKey("fcmPushEnabled")) s.setFcmPushEnabled(
|
||||||
Boolean.parseBoolean(d.get("fcmPushEnabled").toString()));
|
Boolean.parseBoolean(d.get("fcmPushEnabled").toString()));
|
||||||
|
if (d.containsKey("trendSearchSettings")) {
|
||||||
|
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> toMap(GcAppSettings s) {
|
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("paperAutoTradeEnabled", s.getPaperAutoTradeEnabled() != null ? s.getPaperAutoTradeEnabled() : false);
|
||||||
m.put("paperAutoTradeBudgetPct", s.getPaperAutoTradeBudgetPct() != null
|
m.put("paperAutoTradeBudgetPct", s.getPaperAutoTradeBudgetPct() != null
|
||||||
? s.getPaperAutoTradeBudgetPct().doubleValue() : 95);
|
? s.getPaperAutoTradeBudgetPct().doubleValue() : 95);
|
||||||
|
m.put("virtualTargetMaxCount", s.getVirtualTargetMaxCount() != null
|
||||||
|
? s.getVirtualTargetMaxCount() : 20);
|
||||||
m.put("tradingMode", s.getTradingMode() != null ? s.getTradingMode() : "PAPER");
|
m.put("tradingMode", s.getTradingMode() != null ? s.getTradingMode() : "PAPER");
|
||||||
m.put("liveAutoTradeEnabled", s.getLiveAutoTradeEnabled() != null ? s.getLiveAutoTradeEnabled() : false);
|
m.put("liveAutoTradeEnabled", s.getLiveAutoTradeEnabled() != null ? s.getLiveAutoTradeEnabled() : false);
|
||||||
UpbitApiCredentials creds = resolveUpbitCredentials(s);
|
UpbitApiCredentials creds = resolveUpbitCredentials(s);
|
||||||
@@ -211,6 +220,7 @@ public class AppSettingsService {
|
|||||||
m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null
|
m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null
|
||||||
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
||||||
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
|
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
|
||||||
|
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ public class LiveStrategySettingsService {
|
|||||||
"REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE");
|
"REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE");
|
||||||
entity.setPositionMode(
|
entity.setPositionMode(
|
||||||
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
|
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
|
||||||
|
if (dto.getPinned() != null) {
|
||||||
|
entity.setIsPinned(Boolean.TRUE.equals(dto.getPinned()));
|
||||||
|
}
|
||||||
|
|
||||||
repo.save(entity);
|
repo.save(entity);
|
||||||
liveStrategyEvaluator.invalidateCache(market);
|
liveStrategyEvaluator.invalidateCache(market);
|
||||||
@@ -278,6 +281,7 @@ public class LiveStrategySettingsService {
|
|||||||
.liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck()))
|
.liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck()))
|
||||||
.executionType(e.getExecutionType())
|
.executionType(e.getExecutionType())
|
||||||
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
|
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
|
||||||
|
.pinned(Boolean.TRUE.equals(e.getIsPinned()))
|
||||||
.build();
|
.build();
|
||||||
enrichStrategyTimeframes(dto);
|
enrichStrategyTimeframes(dto);
|
||||||
return dto;
|
return dto;
|
||||||
|
|||||||
@@ -64,10 +64,11 @@ public class TrendSearchService {
|
|||||||
int wantBars = wantBarsForScan(r);
|
int wantBars = wantBarsForScan(r);
|
||||||
int minBars = requiredMinBars(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();
|
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 minMatchRate = Math.min(100, Math.max(0, r.getMinMatchRate()));
|
||||||
int enabledFilters = countEnabledFilters(r);
|
int enabledFilters = countEnabledFilters(r);
|
||||||
boolean useBullishScore = r.isBullishTrendEnabled();
|
boolean useBullishScore = r.isBullishTrendEnabled();
|
||||||
@@ -84,7 +85,8 @@ public class TrendSearchService {
|
|||||||
BarSeries series = toSeries(candles, tf);
|
BarSeries series = toSeries(candles, tf);
|
||||||
if (series.getBarCount() < minBars) continue;
|
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);
|
MetricValues m = computeMetrics(series, candles, tick, r);
|
||||||
if (useBullishScore) {
|
if (useBullishScore) {
|
||||||
m.bullishTrendScore = computeBullishTrendScore(m, r);
|
m.bullishTrendScore = computeBullishTrendScore(m, r);
|
||||||
@@ -133,8 +135,9 @@ public class TrendSearchService {
|
|||||||
.market(market).matchRate(0).conditions(List.of()).build();
|
.market(market).matchRate(0).conditions(List.of()).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, TickerSnap> tickers = fetchTickers(List.of(market));
|
Map<String, String> koreanNames = fetchKoreanNamesForMarkets(List.of(market));
|
||||||
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(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);
|
BarSeries series = toSeries(candles, tf);
|
||||||
MetricValues m = computeMetrics(series, candles, tick, r);
|
MetricValues m = computeMetrics(series, candles, tick, r);
|
||||||
if (r.isBullishTrendEnabled()) {
|
if (r.isBullishTrendEnabled()) {
|
||||||
@@ -675,7 +678,37 @@ public class TrendSearchService {
|
|||||||
|
|
||||||
// ── Upbit helpers ───────────────────────────────────────────────────────
|
// ── 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 {
|
try {
|
||||||
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
||||||
String marketsJson = client.get()
|
String marketsJson = client.get()
|
||||||
@@ -683,29 +716,36 @@ public class TrendSearchService {
|
|||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(String.class)
|
.bodyToMono(String.class)
|
||||||
.block();
|
.block();
|
||||||
if (marketsJson == null) return List.of();
|
if (marketsJson == null) return Map.of();
|
||||||
|
|
||||||
List<Map<String, Object>> all = objectMapper.readValue(marketsJson, new TypeReference<>() {});
|
List<Map<String, Object>> all = objectMapper.readValue(marketsJson, new TypeReference<>() {});
|
||||||
List<String> krw = all.stream()
|
Map<String, String> names = new HashMap<>();
|
||||||
.map(m -> (String) m.get("market"))
|
for (Map<String, Object> m : all) {
|
||||||
.filter(m -> m != null && m.startsWith("KRW-"))
|
String market = (String) m.get("market");
|
||||||
.collect(Collectors.toList());
|
if (market == null || !market.startsWith("KRW-")) continue;
|
||||||
|
Object kn = m.get("korean_name");
|
||||||
if (krw.isEmpty()) return List.of();
|
if (kn != null && !kn.toString().isBlank()) {
|
||||||
|
names.put(market, kn.toString());
|
||||||
Map<String, TickerSnap> tickers = fetchTickers(krw);
|
}
|
||||||
return krw.stream()
|
}
|
||||||
.sorted(Comparator.comparingDouble((String m) ->
|
return names;
|
||||||
tickers.getOrDefault(m, TickerSnap.empty(m)).accTradePrice24h).reversed())
|
|
||||||
.limit(limit)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[TrendSearch] market fetch fail: {}", e.getMessage());
|
log.warn("[TrendSearch] korean names fetch fail: {}", e.getMessage());
|
||||||
return List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA");
|
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();
|
if (markets.isEmpty()) return Map.of();
|
||||||
try {
|
try {
|
||||||
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
||||||
@@ -722,9 +762,12 @@ public class TrendSearchService {
|
|||||||
JsonNode arr = objectMapper.readTree(json);
|
JsonNode arr = objectMapper.readTree(json);
|
||||||
for (JsonNode n : arr) {
|
for (JsonNode n : arr) {
|
||||||
String market = n.path("market").asText();
|
String market = n.path("market").asText();
|
||||||
|
String ko = koreanNames != null
|
||||||
|
? koreanNames.getOrDefault(market, market.replace("KRW-", ""))
|
||||||
|
: market.replace("KRW-", "");
|
||||||
map.put(market, new TickerSnap(
|
map.put(market, new TickerSnap(
|
||||||
market,
|
market,
|
||||||
n.path("korean_name").asText(market),
|
ko,
|
||||||
n.path("trade_price").asDouble(0),
|
n.path("trade_price").asDouble(0),
|
||||||
n.path("signed_change_rate").asDouble(0) * 100,
|
n.path("signed_change_rate").asDouble(0) * 100,
|
||||||
n.path("acc_trade_price_24h").asDouble(0)
|
n.path("acc_trade_price_24h").asDouble(0)
|
||||||
@@ -777,8 +820,11 @@ public class TrendSearchService {
|
|||||||
|
|
||||||
private record TickerSnap(String market, String koreanName, double tradePrice,
|
private record TickerSnap(String market, String koreanName, double tradePrice,
|
||||||
double changeRate, double accTradePrice24h) {
|
double changeRate, double accTradePrice24h) {
|
||||||
static TickerSnap empty(String market) {
|
static TickerSnap empty(String market, String koreanName) {
|
||||||
return new TickerSnap(market, market.replace("KRW-", ""), 0, 0, 0);
|
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);
|
||||||
@@ -52,6 +52,8 @@ import {
|
|||||||
splitIndicatorPane,
|
splitIndicatorPane,
|
||||||
} from './utils/indicatorPaneMerge';
|
} from './utils/indicatorPaneMerge';
|
||||||
import { useAppSettings } from './hooks/useAppSettings';
|
import { useAppSettings } from './hooks/useAppSettings';
|
||||||
|
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
||||||
|
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
|
||||||
import MarketPanel from './components/MarketPanel';
|
import MarketPanel from './components/MarketPanel';
|
||||||
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
||||||
import StrategyPage from './components/StrategyPage';
|
import StrategyPage from './components/StrategyPage';
|
||||||
@@ -1723,6 +1725,12 @@ function App() {
|
|||||||
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
||||||
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
|
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
|
||||||
onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })}
|
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 () => {
|
onPaperAccountReset={async () => {
|
||||||
await resetPaperAccount();
|
await resetPaperAccount();
|
||||||
await refreshPaperAccount();
|
await refreshPaperAccount();
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ interface MarketSearchPanelProps {
|
|||||||
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
|
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
|
||||||
/** 이미 추가된 종목 (add 모드에서 + 비활성) */
|
/** 이미 추가된 종목 (add 모드에서 + 비활성) */
|
||||||
addedMarkets?: Set<string>;
|
addedMarkets?: Set<string>;
|
||||||
|
/** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */
|
||||||
|
maxTargetsReached?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||||
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
|
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
|
||||||
variant = 'overlay', query: controlledQuery, onQueryChange,
|
variant = 'overlay', query: controlledQuery, onQueryChange,
|
||||||
actionMode = 'favorite', onAdd, addedMarkets,
|
actionMode = 'favorite', onAdd, addedMarkets, maxTargetsReached = false,
|
||||||
}) => {
|
}) => {
|
||||||
const embedded = variant === 'embedded';
|
const embedded = variant === 'embedded';
|
||||||
const [markets, setMarkets] = useState<MarketInfo[]>([]);
|
const [markets, setMarkets] = useState<MarketInfo[]>([]);
|
||||||
@@ -186,6 +188,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
if (embedded) onClose();
|
if (embedded) onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (maxTargetsReached) return;
|
||||||
const row = markets.find(m => m.market === market);
|
const row = markets.find(m => m.market === market);
|
||||||
onAdd?.(market, {
|
onAdd?.(market, {
|
||||||
koreanName: row?.korean_name ?? market,
|
koreanName: row?.korean_name ?? market,
|
||||||
@@ -196,7 +199,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
}
|
}
|
||||||
onSelect(market);
|
onSelect(market);
|
||||||
if (!embedded) onClose();
|
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) => {
|
const handleAddClick = useCallback((market: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -374,6 +377,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
const tk = tickers[m.market];
|
const tk = tickers[m.market];
|
||||||
const isFav = favs.has(m.market);
|
const isFav = favs.has(m.market);
|
||||||
const isAdded = addedMarkets?.has(m.market) ?? false;
|
const isAdded = addedMarkets?.has(m.market) ?? false;
|
||||||
|
const addBlocked = maxTargetsReached && !isAdded;
|
||||||
const isActive = m.market === currentMarket;
|
const isActive = m.market === currentMarket;
|
||||||
const rate = tk ? formatChangeRate(tk.signed_change_rate) : null;
|
const rate = tk ? formatChangeRate(tk.signed_change_rate) : null;
|
||||||
const isUp = rate !== null && rate >= 0;
|
const isUp = rate !== null && rate >= 0;
|
||||||
@@ -393,10 +397,16 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
actionMode === 'add' ? (
|
actionMode === 'add' ? (
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={e => handleAddClick(m.market, e)}
|
||||||
disabled={isAdded}
|
disabled={isAdded || addBlocked}
|
||||||
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
|
title={
|
||||||
|
isAdded
|
||||||
|
? '이미 추가됨'
|
||||||
|
: addBlocked
|
||||||
|
? '투자대상 최대 개수에 도달했습니다'
|
||||||
|
: '투자대상에 추가'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isAdded ? (
|
{isAdded ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
normalizeTradeAlertPopupPosition,
|
normalizeTradeAlertPopupPosition,
|
||||||
} from '../utils/tradeAlertPopupLayout';
|
} from '../utils/tradeAlertPopupLayout';
|
||||||
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
|
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
|
||||||
|
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
|
||||||
|
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
|
||||||
import {
|
import {
|
||||||
CHART_LEGEND_SETTING_ITEMS,
|
CHART_LEGEND_SETTING_ITEMS,
|
||||||
type ChartLegendVisibility,
|
type ChartLegendVisibility,
|
||||||
@@ -114,6 +116,8 @@ interface SettingsPageProps {
|
|||||||
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
||||||
paperAutoTradeBudgetPct?: number;
|
paperAutoTradeBudgetPct?: number;
|
||||||
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
||||||
|
virtualTargetMaxCount?: number;
|
||||||
|
onVirtualTargetMaxCount?: (v: number) => void;
|
||||||
onPaperAccountReset?: () => void;
|
onPaperAccountReset?: () => void;
|
||||||
tradingMode?: string;
|
tradingMode?: string;
|
||||||
onTradingMode?: (v: string) => void;
|
onTradingMode?: (v: string) => void;
|
||||||
@@ -132,6 +136,8 @@ interface SettingsPageProps {
|
|||||||
displayTimezone?: string;
|
displayTimezone?: string;
|
||||||
onDisplayTimezoneChange?: (tz: string) => void;
|
onDisplayTimezoneChange?: (tz: string) => void;
|
||||||
menuPermissions?: Record<string, boolean>;
|
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"/>
|
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
|
||||||
</svg>
|
</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 = () => (
|
const IcBacktest = () => (
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<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"/>
|
<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: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
|
||||||
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
|
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
|
||||||
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, 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: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
|
||||||
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
||||||
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
||||||
@@ -292,6 +306,8 @@ interface PaperPanelProps {
|
|||||||
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
||||||
paperAutoTradeBudgetPct?: number;
|
paperAutoTradeBudgetPct?: number;
|
||||||
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
||||||
|
virtualTargetMaxCount?: number;
|
||||||
|
onVirtualTargetMaxCount?: (v: number) => void;
|
||||||
onPaperAccountReset?: () => void;
|
onPaperAccountReset?: () => void;
|
||||||
tradingMode?: string;
|
tradingMode?: string;
|
||||||
onTradingMode?: (v: string) => void;
|
onTradingMode?: (v: string) => void;
|
||||||
@@ -319,6 +335,8 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
|||||||
onPaperAutoTradeEnabled,
|
onPaperAutoTradeEnabled,
|
||||||
paperAutoTradeBudgetPct = 95,
|
paperAutoTradeBudgetPct = 95,
|
||||||
onPaperAutoTradeBudgetPct,
|
onPaperAutoTradeBudgetPct,
|
||||||
|
virtualTargetMaxCount = 20,
|
||||||
|
onVirtualTargetMaxCount,
|
||||||
onPaperAccountReset,
|
onPaperAccountReset,
|
||||||
tradingMode = 'PAPER',
|
tradingMode = 'PAPER',
|
||||||
onTradingMode,
|
onTradingMode,
|
||||||
@@ -344,13 +362,27 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
<SettingSection title="가상투자 사용">
|
<SettingSection title="가상매매">
|
||||||
<SettingRow label="가상투자 활성화" desc="켜면 가상투자·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
|
<SettingRow label="가상매매 활성화" desc="켜면 가상매매·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
|
||||||
<label className="stg-toggle">
|
<label className="stg-toggle">
|
||||||
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
||||||
<span className="stg-toggle-slider" />
|
<span className="stg-toggle-slider" />
|
||||||
</label>
|
</label>
|
||||||
</SettingRow>
|
</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>
|
||||||
<SettingSection title="자동매매">
|
<SettingSection title="자동매매">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
@@ -1398,6 +1430,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onPaperAutoTradeEnabled,
|
onPaperAutoTradeEnabled,
|
||||||
paperAutoTradeBudgetPct = 95,
|
paperAutoTradeBudgetPct = 95,
|
||||||
onPaperAutoTradeBudgetPct,
|
onPaperAutoTradeBudgetPct,
|
||||||
|
virtualTargetMaxCount = 20,
|
||||||
|
onVirtualTargetMaxCount,
|
||||||
onPaperAccountReset,
|
onPaperAccountReset,
|
||||||
tradingMode = 'PAPER',
|
tradingMode = 'PAPER',
|
||||||
onTradingMode,
|
onTradingMode,
|
||||||
@@ -1416,6 +1450,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
displayTimezone = 'Asia/Seoul',
|
displayTimezone = 'Asia/Seoul',
|
||||||
onDisplayTimezoneChange,
|
onDisplayTimezoneChange,
|
||||||
menuPermissions,
|
menuPermissions,
|
||||||
|
trendSearchSettings,
|
||||||
|
onTrendSearchSettingsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const categories = filterCategories(menuPermissions);
|
const categories = filterCategories(menuPermissions);
|
||||||
const [active, setActive] = useState<CategoryId>('general');
|
const [active, setActive] = useState<CategoryId>('general');
|
||||||
@@ -1510,6 +1546,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
|
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
|
||||||
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
|
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
|
||||||
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
|
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
|
||||||
|
virtualTargetMaxCount={virtualTargetMaxCount}
|
||||||
|
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
|
||||||
onPaperAccountReset={onPaperAccountReset}
|
onPaperAccountReset={onPaperAccountReset}
|
||||||
tradingMode={tradingMode}
|
tradingMode={tradingMode}
|
||||||
onTradingMode={onTradingMode}
|
onTradingMode={onTradingMode}
|
||||||
@@ -1522,6 +1560,16 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onLiveAutoTradeBudgetPct={onLiveAutoTradeBudgetPct}
|
onLiveAutoTradeBudgetPct={onLiveAutoTradeBudgetPct}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'trend-search': return (
|
||||||
|
trendSearchSettings && onTrendSearchSettingsChange ? (
|
||||||
|
<TrendSearchSettingsPanel
|
||||||
|
settings={trendSearchSettings}
|
||||||
|
onChange={onTrendSearchSettingsChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="stg-ind-unavailable">추세검색 설정을 불러올 수 없습니다.</p>
|
||||||
|
)
|
||||||
|
);
|
||||||
case 'strategy': return (
|
case 'strategy': return (
|
||||||
<StrategyPanel
|
<StrategyPanel
|
||||||
liveMarket={liveMarket}
|
liveMarket={liveMarket}
|
||||||
|
|||||||
@@ -18,10 +18,19 @@ import {
|
|||||||
type TrendSearchResultDto,
|
type TrendSearchResultDto,
|
||||||
} from '../utils/trendSearchApi';
|
} from '../utils/trendSearchApi';
|
||||||
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
|
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/trendSearchDashboard.css';
|
||||||
import '../styles/virtualTradingDashboard.css';
|
import '../styles/virtualTradingDashboard.css';
|
||||||
|
|
||||||
const REFRESH_MS = 3000;
|
|
||||||
const DISPLAY_MODE_KEY = 'tsd-display-mode';
|
const DISPLAY_MODE_KEY = 'tsd-display-mode';
|
||||||
|
|
||||||
function loadDisplayMode(): TrendSearchDisplayMode {
|
function loadDisplayMode(): TrendSearchDisplayMode {
|
||||||
@@ -45,7 +54,16 @@ const TrendSearchPage: React.FC<Props> = ({
|
|||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
tickers,
|
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 [results, setResults] = useState<TrendSearchResultDto[]>([]);
|
||||||
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
|
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
@@ -55,21 +73,50 @@ const TrendSearchPage: React.FC<Props> = ({
|
|||||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
|
||||||
const filtersRef = useRef(filters);
|
const filtersRef = useRef(filters);
|
||||||
filtersRef.current = 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 }) => {
|
const runSearch = useCallback(async (opts?: { silent?: boolean }) => {
|
||||||
if (!opts?.silent) setSearching(true);
|
if (!opts?.silent) setSearching(true);
|
||||||
try {
|
try {
|
||||||
const list = await scanTrendSearch(filtersRef.current);
|
const list = await scanTrendSearch(filtersRef.current);
|
||||||
setResults(sortTrendSearchByStrength(list));
|
const sorted = sortTrendSearchByStrength(list);
|
||||||
|
setResults(sorted);
|
||||||
setLastUpdatedAt(Date.now());
|
setLastUpdatedAt(Date.now());
|
||||||
setSelectedMarket(prev => {
|
setSelectedMarket(prev => {
|
||||||
if (prev && list.some(r => r.market === prev)) return prev;
|
if (prev && sorted.some(r => r.market === prev)) return prev;
|
||||||
return list[0]?.market ?? null;
|
return sorted[0]?.market ?? null;
|
||||||
});
|
});
|
||||||
if (list.length > 0) {
|
if (sorted.length > 0) {
|
||||||
setFlashMarkets(new Set(list.slice(0, 5).map(r => r.market)));
|
setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market)));
|
||||||
setTimeout(() => setFlashMarkets(new Set()), 600);
|
setTimeout(() => setFlashMarkets(new Set()), 600);
|
||||||
}
|
}
|
||||||
|
await autoAddTrendSearchTargets(
|
||||||
|
sorted,
|
||||||
|
pageSettingsRef.current,
|
||||||
|
filtersRef.current.timeframe,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[TrendSearch]', e);
|
console.warn('[TrendSearch]', e);
|
||||||
if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
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);
|
return () => clearInterval(id);
|
||||||
}, [autoRefresh, runSearch]);
|
}, [autoRefresh, pageSettings.autoRefreshSeconds, runSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
|
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
|
||||||
@@ -102,19 +153,54 @@ const TrendSearchPage: React.FC<Props> = ({
|
|||||||
} catch { /* keep row */ }
|
} catch { /* keep row */ }
|
||||||
}, [filters.timeframe]);
|
}, [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 (
|
return (
|
||||||
<>
|
<BuilderPageShell
|
||||||
<BuilderPageShell
|
theme={theme}
|
||||||
theme={theme}
|
title="추세검색"
|
||||||
title="추세검색"
|
subtitle="Golden Analysis · Trend Search"
|
||||||
subtitle="Golden Analysis · Trend Search"
|
pageClassName="bps-page--tsd bps-page--vtd"
|
||||||
pageClassName="bps-page--tsd bps-page--vtd"
|
banner={searching ? (
|
||||||
headerActions={(
|
<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
|
<TrendSearchViewHeaderControls
|
||||||
displayMode={displayMode}
|
displayMode={displayMode}
|
||||||
onDisplayModeChange={setDisplayMode}
|
onDisplayModeChange={setDisplayMode}
|
||||||
autoRefresh={autoRefresh}
|
autoRefresh={autoRefresh}
|
||||||
onAutoRefreshChange={setAutoRefresh}
|
onAutoRefreshChange={setAutoRefresh}
|
||||||
|
refreshSeconds={pageSettings.autoRefreshSeconds}
|
||||||
|
onRefreshSecondsChange={handleRefreshSecondsChange}
|
||||||
|
autoAddTargets={pageSettings.autoAddTargetsEnabled}
|
||||||
|
onAutoAddTargetsChange={handleAutoAddToggle}
|
||||||
searching={searching}
|
searching={searching}
|
||||||
onRefresh={() => void runSearch()}
|
onRefresh={() => void runSearch()}
|
||||||
resultCount={results.length}
|
resultCount={results.length}
|
||||||
@@ -127,7 +213,7 @@ const TrendSearchPage: React.FC<Props> = ({
|
|||||||
left={(
|
left={(
|
||||||
<TrendSearchFilterPanel
|
<TrendSearchFilterPanel
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={handleFiltersChange}
|
||||||
onSearch={() => void runSearch()}
|
onSearch={() => void runSearch()}
|
||||||
searching={searching}
|
searching={searching}
|
||||||
/>
|
/>
|
||||||
@@ -145,22 +231,14 @@ const TrendSearchPage: React.FC<Props> = ({
|
|||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
tickers={tickers}
|
tickers={tickers}
|
||||||
lastUpdatedAt={lastUpdatedAt}
|
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
syncVirtualTargetsToBackend,
|
syncVirtualTargetsToBackend,
|
||||||
stopVirtualLiveOnBackend,
|
stopVirtualLiveOnBackend,
|
||||||
} from '../utils/virtualLiveStrategySync';
|
} from '../utils/virtualLiveStrategySync';
|
||||||
|
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
||||||
|
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
|
||||||
import '../styles/virtualTradingDashboard.css';
|
import '../styles/virtualTradingDashboard.css';
|
||||||
|
|
||||||
type RightTab = 'trade' | 'orderbook' | 'history';
|
type RightTab = 'trade' | 'orderbook' | 'history';
|
||||||
@@ -95,6 +97,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
||||||
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
||||||
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
|
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
|
||||||
|
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
@@ -104,6 +107,29 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
]).finally(() => setLoading(false));
|
]).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(() => { saveVirtualTargets(targets); }, [targets]);
|
||||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||||
@@ -185,11 +211,27 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
}, [tickers]);
|
}, [tickers]);
|
||||||
|
|
||||||
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
||||||
|
if (items.length > virtualTargetMaxCount) {
|
||||||
|
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setTargets(items);
|
setTargets(items);
|
||||||
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
|
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
|
||||||
handleSelectMarket(items[0].market);
|
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 handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
||||||
const next = { ...session, globalStrategyId: strategyId };
|
const next = { ...session, globalStrategyId: strategyId };
|
||||||
@@ -386,6 +428,8 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
onTargetsChange={handleTargetsChange}
|
onTargetsChange={handleTargetsChange}
|
||||||
onSelectMarket={handleSelectMarket}
|
onSelectMarket={handleSelectMarket}
|
||||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||||
|
onTogglePin={handleTogglePin}
|
||||||
|
virtualTargetMaxCount={virtualTargetMaxCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
center={(
|
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>
|
</div>
|
||||||
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
||||||
<VirtualIndicatorCompareTable metrics={metrics} />
|
<VirtualIndicatorCompareTable metrics={metrics} showStrategyCondition={false} />
|
||||||
</div>
|
</div>
|
||||||
<div className="vtd-card-foot">
|
<div className="vtd-card-foot">
|
||||||
<span className="vtd-card-updated">갱신 {updatedLabel}</span>
|
<span className="vtd-card-updated">갱신 {updatedLabel}</span>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
|
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
|
||||||
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
|
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
|
||||||
import {
|
import { sumWeights } from '../../utils/trendSearchBullishWeights';
|
||||||
BULLISH_WEIGHT_ITEMS,
|
import TrendSearchBullishWeightsSection from './TrendSearchBullishWeightsSection';
|
||||||
adjustBullishWeight,
|
|
||||||
sumWeights,
|
|
||||||
type BullishWeightKey,
|
|
||||||
} from '../../utils/trendSearchBullishWeights';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filters: TrendSearchRequest;
|
filters: TrendSearchRequest;
|
||||||
@@ -43,11 +39,6 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
|||||||
|
|
||||||
const weightTotal = sumWeights(Object.values(weights));
|
const weightTotal = sumWeights(Object.values(weights));
|
||||||
|
|
||||||
const handleWeightChange = (key: BullishWeightKey, value: number) => {
|
|
||||||
const next = adjustBullishWeight(weights, key, value);
|
|
||||||
patch(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tsd-filter-panel">
|
<div className="tsd-filter-panel">
|
||||||
<div className="tsd-panel-head">
|
<div className="tsd-panel-head">
|
||||||
@@ -60,51 +51,10 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tsd-bullish-card">
|
<TrendSearchBullishWeightsSection
|
||||||
<div className="tsd-bullish-card-head">
|
weights={weights}
|
||||||
<div>
|
onChange={w => patch(w)}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="tsd-filter-section">
|
<div className="tsd-filter-section">
|
||||||
<h4 className="tsd-filter-section-title">캔들 주기</h4>
|
<h4 className="tsd-filter-section-title">캔들 주기</h4>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import VirtualTargetQuote from '../virtual/VirtualTargetQuote';
|
|||||||
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
|
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
|
||||||
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
|
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
|
||||||
import TrendSearchCardChart from './TrendSearchCardChart';
|
import TrendSearchCardChart from './TrendSearchCardChart';
|
||||||
|
import TrendSearchTargetActions from './TrendSearchTargetActions';
|
||||||
|
|
||||||
export type TrendSearchDisplayMode = 'summary' | 'chart';
|
export type TrendSearchDisplayMode = 'summary' | 'chart';
|
||||||
|
|
||||||
@@ -24,6 +25,11 @@ interface Props {
|
|||||||
ticker?: TickerData;
|
ticker?: TickerData;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
flash?: boolean;
|
flash?: boolean;
|
||||||
|
inTargets?: boolean;
|
||||||
|
targetPinned?: boolean;
|
||||||
|
targetBusy?: boolean;
|
||||||
|
onAddTarget?: () => void;
|
||||||
|
onRemoveTarget?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultToTicker(result: TrendSearchResultDto): TickerData {
|
function resultToTicker(result: TrendSearchResultDto): TickerData {
|
||||||
@@ -55,6 +61,11 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
|||||||
ticker,
|
ticker,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
flash = false,
|
flash = false,
|
||||||
|
inTargets = false,
|
||||||
|
targetPinned = false,
|
||||||
|
targetBusy = false,
|
||||||
|
onAddTarget,
|
||||||
|
onRemoveTarget,
|
||||||
}) => {
|
}) => {
|
||||||
const en = result.market.replace(/^KRW-/, '');
|
const en = result.market.replace(/^KRW-/, '');
|
||||||
const quoteTicker = ticker ?? resultToTicker(result);
|
const quoteTicker = ticker ?? resultToTicker(result);
|
||||||
@@ -97,6 +108,18 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
|||||||
<span className="tsd-card-score">{result.matchRate}점</span>
|
<span className="tsd-card-score">{result.matchRate}점</span>
|
||||||
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
|
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="vtd-card-meta">
|
<div className="vtd-card-meta">
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ interface Props {
|
|||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
tickers?: Map<string, TickerData>;
|
tickers?: Map<string, TickerData>;
|
||||||
lastUpdatedAt?: number;
|
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> = ({
|
const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||||
@@ -34,6 +39,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
|||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
tickers,
|
tickers,
|
||||||
lastUpdatedAt,
|
lastUpdatedAt,
|
||||||
|
isInTargets,
|
||||||
|
isPinned,
|
||||||
|
onAddTarget,
|
||||||
|
onRemoveTarget,
|
||||||
|
targetBusyMarket = null,
|
||||||
}) => {
|
}) => {
|
||||||
const sortedResults = useMemo(
|
const sortedResults = useMemo(
|
||||||
() => sortTrendSearchByStrength(results),
|
() => sortTrendSearchByStrength(results),
|
||||||
@@ -77,6 +87,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
|||||||
ticker={tickers?.get(row.market)}
|
ticker={tickers?.get(row.market)}
|
||||||
updatedAt={lastUpdatedAt}
|
updatedAt={lastUpdatedAt}
|
||||||
flash={flashMarkets?.has(row.market)}
|
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>
|
</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 React from 'react';
|
||||||
import type { TrendSearchDisplayMode } from './TrendSearchResultCard';
|
import type { TrendSearchDisplayMode } from './TrendSearchResultCard';
|
||||||
|
import {
|
||||||
|
TREND_SEARCH_REFRESH_OPTIONS,
|
||||||
|
formatTrendRefreshLabel,
|
||||||
|
} from '../../utils/trendSearchAppSettings';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
displayMode: TrendSearchDisplayMode;
|
displayMode: TrendSearchDisplayMode;
|
||||||
onDisplayModeChange: (mode: TrendSearchDisplayMode) => void;
|
onDisplayModeChange: (mode: TrendSearchDisplayMode) => void;
|
||||||
autoRefresh: boolean;
|
autoRefresh: boolean;
|
||||||
onAutoRefreshChange: (v: boolean) => void;
|
onAutoRefreshChange: (v: boolean) => void;
|
||||||
|
refreshSeconds: number;
|
||||||
|
onRefreshSecondsChange: (sec: number) => void;
|
||||||
|
autoAddTargets: boolean;
|
||||||
|
onAutoAddTargetsChange: (v: boolean) => void;
|
||||||
searching?: boolean;
|
searching?: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
resultCount?: number;
|
resultCount?: number;
|
||||||
@@ -16,6 +24,10 @@ const TrendSearchViewHeaderControls: React.FC<Props> = ({
|
|||||||
onDisplayModeChange,
|
onDisplayModeChange,
|
||||||
autoRefresh,
|
autoRefresh,
|
||||||
onAutoRefreshChange,
|
onAutoRefreshChange,
|
||||||
|
refreshSeconds,
|
||||||
|
onRefreshSecondsChange,
|
||||||
|
autoAddTargets,
|
||||||
|
onAutoAddTargetsChange,
|
||||||
searching,
|
searching,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
resultCount = 0,
|
resultCount = 0,
|
||||||
@@ -42,10 +54,34 @@ const TrendSearchViewHeaderControls: React.FC<Props> = ({
|
|||||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||||
<span className="tsd-result-count">{resultCount}종목</span>
|
<span className="tsd-result-count">{resultCount}종목</span>
|
||||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||||
<label className="tsd-auto-refresh">
|
<label className="tsd-auto-add-toggle" title="검색 결과 상위 순위를 가상매매 투자대상에 자동 등록">
|
||||||
<input type="checkbox" checked={autoRefresh} onChange={e => onAutoRefreshChange(e.target.checked)} />
|
<input
|
||||||
<span>3초 자동 갱신</span>
|
type="checkbox"
|
||||||
|
checked={autoAddTargets}
|
||||||
|
onChange={e => onAutoAddTargetsChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>투자대상 자동추가</span>
|
||||||
</label>
|
</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 type="button" className="tsd-refresh-btn" disabled={searching} onClick={onRefresh}>
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,16 +7,21 @@ import ConditionStatusSignal from './ConditionStatusSignal';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
metrics: ConditionMetric[];
|
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-wrap">
|
||||||
<div className="vtd-sig-table-head">실시간 지표 비교</div>
|
<div className="vtd-sig-table-head">실시간 지표 비교</div>
|
||||||
<table className="vtd-sig-table">
|
<table className={`vtd-sig-table${showStrategyCondition ? '' : ' vtd-sig-table--no-threshold'}`}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="vtd-sig-col vtd-sig-col--ind" />
|
<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--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--progress" />
|
||||||
<col className="vtd-sig-col vtd-sig-col--status" />
|
<col className="vtd-sig-col vtd-sig-col--status" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
@@ -24,7 +29,9 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="vtd-sig-table-th--ind">기술적 지표</th>
|
<th className="vtd-sig-table-th--ind">기술적 지표</th>
|
||||||
<th className="vtd-sig-table-th--val">현재값</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--progress">진행률</th>
|
||||||
<th className="vtd-sig-table-th--status">상태</th>
|
<th className="vtd-sig-table-th--status">상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -37,10 +44,12 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
|||||||
<tr key={row.id}>
|
<tr key={row.id}>
|
||||||
<td className="vtd-sig-table-ind">{row.displayName}</td>
|
<td className="vtd-sig-table-ind">{row.displayName}</td>
|
||||||
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
|
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
|
||||||
<td className="vtd-sig-table-threshold">
|
{showStrategyCondition && (
|
||||||
{row.thresholdLabel
|
<td className="vtd-sig-table-threshold">
|
||||||
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
|
{row.thresholdLabel
|
||||||
</td>
|
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="vtd-sig-table-progress">
|
<td className="vtd-sig-table-progress">
|
||||||
<div className="vtd-sig-row-bar vtd-sig-row-bar--heat">
|
<div className="vtd-sig-row-bar vtd-sig-row-bar--heat">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { MarketSearchPanel } from '../MarketSearchPanel';
|
import { MarketSearchPanel } from '../MarketSearchPanel';
|
||||||
import { getKoreanName } from '../../utils/marketNameCache';
|
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||||
import type { StrategyDto } from '../../utils/backendApi';
|
import type { StrategyDto } from '../../utils/backendApi';
|
||||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
import VirtualTargetQuote from './VirtualTargetQuote';
|
import VirtualTargetQuote from './VirtualTargetQuote';
|
||||||
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
|
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
|
||||||
|
import {
|
||||||
|
isVirtualTargetAddAllowed,
|
||||||
|
virtualTargetLimitMessage,
|
||||||
|
} from '../../utils/virtualTargetLimits';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
targets: VirtualTargetItem[];
|
targets: VirtualTargetItem[];
|
||||||
@@ -15,6 +19,8 @@ interface Props {
|
|||||||
onTargetsChange: (items: VirtualTargetItem[]) => void;
|
onTargetsChange: (items: VirtualTargetItem[]) => void;
|
||||||
onSelectMarket: (market: string) => void;
|
onSelectMarket: (market: string) => void;
|
||||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||||
|
onTogglePin?: (market: string) => void;
|
||||||
|
virtualTargetMaxCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualLeftTargetPanel: React.FC<Props> = ({
|
const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||||
@@ -26,12 +32,15 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
|||||||
onTargetsChange,
|
onTargetsChange,
|
||||||
onSelectMarket,
|
onSelectMarket,
|
||||||
onTargetStrategyChange,
|
onTargetStrategyChange,
|
||||||
|
onTogglePin,
|
||||||
|
virtualTargetMaxCount = 20,
|
||||||
}) => {
|
}) => {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const searchRef = useRef<HTMLDivElement>(null);
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
|
const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
|
||||||
|
const atTargetLimit = !isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount);
|
||||||
|
|
||||||
const closeDropdown = useCallback(() => {
|
const closeDropdown = useCallback(() => {
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
@@ -54,21 +63,28 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => {
|
const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => {
|
||||||
if (addedSet.has(market)) return;
|
if (addedSet.has(market)) return;
|
||||||
|
if (!isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount)) {
|
||||||
|
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = resolveVirtualTargetNames(market, meta.koreanName, meta.englishName);
|
||||||
onTargetsChange([
|
onTargetsChange([
|
||||||
...targets,
|
...targets,
|
||||||
{
|
{
|
||||||
market,
|
market,
|
||||||
strategyId: globalStrategyId,
|
strategyId: globalStrategyId,
|
||||||
koreanName: meta.koreanName,
|
koreanName: names.koreanName,
|
||||||
englishName: meta.englishName,
|
englishName: names.englishName,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
onSelectMarket(market);
|
onSelectMarket(market);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket]);
|
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket, virtualTargetMaxCount]);
|
||||||
|
|
||||||
const handleRemove = useCallback((market: string) => {
|
const handleRemove = useCallback((market: string) => {
|
||||||
|
const item = targets.find(t => t.market === market);
|
||||||
|
if (item?.pinned) return;
|
||||||
onTargetsChange(targets.filter(t => t.market !== market));
|
onTargetsChange(targets.filter(t => t.market !== market));
|
||||||
}, [targets, onTargetsChange]);
|
}, [targets, onTargetsChange]);
|
||||||
|
|
||||||
@@ -96,6 +112,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
|||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
actionMode="add"
|
actionMode="add"
|
||||||
addedMarkets={addedSet}
|
addedMarkets={addedSet}
|
||||||
|
maxTargetsReached={atTargetLimit}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onSelect={onSelectMarket}
|
onSelect={onSelectMarket}
|
||||||
onClose={closeDropdown}
|
onClose={closeDropdown}
|
||||||
@@ -107,7 +124,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
|||||||
<section className="vtd-target-section">
|
<section className="vtd-target-section">
|
||||||
<div className="vtd-target-list-head">
|
<div className="vtd-target-list-head">
|
||||||
<span>투자대상</span>
|
<span>투자대상</span>
|
||||||
<span className="vtd-target-count">{targets.length}종목</span>
|
<span className="vtd-target-count">{targets.length}/{virtualTargetMaxCount}종목</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="vtd-target-list">
|
<div className="vtd-target-list">
|
||||||
@@ -115,13 +132,20 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
|||||||
<p className="vtd-muted">검색 후 + 버튼으로 종목을 추가하세요.</p>
|
<p className="vtd-muted">검색 후 + 버튼으로 종목을 추가하세요.</p>
|
||||||
)}
|
)}
|
||||||
{targets.map(item => {
|
{targets.map(item => {
|
||||||
const ko = item.koreanName ?? getKoreanName(item.market);
|
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
|
||||||
const en = item.englishName ?? item.market.replace(/^KRW-/, '');
|
item.market,
|
||||||
|
item.koreanName,
|
||||||
|
item.englishName,
|
||||||
|
);
|
||||||
const active = item.market === selectedMarket;
|
const active = item.market === selectedMarket;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.market}
|
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)}
|
onClick={() => onSelectMarket(item.market)}
|
||||||
>
|
>
|
||||||
<div className="vtd-target-item-main">
|
<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-ko">{ko}</span>
|
||||||
<span className="vtd-target-en">{en}</span>
|
<span className="vtd-target-en">{en}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="vtd-target-item-actions">
|
||||||
type="button"
|
<button
|
||||||
className="vtd-target-remove"
|
type="button"
|
||||||
title="목록에서 제거"
|
className={`vtd-target-pin${item.pinned ? ' vtd-target-pin--on' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
|
title={item.pinned ? '고정 해제' : '투자대상 고정'}
|
||||||
>
|
aria-label={item.pinned ? '고정 해제' : '투자대상 고정'}
|
||||||
✕
|
aria-pressed={!!item.pinned}
|
||||||
</button>
|
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>
|
</div>
|
||||||
<VirtualTargetQuote
|
<VirtualTargetQuote
|
||||||
market={item.market}
|
market={item.market}
|
||||||
|
|||||||
@@ -15,20 +15,25 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const segments = useMemo(() => {
|
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++) {
|
for (let i = 0; i < SEGMENTS; i++) {
|
||||||
const lit = i < litCount;
|
const lit = i < litCount;
|
||||||
if (!lit) {
|
if (!lit) {
|
||||||
items.push({ lit: false, tone: 'blue' });
|
items.push({ lit: false, tone: 'blue' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let tone: 'red' | 'gold' | 'blue' | 'peak';
|
||||||
if (rate >= 100 && i === SEGMENTS - 1) {
|
if (rate >= 100 && i === SEGMENTS - 1) {
|
||||||
items.push({ lit: true, tone: 'peak' });
|
tone = 'peak';
|
||||||
} else if (i >= Math.floor(SEGMENTS * 0.55)) {
|
} else if (i < third) {
|
||||||
items.push({ lit: true, tone: 'gold' });
|
tone = 'red';
|
||||||
|
} else if (i < third * 2) {
|
||||||
|
tone = 'gold';
|
||||||
} else {
|
} else {
|
||||||
items.push({ lit: true, tone: 'blue' });
|
tone = 'blue';
|
||||||
}
|
}
|
||||||
|
items.push({ lit: true, tone });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [litCount, rate]);
|
}, [litCount, rate]);
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ const LABELS: Record<TrafficLightState, string> = {
|
|||||||
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
|
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
|
||||||
<div className="vtd-sig-light vtd-sig-light--card-right">
|
<div className="vtd-sig-light vtd-sig-light--card-right">
|
||||||
<div className="vtd-sig-light-housing">
|
<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--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>
|
||||||
<div className="vtd-sig-light-info vtd-sig-light-info--below">
|
<div className="vtd-sig-light-info vtd-sig-light-info--below">
|
||||||
<span className="vtd-sig-light-rate">{matchRate}%</span>
|
<span className="vtd-sig-light-rate">{matchRate}%</span>
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ import type { ChartLegendVisibility } from '../types/chartLegend';
|
|||||||
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
||||||
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
|
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
|
||||||
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
|
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;
|
let _cache: AppSettingsDto | null = null;
|
||||||
@@ -96,6 +104,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
|||||||
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
|
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
|
||||||
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
|
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
|
||||||
paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95,
|
paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95,
|
||||||
|
virtualTargetMaxCount: clampVirtualTargetMax(s.virtualTargetMaxCount ?? DEFAULT_VIRTUAL_TARGET_MAX),
|
||||||
tradingMode: s.tradingMode ?? 'PAPER',
|
tradingMode: s.tradingMode ?? 'PAPER',
|
||||||
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
|
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
|
||||||
hasUpbitKeys: s.hasUpbitKeys ?? false,
|
hasUpbitKeys: s.hasUpbitKeys ?? false,
|
||||||
@@ -104,6 +113,9 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
|||||||
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
|
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
|
||||||
fcmPushEnabled: s.fcmPushEnabled ?? false,
|
fcmPushEnabled: s.fcmPushEnabled ?? false,
|
||||||
displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE),
|
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 };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,6 +66,48 @@
|
|||||||
accent-color: var(--tsd-gold);
|
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 {
|
.tsd-refresh-btn {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -907,6 +949,55 @@
|
|||||||
display: none !important;
|
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 {
|
.tsd-card-score {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -951,55 +1042,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 검색 실행 — 화면 중앙 백드롭 프로그레스 */
|
/* 검색 실행 — 상단 프로그레스바 로딩 */
|
||||||
.tsd-search-overlay {
|
.tsd-search-loading {
|
||||||
position: fixed;
|
flex-shrink: 0;
|
||||||
inset: 0;
|
margin: 0 12px 4px;
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 4px;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
.tsd-search-spinner {
|
.tsd-search-loading-track {
|
||||||
width: 36px;
|
height: 3px;
|
||||||
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;
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--tsd-track-bg);
|
background: var(--tsd-track-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.tsd-search-progress-indeterminate {
|
.tsd-search-loading-fill {
|
||||||
width: 45%;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 38%;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: linear-gradient(90deg, var(--tsd-gold), var(--se-accent, var(--accent)));
|
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 {
|
.tsd-search-loading-label {
|
||||||
0% { transform: translateX(-120%); }
|
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%); }
|
100% { transform: translateX(320%); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,6 +348,10 @@
|
|||||||
background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, var(--se-panel-card-bg));
|
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 {
|
.vtd-target-item-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -452,6 +456,39 @@
|
|||||||
animation: vtd-target-flash-fall 0.7s ease-out forwards;
|
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 {
|
.vtd-target-remove {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@@ -464,11 +501,16 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-target-remove:hover {
|
.vtd-target-remove:hover:not(:disabled) {
|
||||||
background: color-mix(in srgb, var(--down, #ef5350) 15%, transparent);
|
background: color-mix(in srgb, var(--down, #ef5350) 15%, transparent);
|
||||||
color: var(--down, #ef5350);
|
color: var(--down, #ef5350);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-target-remove:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.vtd-target-strategy-field {
|
.vtd-target-strategy-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1569,6 +1611,11 @@
|
|||||||
transition: background 0.25s ease, box-shadow 0.25s ease;
|
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 {
|
.vtd-sig-eq-seg--lit.vtd-sig-eq-seg--blue {
|
||||||
background: linear-gradient(90deg, #1e6fd9, #4fc3f7);
|
background: linear-gradient(90deg, #1e6fd9, #4fc3f7);
|
||||||
box-shadow: 0 0 6px color-mix(in srgb, #4fc3f7 50%, transparent);
|
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--progress { width: 16%; }
|
||||||
.vtd-sig-col--status { width: 18%; min-width: 4.75rem; }
|
.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 {
|
.vtd-sig-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 6px 6px;
|
padding: 6px 6px;
|
||||||
|
|||||||
@@ -418,6 +418,8 @@ export interface AppSettingsDto {
|
|||||||
paperMinOrderKrw?: number;
|
paperMinOrderKrw?: number;
|
||||||
paperAutoTradeEnabled?: boolean;
|
paperAutoTradeEnabled?: boolean;
|
||||||
paperAutoTradeBudgetPct?: number;
|
paperAutoTradeBudgetPct?: number;
|
||||||
|
/** 가상매매 투자대상 목록 최대 종목 수 */
|
||||||
|
virtualTargetMaxCount?: number;
|
||||||
/** PAPER | LIVE | BOTH */
|
/** PAPER | LIVE | BOTH */
|
||||||
tradingMode?: string;
|
tradingMode?: string;
|
||||||
liveAutoTradeEnabled?: boolean;
|
liveAutoTradeEnabled?: boolean;
|
||||||
@@ -431,6 +433,8 @@ export interface AppSettingsDto {
|
|||||||
fcmPushEnabled?: boolean;
|
fcmPushEnabled?: boolean;
|
||||||
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
|
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
|
||||||
displayTimezone?: string;
|
displayTimezone?: string;
|
||||||
|
/** 추세검색 기본 설정 */
|
||||||
|
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveSummaryDto {
|
export interface LiveSummaryDto {
|
||||||
@@ -1025,6 +1029,8 @@ export interface LiveStrategySettingsDto {
|
|||||||
skipWatchlistSync?: boolean;
|
skipWatchlistSync?: boolean;
|
||||||
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
|
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
|
||||||
skipGlobalTemplate?: boolean;
|
skipGlobalTemplate?: boolean;
|
||||||
|
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 */
|
||||||
|
isPinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 실시간 전략 체크 설정 로드 */
|
/** 실시간 전략 체크 설정 로드 */
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ export type TopMenuId = MenuPage;
|
|||||||
|
|
||||||
export type SettingsCategoryId =
|
export type SettingsCategoryId =
|
||||||
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
|
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
|
||||||
| 'paper' | 'alert' | 'network' | 'admin';
|
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||||
|
|
||||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
|
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
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 = [
|
export const ALL_MENU_IDS = [
|
||||||
@@ -42,7 +42,8 @@ export const MENU_LABELS: Record<string, string> = {
|
|||||||
settings_indicators: '설정 · 보조지표',
|
settings_indicators: '설정 · 보조지표',
|
||||||
settings_backtest: '설정 · 백테스팅',
|
settings_backtest: '설정 · 백테스팅',
|
||||||
settings_strategy: '설정 · 전략',
|
settings_strategy: '설정 · 전략',
|
||||||
settings_paper: '설정 · 가상투자',
|
settings_paper: '설정 · 가상매매',
|
||||||
|
'settings_trend-search': '설정 · 추세검색',
|
||||||
settings_alert: '설정 · 알림',
|
settings_alert: '설정 · 알림',
|
||||||
settings_network: '설정 · 네트워크',
|
settings_network: '설정 · 네트워크',
|
||||||
settings_admin: '설정 · 관리자',
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,18 @@ export function bullishWeightsFromRequest(
|
|||||||
return BULLISH_WEIGHT_ITEMS.map(item => req[item.key] ?? DEFAULT_BULLISH_WEIGHTS[item.key]);
|
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 {
|
export function sumWeights(weights: number[]): number {
|
||||||
return weights.reduce((a, b) => a + b, 0);
|
return weights.reduce((a, b) => a + b, 0);
|
||||||
}
|
}
|
||||||
@@ -71,43 +83,48 @@ export function adjustBullishWeight(
|
|||||||
const idx = keys.indexOf(changedKey);
|
const idx = keys.indexOf(changedKey);
|
||||||
if (idx < 0) return current;
|
if (idx < 0) return current;
|
||||||
|
|
||||||
const clamped = Math.max(0, Math.min(100, Math.round(newValue)));
|
const clamped = snapToWeightStep(newValue);
|
||||||
const oldValue = current[changedKey];
|
const oldValue = snapToWeightStep(current[changedKey]);
|
||||||
if (clamped === oldValue) return current;
|
if (clamped === oldValue) return current;
|
||||||
|
|
||||||
const result: Record<BullishWeightKey, number> = { ...current };
|
const result: Record<BullishWeightKey, number> = { ...current };
|
||||||
|
for (const k of keys) result[k] = snapToWeightStep(result[k]);
|
||||||
result[changedKey] = clamped;
|
result[changedKey] = clamped;
|
||||||
|
|
||||||
const delta = clamped - oldValue;
|
const delta = clamped - oldValue;
|
||||||
const otherKeys = keys.filter(k => k !== changedKey);
|
const otherKeys = keys.filter(k => k !== changedKey);
|
||||||
|
|
||||||
if (delta > 0) {
|
const distribute = (amount: number, mode: 'take' | 'give') => {
|
||||||
let remaining = delta;
|
let remaining = amount;
|
||||||
for (let i = 0; i < otherKeys.length; i++) {
|
let idx = 0;
|
||||||
const k = otherKeys[i];
|
let guard = 0;
|
||||||
const take = i === otherKeys.length - 1
|
while (remaining > 0 && guard < 200) {
|
||||||
? remaining
|
const k = otherKeys[idx % otherKeys.length];
|
||||||
: Math.floor(delta / otherKeys.length);
|
if (mode === 'take') {
|
||||||
result[k] = Math.max(0, result[k] - take);
|
const take = Math.min(WEIGHT_STEP, remaining, result[k]);
|
||||||
remaining -= take;
|
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;
|
if (delta > 0) distribute(delta, 'take');
|
||||||
for (let i = 0; i < otherKeys.length; i++) {
|
else if (delta < 0) distribute(-delta, 'give');
|
||||||
const k = otherKeys[i];
|
|
||||||
const give = i === otherKeys.length - 1
|
|
||||||
? remaining
|
|
||||||
: Math.floor(add / otherKeys.length);
|
|
||||||
result[k] += give;
|
|
||||||
remaining -= give;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = keys.reduce((s, k) => s + result[k], 0);
|
const total = keys.reduce((s, k) => s + result[k], 0);
|
||||||
if (total !== 100) {
|
if (total !== 100) {
|
||||||
const fixKey = otherKeys[0] ?? changedKey;
|
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;
|
return result;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export async function syncVirtualTargetsToBackend(
|
|||||||
market: t.market,
|
market: t.market,
|
||||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||||
candleType: normalizeStartCandleType(resolveTargetCandleType(t)),
|
candleType: normalizeStartCandleType(resolveTargetCandleType(t)),
|
||||||
|
isPinned: !!t.pinned,
|
||||||
...shared,
|
...shared,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||||
|
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||||
|
|
||||||
/** 가상투자 대상 종목·세션 설정 localStorage */
|
/** 가상투자 대상 종목·세션 설정 localStorage */
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ export interface VirtualTargetItem {
|
|||||||
candleType?: string;
|
candleType?: string;
|
||||||
koreanName?: string;
|
koreanName?: string;
|
||||||
englishName?: string;
|
englishName?: string;
|
||||||
|
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */
|
||||||
|
pinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VirtualSessionConfig {
|
export interface VirtualSessionConfig {
|
||||||
@@ -31,7 +34,15 @@ export function loadVirtualTargets(): VirtualTargetItem[] {
|
|||||||
const raw = localStorage.getItem(TARGETS_KEY);
|
const raw = localStorage.getItem(TARGETS_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
const parsed = JSON.parse(raw) as VirtualTargetItem[];
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user