가상투자 메뉴 기능 구현

This commit is contained in:
Macbook
2026-05-25 06:41:45 +09:00
parent 0cfe7fc84c
commit 8b373b11e3
33 changed files with 3897 additions and 99 deletions
@@ -7,7 +7,7 @@ public final class MenuIds {
private MenuIds() {}
public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "strategy", "strategy-editor", "backtest", "notifications", "settings",
"dashboard", "chart", "paper", "virtual", "strategy", "strategy-editor", "backtest", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin"
);
@@ -0,0 +1,35 @@
package com.goldenchart.controller;
import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.service.LiveConditionStatusService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 가상투자 — 실시간 조건 충족 현황 API.
*
* GET /api/strategy/live-conditions?market=KRW-BTC&amp;strategyId=1
*/
@RestController
@RequestMapping("/strategy/live-conditions")
@RequiredArgsConstructor
public class LiveConditionController {
private final LiveConditionStatusService service;
@GetMapping
public ResponseEntity<LiveConditionStatusDto> get(
@RequestParam String market,
@RequestParam long strategyId,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
return ResponseEntity.ok(service.evaluate(parseUserId(userIdHeader), deviceId, market, strategyId));
}
private Long parseUserId(String header) {
if (header == null || header.isBlank()) return null;
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
}
}
@@ -0,0 +1,29 @@
package com.goldenchart.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LiveConditionRowDto {
private String id;
private String indicatorType;
private String displayName;
private String conditionType;
private String conditionLabel;
private String thresholdLabel;
private Double currentValue;
private Double targetValue;
/** true=충족, false=미충족, null=평가 불가 */
private Boolean satisfied;
private String timeframe;
/** buy | sell */
private String side;
}
@@ -0,0 +1,25 @@
package com.goldenchart.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LiveConditionStatusDto {
private String market;
private Long strategyId;
private String timeframe;
/** 0~100 (충족 조건 비율) */
private int matchRate;
private List<LiveConditionRowDto> rows;
private long updatedAt;
}
@@ -0,0 +1,313 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.goldenchart.dto.LiveConditionRowDto;
import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcStrategyRepository;
import com.goldenchart.storage.Ta4jStorage;
import com.goldenchart.websocket.DynamicSubscriptionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Rule;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* 가상투자 — 종목×전략별 조건 충족 현황 (Ta4j Rule.isSatisfied).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LiveConditionStatusService {
private static final Map<String, String> CONDITION_LABEL = Map.ofEntries(
Map.entry("GT", "초과(>)"),
Map.entry("LT", "미만(<)"),
Map.entry("GTE", "이상(≥)"),
Map.entry("LTE", "이하(≤)"),
Map.entry("EQ", "같음(=)"),
Map.entry("NEQ", "다름(≠)"),
Map.entry("CROSS_UP", "상향 돌파"),
Map.entry("CROSS_DOWN", "하향 돌파"),
Map.entry("SLOPE_UP", "상승 중"),
Map.entry("SLOPE_DOWN", "하락 중")
);
private record PendingCond(String id, JsonNode condition, String timeframe, String side) {}
private final GcStrategyRepository strategyRepo;
private final Ta4jStorage ta4jStorage;
private final StrategyDslToTa4jAdapter adapter;
private final IndicatorSettingsService indicatorSettingsService;
private final ObjectMapper objectMapper;
private final DynamicSubscriptionManager subscriptionManager;
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId) {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty()) {
return empty(market, strategyId);
}
GcStrategy strategy = opt.get();
Map<String, Map<String, Object>> params =
indicatorSettingsService.getAll(userId, deviceId);
List<PendingCond> pending = new ArrayList<>();
collectConditions(strategy.getBuyConditionJson(), "1m", "buy", pending);
collectConditions(strategy.getSellConditionJson(), "1m", "sell", pending);
if (pending.isEmpty()) {
return empty(market, strategyId);
}
// 데이터 수신 보장 — 평가 전 해당 종목·봉 타입 pin + 동기 warm-up
Set<String> timeframes = new LinkedHashSet<>();
for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
timeframes.add(tf);
subscriptionManager.ensureBarsReadySync(market, tf, 60);
}
subscriptionManager.ensureBarsReadySync(market, "1m", 60);
List<LiveConditionRowDto> rows = new ArrayList<>();
int met = 0;
int evaluable = 0;
for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
if (!ta4jStorage.exists(market, tf)) {
rows.add(toRowUnevaluated(p));
continue;
}
BarSeries series = ta4jStorage.getOrCreate(market, tf);
if (series.isEmpty()) {
rows.add(toRowUnevaluated(p));
continue;
}
int index = series.getEndIndex();
try {
ObjectNode wrapper = objectMapper.createObjectNode();
wrapper.put("type", "CONDITION");
wrapper.set("condition", p.condition());
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, market, ta4jStorage);
Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue(
p.condition(), series, params, index, true);
Double target = readTargetNumeric(p.condition());
if (target == null) {
target = adapter.readConditionFieldValue(
p.condition(), series, params, index, false);
}
rows.add(toRow(p, current, target, satisfied, series, params, index));
evaluable++;
if (satisfied) met++;
} catch (Exception e) {
log.debug("[LiveCondition] eval fail {} {}: {}", market, p.id(), e.getMessage());
rows.add(toRowUnevaluated(p));
}
}
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
String tfSummary = String.join(", ", timeframes);
return LiveConditionStatusDto.builder()
.market(market)
.strategyId(strategyId)
.timeframe(tfSummary)
.matchRate(matchRate)
.rows(rows)
.updatedAt(System.currentTimeMillis())
.build();
}
private LiveConditionStatusDto empty(String market, long strategyId) {
return LiveConditionStatusDto.builder()
.market(market)
.strategyId(strategyId)
.timeframe("")
.matchRate(0)
.rows(List.of())
.updatedAt(System.currentTimeMillis())
.build();
}
private void collectConditions(String json, String timeframe, String side,
List<PendingCond> out) {
if (json == null || json.isBlank()) return;
try {
walk(objectMapper.readTree(json), timeframe, side, out, new HashSet<>());
} catch (Exception e) {
log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage());
}
}
private void walk(JsonNode node, String timeframe, String side,
List<PendingCond> out, Set<String> seen) {
if (node == null || node.isNull()) return;
String type = node.path("type").asText("");
if ("TIMEFRAME".equals(type)) {
String tf = LiveStrategyTimeframeService.normalize(
node.path("candleType").asText(timeframe));
JsonNode children = node.path("children");
if (children.isArray()) {
for (JsonNode c : children) walk(c, tf, side, out, seen);
}
return;
}
if ("AND".equals(type) || "OR".equals(type)) {
JsonNode children = node.path("children");
if (children.isArray()) {
for (JsonNode c : children) walk(c, timeframe, side, out, seen);
}
return;
}
if ("NOT".equals(type)) {
walk(node.path("child"), timeframe, side, out, seen);
return;
}
if ("CONDITION".equals(type) && node.has("condition")) {
JsonNode cond = node.path("condition");
String nodeId = node.path("id").asText("");
String id = !nodeId.isBlank()
? nodeId + "-" + side
: side + ":" + timeframe + ":" + cond.hashCode();
String key = id + ":" + cond.toString();
if (seen.add(key)) {
out.add(new PendingCond(id, cond, timeframe, side));
}
}
}
private LiveConditionRowDto toRowUnevaluated(PendingCond p) {
JsonNode c = p.condition();
String indType = c.path("indicatorType").asText("");
String condType = c.path("conditionType").asText("GT");
String condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
Double target = readTargetNumeric(c);
return LiveConditionRowDto.builder()
.id(p.id())
.indicatorType(indType)
.displayName(indType)
.conditionType(condType)
.conditionLabel(condLabel)
.thresholdLabel(formatThresholdStatic(c, target))
.currentValue(null)
.targetValue(target)
.satisfied(null)
.timeframe(p.timeframe())
.side(p.side())
.build();
}
private String formatThresholdStatic(JsonNode cond, Double target) {
if (target != null && !target.isNaN()) {
String ct = cond.path("conditionType").asText("GT");
String v = formatNum(target);
return switch (ct) {
case "LT", "CROSS_DOWN" -> "< " + v;
case "GT", "CROSS_UP" -> "> " + v;
case "GTE" -> "" + v;
case "LTE" -> "" + v;
case "EQ" -> "= " + v;
default -> cond.path("rightField").asText(v);
};
}
String rf = cond.path("rightField").asText("");
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
return rf.replace('_', ' ');
}
return "";
}
private LiveConditionRowDto toRow(PendingCond p, Double current, Double target,
Boolean satisfied, BarSeries series,
Map<String, Map<String, Object>> params, int index) {
JsonNode c = p.condition();
String indType = c.path("indicatorType").asText("");
String condType = c.path("conditionType").asText("GT");
String condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
return LiveConditionRowDto.builder()
.id(p.id())
.indicatorType(indType)
.displayName(indType)
.conditionType(condType)
.conditionLabel(condLabel)
.thresholdLabel(formatThreshold(c, target, series, params, index))
.currentValue(current)
.targetValue(target)
.satisfied(satisfied)
.timeframe(p.timeframe())
.side(p.side())
.build();
}
private String formatThreshold(JsonNode cond, Double target, BarSeries series,
Map<String, Map<String, Object>> params, int index) {
String ct = cond.path("conditionType").asText("GT");
if (target != null && !target.isNaN()) {
String v = formatNum(target);
return switch (ct) {
case "LT", "CROSS_DOWN" -> "< " + v;
case "GT", "CROSS_UP" -> "> " + v;
case "GTE" -> "" + v;
case "LTE" -> "" + v;
case "EQ" -> "= " + v;
default -> cond.path("rightField").asText(v);
};
}
String rf = cond.path("rightField").asText("");
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false);
if (rightLive != null && !rightLive.isNaN()) {
String v = formatNum(rightLive);
return switch (ct) {
case "LT", "CROSS_DOWN" -> "< " + v;
case "GT", "CROSS_UP" -> "> " + v;
case "GTE" -> "" + v;
case "LTE" -> "" + v;
case "EQ" -> "= " + v;
default -> rf.replace('_', ' ') + " (" + v + ")";
};
}
return rf.replace('_', ' ');
}
return "";
}
private static String formatNum(double v) {
if (Math.abs(v) >= 1000) return String.format("%.0f", v);
return String.format("%.2f", v);
}
private Double readTargetNumeric(JsonNode cond) {
if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
return cond.path("targetValue").asDouble();
}
if (cond.has("compareValue") && !cond.path("compareValue").isNull()) {
return cond.path("compareValue").asDouble();
}
String rf = cond.path("rightField").asText("");
if (rf.startsWith("K_")) {
try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {}
}
return null;
}
}
@@ -49,22 +49,15 @@ public class LiveStrategySettingsService {
@Transactional(readOnly = true)
public List<LiveStrategySettingsDto> listActive(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
List<GcLiveStrategySettings> rows = userId != null
? repo.findByUserIdAndIsLiveCheckTrue(userId)
: repo.findByDeviceIdAndIsLiveCheckTrue(
deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous");
if (rows.isEmpty()) {
return List.of();
}
List<String> watchlist = listWatchlistSymbols(userId, deviceId);
Long strategyId = app.getLiveStrategyId();
List<String> strategyCandleTypes = conditionTimeframes.collectForStrategyList(strategyId);
return watchlist.stream()
.map(m -> LiveStrategySettingsDto.builder()
.market(m)
.strategyId(strategyId)
.liveCheck(true)
.executionType(app.getLiveExecutionType())
.positionMode(app.getLivePositionMode())
.strategyCandleTypes(strategyCandleTypes)
.build())
return rows.stream()
.map(this::toDto)
.toList();
}
@@ -125,6 +118,17 @@ public class LiveStrategySettingsService {
public LiveStrategySettingsDto save(Long userId, String deviceId,
LiveStrategySettingsDto dto) {
persistGlobalTemplate(userId, deviceId, dto);
if (dto.getMarket() != null && !dto.getMarket().isBlank()) {
enrichStrategyTimeframes(dto);
LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, dto);
if (dto.isLiveCheck()) {
pinIfReady(dto.getMarket(), saved);
} else {
liveStrategyEvaluator.invalidateCache(dto.getMarket());
}
}
syncAllWatchlistMarkets(userId, deviceId, dto);
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
@@ -139,6 +143,10 @@ public class LiveStrategySettingsService {
if (dto.getPositionMode() != null) {
out.setPositionMode(dto.getPositionMode());
}
if (dto.getStrategyId() != null) {
out.setStrategyId(dto.getStrategyId());
out.setLiveCheck(dto.isLiveCheck());
}
enrichStrategyTimeframes(out);
return out;
}
@@ -139,6 +139,39 @@ public class StrategyDslToTa4jAdapter {
};
}
/** 조건 DSL left/right 필드의 현재 봉 수치 (가상투자 UI 표시용) */
public Double readConditionFieldValue(JsonNode cond, BarSeries series,
Map<String, Map<String, Object>> params,
int index, boolean leftSide) {
if (cond == null || cond.isNull() || series == null || index < 0) return null;
if (index >= series.getBarCount()) return null;
String field = leftSide
? cond.path("leftField").asText("NONE")
: cond.path("rightField").asText("NONE");
if (field == null || field.isBlank() || "NONE".equals(field)) return null;
String indType = cond.path("indicatorType").asText("");
int condPeriod = cond.path("period").asInt(-1);
int leftPeriod = cond.path("leftPeriod").asInt(-1);
int rightPeriod = cond.path("rightPeriod").asInt(-1);
int sidePeriod = leftSide ? leftPeriod : rightPeriod;
String registryKey = toRegistryKey(indType);
Map<String, Object> indParams = params != null
? params.getOrDefault(registryKey, Map.of())
: Map.of();
try {
Indicator<Num> ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod);
Num v = ind.getValue(index);
return v != null ? v.doubleValue() : null;
} catch (Exception e) {
log.debug("[Adapter] field value read fail {}: {}", field, e.getMessage());
return null;
}
}
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
String ct = node.path("candleType").asText("1m");
BarSeries branchSeries = ctx.resolveSeries(ct);
@@ -147,9 +147,36 @@ public class DynamicSubscriptionManager {
}
}
/**
* live-conditions API 등 — 최소 봉 수 확보까지 동기 warm-up (종목별 독립).
*/
public void ensureBarsReadySync(String market, String candleType, int minBars) {
upbitWsClient.addMarket(market);
if (ta4jStorage.getBarCount(market, candleType) >= minBars) {
return;
}
String key = market + ":" + candleType;
if (warmingUp.putIfAbsent(key, Boolean.TRUE) == null) {
try {
warmUp(market, candleType);
} finally {
warmingUp.remove(key);
}
} else {
// 다른 스레드 warm-up 중 — 짧게 대기 후 재확인
for (int i = 0; i < 30 && ta4jStorage.getBarCount(market, candleType) < minBars; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
/**
* 실시간 전략 체크 등 STOMP 클라이언트 없이도 백엔드가 틱·캔들을 수집하도록 마켓을 고정 구독한다.
* (관심종목 모니터링 — 프론트가 해당 토픽을 구독하면 평가·시그널 발행이 연결됨)
*/
public void ensureMarketPinned(String market, String candleType) {
String key = market + ":" + candleType;
@@ -0,0 +1,6 @@
-- 가상투자 메뉴 권한 (ADMIN·USER 허용, GUEST 차단)
INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES
('ADMIN', 'virtual', 1),
('USER', 'virtual', 1),
('GUEST', 'virtual', 0)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed);
+13
View File
@@ -69,6 +69,7 @@ import { BacktestResultModal } from './components/BacktestResultModal';
import { BacktestHistoryPage } from './components/BacktestHistoryPage';
import SettingsPage from './components/SettingsPage';
import PaperTradingPage from './components/PaperTradingPage';
import VirtualTradingPage from './components/VirtualTradingPage';
import DashboardPage from './components/DashboardPage';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar';
@@ -99,6 +100,7 @@ import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import './App.css';
import './styles/appPopup.css';
import './styles/paperDashboard.css';
import './styles/virtualTradingDashboard.css';
import './styles/backtestDashboard.css';
let _indCounter = 0;
@@ -1543,6 +1545,17 @@ function App() {
/>
)}
{menuPage === 'virtual' && (
<VirtualTradingPage
theme={theme}
tickers={marketTickers}
defaultMarket={symbol}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => setPaperRefreshKey(k => k + 1)}
/>
)}
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
{menuPage === 'notifications' && (
<TradeNotificationListPage
+94 -26
View File
@@ -42,16 +42,35 @@ interface MarketSearchPanelProps {
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
*/
anchorRect?: DOMRect | null;
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
variant?: 'overlay' | 'embedded';
/** 외부 입력과 검색어 동기화 */
initialQuery?: string;
/** 제어형 검색어 (embedded 등 외부 input 사용 시) */
query?: string;
onQueryChange?: (query: string) => void;
/** favorite=별표, add=투자대상 추가(+) */
actionMode?: 'favorite' | 'add';
/** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
/** 이미 추가된 종목 (add 모드에서 + 비활성) */
addedMarkets?: Set<string>;
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
variant = 'overlay', query: controlledQuery, onQueryChange,
actionMode = 'favorite', onAdd, addedMarkets,
}) => {
const embedded = variant === 'embedded';
const [markets, setMarkets] = useState<MarketInfo[]>([]);
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
const [query, setQuery] = useState(initialQuery);
const [internalQuery, setInternalQuery] = useState(initialQuery);
const query = controlledQuery ?? internalQuery;
const setQuery = useCallback((next: string) => {
if (controlledQuery === undefined) setInternalQuery(next);
onQueryChange?.(next);
}, [controlledQuery, onQueryChange]);
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [loading, setLoading] = useState(true);
@@ -84,13 +103,13 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
})
.catch(() => setLoading(false));
setTimeout(() => inputRef.current?.focus(), 50);
if (!embedded) setTimeout(() => inputRef.current?.focus(), 50);
refreshFavs();
}, [refreshFavs]);
}, [refreshFavs, embedded]);
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
if (controlledQuery === undefined) setInternalQuery(initialQuery);
}, [initialQuery, controlledQuery]);
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
useEffect(() => {
@@ -145,6 +164,30 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
}
}, [markets]);
const tryAddMarket = useCallback((market: string) => {
if (actionMode === 'add') {
onSelect(market);
if (addedMarkets?.has(market)) {
if (embedded) onClose();
return;
}
const row = markets.find(m => m.market === market);
onAdd?.(market, {
koreanName: row?.korean_name ?? market,
englishName: row?.english_name ?? market,
});
if (embedded) onClose();
return;
}
onSelect(market);
if (!embedded) onClose();
}, [actionMode, onSelect, onAdd, addedMarkets, markets, embedded, onClose]);
const handleAddClick = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
tryAddMarket(market);
}, [tryAddMarket]);
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
const q = query.trim();
const filtered = markets.filter(m => {
@@ -176,7 +219,9 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
const PANEL_Z = 99999;
const panelStyle: React.CSSProperties | undefined = anchorRect
const panelStyle: React.CSSProperties | undefined = embedded
? undefined
: anchorRect
? {
position: 'fixed',
top: anchorRect.top + HEADER_H,
@@ -206,22 +251,26 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
}
: undefined;
const handleRowSelect = useCallback((market: string) => {
tryAddMarket(market);
}, [tryAddMarket]);
return (
<>
{/* 배경 오버레이 (클릭 시 닫기) */}
{!embedded && (
<div
className="msp-backdrop"
style={backdropStyle}
onClick={onClose}
/>
)}
{/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
<div
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}`}
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}${embedded ? ' msp-panel--embedded' : ''}`}
style={panelStyle}
>
{/* 검색 입력 */}
{!embedded && (
<div className="msp-search-row">
<span className="msp-search-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -236,10 +285,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter' && sorted.length > 0) {
onSelect(sorted[0].market);
onClose();
}
if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market);
}}
/>
{query && (
@@ -255,14 +301,18 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
</svg>
</button>
</div>
)}
{/* 컬럼 헤더 */}
<div className="msp-header">
<span className="msp-col msp-col-fav" />
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}`}>
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
<span className="msp-col msp-col-name"></span>
{!embedded && (
<>
<span className="msp-col msp-col-price"></span>
<span className="msp-col msp-col-change"></span>
<span className="msp-col msp-col-vol"></span>
</>
)}
</div>
{/* 목록 */}
@@ -276,6 +326,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
{sorted.map(m => {
const tk = tickers[m.market];
const isFav = favs.has(m.market);
const isAdded = addedMarkets?.has(m.market) ?? false;
const isActive = m.market === currentMarket;
const rate = tk ? tk.signed_change_rate * 100 : null;
const isUp = rate !== null && rate >= 0;
@@ -283,10 +334,28 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
return (
<div
key={m.market}
className={`msp-item${isActive ? ' active' : ''}`}
onClick={() => { onSelect(m.market); onClose(); }}
className={`msp-item${embedded ? ' msp-item--embedded' : ''}${isActive ? ' active' : ''}`}
onClick={() => handleRowSelect(m.market)}
>
{/* 즐겨찾기 */}
{actionMode === 'add' ? (
<button
type="button"
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
onClick={e => handleAddClick(m.market, e)}
disabled={isAdded}
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
>
{isAdded ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
)}
</button>
) : (
<span
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
onClick={e => toggleFav(m.market, e)}
@@ -294,29 +363,28 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
>
{isFav ? '★' : '☆'}
</span>
)}
{/* 이름 */}
<span className="msp-col msp-col-name">
<span className="msp-kor">{m.korean_name}</span>
<span className="msp-sym">{m.symbol}</span>
</span>
{/* 현재가 */}
{!embedded && (
<>
<span className="msp-col msp-col-price">
{tk ? tk.trade_price.toLocaleString() : '-'}
</span>
{/* 전일대비 */}
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
</span>
{/* 거래대금 */}
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
</>
)}
</div>
);
})}
+10 -1
View File
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
interface TopMenuBarProps {
activePage: MenuPage;
@@ -129,10 +129,19 @@ const IcStrategyEditor = () => (
</svg>
);
const IcVirtual = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="12" height="12" rx="1.5"/>
<path d="M5 8h6M8 5v6"/>
<circle cx="11.5" cy="4.5" r="1.2" fill="currentColor" stroke="none"/>
</svg>
);
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'virtual', label: '가상투자', icon: <IcVirtual /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> },
@@ -0,0 +1,336 @@
/**
* 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadStrategies,
saveLiveStrategySettings,
type PaperSummaryDto,
type StrategyDto,
} from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types';
import TradeOrderPanel from './TradeOrderPanel';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import PaperSplitPanel from './paper/PaperSplitPanel';
import BuilderPageShell from './layout/BuilderPageShell';
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import {
loadVirtualSession,
loadVirtualTargets,
saveVirtualSession,
saveVirtualTargets,
type VirtualSessionConfig,
type VirtualTargetItem,
} from '../utils/virtualTradingStorage';
import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook';
interface Props {
theme?: Theme;
tickers?: Map<string, { tradePrice: number | null }>;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
}
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
const VirtualTradingPage: React.FC<Props> = ({
theme = 'dark',
tickers,
defaultMarket = 'KRW-BTC',
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
}) => {
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [loading, setLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
loadPaperSummary().then(setSummary).finally(() => setLoading(false));
}, []);
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
useEffect(() => { saveVirtualSession(session); }, [session]);
const targetRefs = useMemo(() =>
targets.map(t => ({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
})),
[targets, session.globalStrategyId]);
const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running);
const liveStatusByMarket = useVirtualTargetLiveStatus(targetRefs, session.running);
const mergedLiveStatus = useMemo(() => {
if (!session.running) return liveStatusByMarket;
const merged = { ...liveStatusByMarket };
const now = Date.now();
for (const [market, snap] of Object.entries(snapshots)) {
if (snap?.updatedAt && now - snap.updatedAt < 8000) {
merged[market] = 'live';
}
}
return merged;
}, [liveStatusByMarket, snapshots, session.running]);
const posQty = useMemo(() => {
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
return p?.quantity ?? 0;
}, [summary?.positions, selectedMarket]);
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
setTargets(items);
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
setSelectedMarket(items[0].market);
}
}, [selectedMarket]);
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
setSession(s => ({ ...s, globalStrategyId: strategyId }));
}, []);
const handleStart = useCallback(async () => {
if (targets.length === 0) {
window.alert('투자대상 종목을 먼저 추가하세요.');
return;
}
if (!session.globalStrategyId) {
window.alert('투자전략을 선택하세요.');
return;
}
const template = {
isLiveCheck: true,
executionType: session.executionType,
positionMode: session.positionMode,
};
try {
await Promise.all(targets.map(t =>
saveLiveStrategySettings({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
...template,
}),
));
setSession(s => ({ ...s, running: true }));
} catch {
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
}
}, [targets, session]);
const handleStop = useCallback(async () => {
if (targets.length === 0) {
setSession(s => ({ ...s, running: false }));
return;
}
try {
await Promise.all(targets.map(t =>
saveLiveStrategySettings({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
isLiveCheck: false,
executionType: session.executionType,
positionMode: session.positionMode,
}),
));
} catch { /* ignore */ }
setSession(s => ({ ...s, running: false }));
}, [targets, session]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
const side = rowType === 'ask' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req); else setFillSell(req);
setRightTab('trade');
}, [selectedMarket]);
const headerControls = (
<div className="vtd-header-controls">
<label className="vtd-header-field">
<span></span>
<select
className="vtd-header-select"
value={session.globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
handleGlobalStrategyChange(v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="vtd-header-field">
<span></span>
<select
className="vtd-header-select"
value={session.executionType}
onChange={e => setSession(s => ({
...s,
executionType: e.target.value === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
}))}
>
<option value="CANDLE_CLOSE"> </option>
<option value="REALTIME_TICK"> </option>
</select>
</label>
<label className="vtd-header-field">
<span> </span>
<select
className="vtd-header-select"
value={session.positionMode}
onChange={e => setSession(s => ({
...s,
positionMode: e.target.value === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
}))}
>
<option value="LONG_ONLY"> </option>
<option value="SIGNAL_ONLY"> </option>
</select>
</label>
{!session.running ? (
<button type="button" className="bps-btn bps-btn--primary" onClick={() => void handleStart()}>
</button>
) : (
<button type="button" className="bps-btn bps-btn--danger" onClick={() => void handleStop()}>
</button>
)}
</div>
);
const rightTabs = (
<div className="bps-right-tabs">
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}></button>
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}></button>
</div>
);
return (
<BuilderPageShell
theme={theme}
title="가상투자"
subtitle="Virtual Trading"
loading={loading}
loadingText="가상투자 대시보드 로딩…"
leftStorageKey="vtd-left-width"
leftDefaultWidth={380}
rightStorageKey="vtd-right-width"
rightDefaultWidth={380}
headerActions={headerControls}
left={(
<VirtualLeftTargetPanel
targets={targets}
globalStrategyId={session.globalStrategyId}
strategies={strategies}
selectedMarket={selectedMarket}
onTargetsChange={handleTargetsChange}
onSelectMarket={setSelectedMarket}
/>
)}
center={(
<VirtualTargetGrid
targets={targets}
strategies={strategies}
snapshots={snapshots}
running={session.running}
globalStrategyId={session.globalStrategyId}
liveStatusByMarket={mergedLiveStatus}
theme={theme}
/>
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={summary?.cashBalance ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={setSelectedMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
bottom={(
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={setSelectedMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
/>
) : (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매도 호가"
bottomTitle="매수 호가"
top={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="asks"
fillHeight
hideHeader
depth={10}
/>
)}
bottom={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="bids"
fillHeight
hideHeader
depth={10}
/>
)}
/>
)}
</div>
)}
/>
);
};
export default VirtualTradingPage;
@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { Theme } from '../../types';
import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize';
import '../../styles/strategyEditorTheme.css';
@@ -7,6 +7,9 @@ import '../../styles/builderPageShell.css';
const LEFT_MIN = 220;
const LEFT_MAX = 520;
const LEFT_DEFAULT = 280;
const RIGHT_MIN = 260;
const RIGHT_MAX = 520;
const RIGHT_DEFAULT = 280;
const FOOTER_MIN = 88;
const FOOTER_MAX = 420;
const FOOTER_DEFAULT = 140;
@@ -29,6 +32,9 @@ export interface BuilderPageShellProps {
footerLabel?: string;
footer?: React.ReactNode;
leftStorageKey?: string;
leftDefaultWidth?: number;
rightStorageKey?: string;
rightDefaultWidth?: number;
footerStorageKey?: string;
loading?: boolean;
loadingText?: string;
@@ -52,15 +58,21 @@ export default function BuilderPageShell({
footerLabel,
footer,
leftStorageKey = 'bps-left-width',
leftDefaultWidth = LEFT_DEFAULT,
rightStorageKey = 'bps-right-width',
rightDefaultWidth = RIGHT_DEFAULT,
footerStorageKey = 'bps-footer-height',
loading = false,
loadingText = '로딩 중…',
}: BuilderPageShellProps) {
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, LEFT_DEFAULT));
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, leftDefaultWidth));
const [rightWidth, setRightWidth] = useState(() => readStoredSize(rightStorageKey, rightDefaultWidth));
const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const footerHeightRef = useRef(footerHeight);
leftWidthRef.current = leftWidth;
rightWidthRef.current = rightWidth;
footerHeightRef.current = footerHeight;
const onLeftSplitter = usePanelResize(
@@ -72,6 +84,37 @@ export default function BuilderPageShell({
v => storeSize(leftStorageKey, v),
);
const handleRightSplitter = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const startX = e.clientX;
const start = rightWidthRef.current;
const cursor = 'col-resize';
document.body.style.cursor = cursor;
document.body.style.userSelect = 'none';
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('se-splitter--active');
const splitter = e.currentTarget;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
const next = Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, start - delta));
setRightWidth(next);
};
const onUp = (ev: PointerEvent) => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
splitter.releasePointerCapture(ev.pointerId);
splitter.classList.remove('se-splitter--active');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
storeSize(rightStorageKey, rightWidthRef.current);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}, [rightStorageKey]);
const onFooterSplitter = usePanelResize(
'horizontal',
setFooterHeight,
@@ -165,7 +208,18 @@ export default function BuilderPageShell({
</main>
{right && (
<aside className="bps-right">
<>
<div
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비 조절"
onPointerDown={handleRightSplitter}
/>
<aside
className="bps-right"
style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}
>
{rightTabs ?? (rightTitle ? (
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
<h2 className="bps-panel-title">{rightTitle}</h2>
@@ -173,6 +227,7 @@ export default function BuilderPageShell({
) : null)}
<div className="bps-right-body">{right}</div>
</aside>
</>
)}
</div>
</div>
@@ -0,0 +1,84 @@
import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics';
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
interface Props {
metrics: ConditionMetric[];
}
function StatusBadge({ status }: { status: ConditionMetric['status'] }) {
switch (status) {
case 'match':
return (
<span className="vtd-sig-status vtd-sig-status--match">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
Match
</span>
);
case 'pending':
return (
<span className="vtd-sig-status vtd-sig-status--pending">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 9v4"/><line x1="12" y1="17" x2="12.01" y2="17"/>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
Pending
</span>
);
case 'nomatch':
return (
<span className="vtd-sig-status vtd-sig-status--nomatch">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
No Match
</span>
);
default:
return <span className="vtd-sig-status vtd-sig-status--unknown"></span>;
}
}
const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
<div className="vtd-sig-table-wrap">
<div className="vtd-sig-table-head">REAL-TIME INDICATOR COMPARISON</div>
<table className="vtd-sig-table">
<thead>
<tr>
<th>TECHNICAL INDICATOR</th>
<th>CURRENT VALUE</th>
<th>STRATEGY THRESHOLD</th>
<th>PROGRESS</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{metrics.map(({ row, status, progress }) => (
<tr key={row.id}>
<td className="vtd-sig-table-ind">{row.displayName}</td>
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
<td className="vtd-sig-table-threshold">
{row.thresholdLabel
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
</td>
<td className="vtd-sig-table-progress">
<div className="vtd-sig-row-bar">
<div
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${status}`}
style={{ width: `${progress ?? 0}%` }}
/>
</div>
<span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span>
</td>
<td><StatusBadge status={status} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
export default VirtualIndicatorCompareTable;
@@ -0,0 +1,165 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MarketSearchPanel } from '../MarketSearchPanel';
import { getKoreanName } from '../../utils/marketNameCache';
import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
interface Props {
targets: VirtualTargetItem[];
globalStrategyId: number | null;
strategies: StrategyDto[];
selectedMarket: string;
onTargetsChange: (items: VirtualTargetItem[]) => void;
onSelectMarket: (market: string) => void;
}
const VirtualLeftTargetPanel: React.FC<Props> = ({
targets,
globalStrategyId,
strategies,
selectedMarket,
onTargetsChange,
onSelectMarket,
}) => {
const [query, setQuery] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
const closeDropdown = useCallback(() => {
setShowDropdown(false);
}, []);
const openDropdown = useCallback(() => {
setShowDropdown(true);
}, []);
useEffect(() => {
if (!showDropdown) return;
const onDocMouseDown = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', onDocMouseDown);
return () => document.removeEventListener('mousedown', onDocMouseDown);
}, [showDropdown]);
const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => {
if (addedSet.has(market)) return;
onTargetsChange([
...targets,
{
market,
strategyId: globalStrategyId,
koreanName: meta.koreanName,
englishName: meta.englishName,
},
]);
onSelectMarket(market);
setQuery('');
setShowDropdown(false);
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket]);
const handleRemove = useCallback((market: string) => {
onTargetsChange(targets.filter(t => t.market !== market));
}, [targets, onTargetsChange]);
const handleStrategyChange = useCallback((market: string, strategyId: number | null) => {
onTargetsChange(targets.map(t =>
t.market === market ? { ...t, strategyId } : t,
));
}, [targets, onTargetsChange]);
return (
<div className={`vtd-left${showDropdown ? ' vtd-left--dropdown-open' : ''}`}>
<section className="vtd-search-section" ref={searchRef}>
<div className="vtd-search-label"> ( )</div>
<input
className={`vtd-search-input${showDropdown ? ' vtd-search-input--open' : ''}`}
placeholder="종목명 또는 심볼 검색"
value={query}
onChange={e => {
setQuery(e.target.value);
setShowDropdown(true);
}}
onFocus={openDropdown}
onClick={openDropdown}
/>
{showDropdown && (
<div className="vtd-search-dropdown">
<MarketSearchPanel
variant="embedded"
currentMarket={selectedMarket}
query={query}
onQueryChange={setQuery}
actionMode="add"
addedMarkets={addedSet}
onAdd={handleAdd}
onSelect={onSelectMarket}
onClose={closeDropdown}
/>
</div>
)}
</section>
<section className="vtd-target-section">
<div className="vtd-target-list-head">
<span></span>
<span className="vtd-target-count">{targets.length}</span>
</div>
<div className="vtd-target-list">
{targets.length === 0 && (
<p className="vtd-muted"> + .</p>
)}
{targets.map(item => {
const ko = item.koreanName ?? getKoreanName(item.market);
const en = item.englishName ?? item.market.replace(/^KRW-/, '');
const active = item.market === selectedMarket;
return (
<div
key={item.market}
className={`vtd-target-item${active ? ' vtd-target-item--active' : ''}`}
onClick={() => onSelectMarket(item.market)}
>
<div className="vtd-target-item-main">
<div className="vtd-target-names">
<span className="vtd-target-ko">{ko}</span>
<span className="vtd-target-en">{en}</span>
</div>
<button
type="button"
className="vtd-target-remove"
title="목록에서 제거"
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
>
</button>
</div>
<label className="vtd-target-strat" onClick={e => e.stopPropagation()}>
<select
className="vtd-left-select"
value={item.strategyId ?? globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
handleStrategyChange(item.market, v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
</div>
);
})}
</div>
</section>
</div>
);
};
export default VirtualLeftTargetPanel;
@@ -0,0 +1,27 @@
import React from 'react';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
interface Props {
status: VirtualLiveStatus;
}
const LABEL: Record<VirtualLiveStatus, string | null> = {
idle: null,
connecting: '연결 중',
live: '실시간',
disconnected: '연결 끊김',
};
const VirtualLiveBadge: React.FC<Props> = ({ status }) => {
const label = LABEL[status];
if (!label) return null;
return (
<span className={`vtd-live-badge vtd-live-badge--${status}`}>
<span className="vtd-live-badge-dot" aria-hidden />
<span className="vtd-live-badge-text">{label}</span>
</span>
);
};
export default VirtualLiveBadge;
@@ -0,0 +1,64 @@
import React, { useMemo } from 'react';
const SEGMENTS = 25;
interface Props {
matchRate: number;
}
const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
const litCount = useMemo(
() => Math.round((Math.min(100, Math.max(0, matchRate)) / 100) * SEGMENTS),
[matchRate],
);
const segments = useMemo(() => {
const items: Array<{ lit: boolean; tone: 'blue' | 'gold' | 'peak' }> = [];
for (let i = 0; i < SEGMENTS; i++) {
const lit = i < litCount;
if (!lit) {
items.push({ lit: false, tone: 'blue' });
continue;
}
if (matchRate >= 100 && i === SEGMENTS - 1) {
items.push({ lit: true, tone: 'peak' });
} else if (i >= Math.floor(SEGMENTS * 0.55)) {
items.push({ lit: true, tone: 'gold' });
} else {
items.push({ lit: true, tone: 'blue' });
}
}
return items;
}, [litCount, matchRate]);
const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
return (
<div className="vtd-sig-eq">
<div className="vtd-sig-eq-scale">
{ticks.map(t => (
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
))}
</div>
<div className="vtd-sig-eq-bar-wrap">
<div className="vtd-sig-eq-bar">
{segments.map((seg, i) => (
<div
key={i}
className={[
'vtd-sig-eq-seg',
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
].filter(Boolean).join(' ')}
/>
))}
</div>
{matchRate >= 100 && (
<div className="vtd-sig-eq-badge">100% MATCH</div>
)}
</div>
</div>
);
};
export default VirtualSignalEqualizer;
@@ -0,0 +1,29 @@
import React from 'react';
import type { TrafficLightState } from '../../utils/virtualSignalMetrics';
interface Props {
state: TrafficLightState;
matchRate: number;
}
const LABELS: Record<TrafficLightState, string> = {
red: '조건 미충족',
yellow: '부분 일치 / 대기',
blue: '시그널 일치 (100%)',
};
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
<div className="vtd-sig-light">
<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>
<div className="vtd-sig-light-info">
<span className="vtd-sig-light-rate">{matchRate}%</span>
<span className="vtd-sig-light-label">{LABELS[state]}</span>
</div>
</div>
);
export default VirtualSignalTrafficLight;
@@ -0,0 +1,206 @@
/**
* 가상투자 — 종목×전략 차트 팝업 (캔들 + 전략 보조지표)
*/
import React, {
useCallback, useEffect, useMemo, useRef, useState,
} from 'react';
import AppPopup from '../AppPopup';
import TradingChart from '../TradingChart';
import type { StrategyDto } from '../../utils/backendApi';
import { pinCandleWatch } from '../../utils/backendApi';
import type {
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
} from '../../types';
import { getKoreanName } from '../../utils/marketNameCache';
import { isUpbitMarket } from '../../utils/upbitApi';
import { useChartRealtimeData, type WsStatus } from '../../hooks/useChartRealtimeData';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import type { ChartManager } from '../../utils/ChartManager';
import {
buildStrategyChartIndicators,
resolveStrategyPrimaryTimeframe,
resolveStrategyTimeframes,
} from '../../utils/strategyToChartIndicators';
import { timeframeToCandleType } from '../../utils/chartCandleType';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
interface Props {
market: string;
strategy: StrategyDto | undefined;
theme?: Theme;
running?: boolean;
onClose: () => void;
}
const noop = () => {};
function WsBadge({ status }: { status: WsStatus }) {
const map: Record<WsStatus, { cls: string; label: string }> = {
connecting: { cls: 'vtd-chart-ws--connecting', label: '연결 중' },
connected: { cls: 'vtd-chart-ws--live', label: '실시간' },
disconnected: { cls: 'vtd-chart-ws--off', label: '연결 끊김' },
error: { cls: 'vtd-chart-ws--off', label: '오류' },
};
const { cls, label } = map[status];
return (
<span className={`vtd-chart-ws ${cls}`}>
<span className="vtd-chart-ws-dot" aria-hidden />
{label}
</span>
);
}
const VirtualStrategyChartPopup: React.FC<Props> = ({
market,
strategy,
theme = 'dark',
running = false,
onClose,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy));
useEffect(() => {
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
}, [strategy, market]);
const indicators = useMemo(
() => buildStrategyChartIndicators(strategy, getParams, getVisualConfig),
[strategy, getParams, getVisualConfig],
);
const managerRef = useRef<ChartManager | null>(null);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const chartLiveReadyRef = useRef(false);
const marketRef = useRef(market);
marketRef.current = market;
const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true;
const pending = pendingBarRef.current;
const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
pendingBarRef.current = null;
mgr.updateBar(pending);
}, []);
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
if (barsMarketRef.current !== marketRef.current) return;
if (!chartLiveReadyRef.current || !managerRef.current) {
pendingBarRef.current = bar;
return;
}
if (append) managerRef.current.appendBar(bar);
else managerRef.current.updateBar(bar);
}, []);
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, false);
}, [applyRealtimeBar]);
const handleNewCandle = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, true);
}, [applyRealtimeBar]);
const useUpbit = isUpbitMarket(market);
const { bars, barsMarket, wsStatus, isLoading } = useChartRealtimeData(
market,
timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
}), [handleTickUpdate, handleNewCandle, useUpbit]),
);
barsMarketRef.current = barsMarket;
useEffect(() => {
chartLiveReadyRef.current = false;
pendingBarRef.current = null;
}, [market, timeframe]);
useEffect(() => {
if (!useUpbit) return;
void pinCandleWatch(market, timeframeToCandleType(timeframe));
if (timeframeToCandleType(timeframe) !== '1m') {
void pinCandleWatch(market, '1m');
}
}, [market, timeframe, useUpbit, running]);
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
const stratName = strategy?.name ?? '전략 미지정';
return (
<AppPopup
onClose={onClose}
title={sym}
titleKo={ko}
badge="CHART"
width={920}
maxWidth="96vw"
centered
backdrop
closeOnBackdrop
bodyClassName="app-popup-body vtd-chart-popup-body"
headerExtra={running ? <WsBadge status={wsStatus} /> : undefined}
>
<div className="vtd-chart-popup-meta">
<span className="vtd-chart-popup-strat">{stratName}</span>
{indicators.length > 0 && (
<span className="vtd-chart-popup-ind-count"> {indicators.length}</span>
)}
</div>
<div className="vtd-chart-popup-tf-row">
{tfOptions.map(tf => (
<button
key={tf}
type="button"
className={`vtd-chart-popup-tf${timeframe === tf ? ' vtd-chart-popup-tf--on' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
<div className="vtd-chart-popup-canvas-wrap">
{isLoading && <div className="vtd-chart-popup-loading"> </div>}
<TradingChart
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={timeframe}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
/>
</div>
{indicators.length === 0 && (
<p className="vtd-muted vtd-chart-popup-empty">
.
</p>
)}
</AppPopup>
);
};
export default VirtualStrategyChartPopup;
@@ -0,0 +1,101 @@
import React, { useMemo } from 'react';
import { getKoreanName } from '../../utils/marketNameCache';
import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import {
buildConditionMetrics,
computeMatchRate,
formatUpdatedTime,
getTrafficLightState,
} from '../../utils/virtualSignalMetrics';
import VirtualLiveBadge from './VirtualLiveBadge';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
interface Props {
market: string;
strategy: StrategyDto | undefined;
snapshot: VirtualIndicatorSnapshot | undefined;
running: boolean;
liveStatus?: VirtualLiveStatus;
viewMode?: VirtualCardViewMode;
onOpenChart?: () => void;
}
const VirtualTargetCard: React.FC<Props> = ({
market, strategy, snapshot, running, liveStatus = 'idle', viewMode = 'summary', onOpenChart,
}) => {
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
const tf = snapshot?.timeframe ?? '—';
const rows = snapshot?.rows ?? [];
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
const matchRate = useMemo(
() => computeMatchRate(metrics, snapshot?.matchRate),
[metrics, snapshot?.matchRate],
);
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
const metCount = metrics.filter(m => m.status === 'match').length;
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
const isDetail = viewMode === 'detail';
return (
<div className={`vtd-card vtd-card--signal${running ? ' vtd-card--live' : ''}${isDetail ? ' vtd-card--detail' : ' vtd-card--summary'}`}>
<div className="vtd-card-head">
<div className="vtd-card-title">
<span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span>
</div>
<div className="vtd-card-head-actions">
<button
type="button"
className="vtd-card-chart-btn"
title="차트 보기"
aria-label="차트 보기"
onClick={onOpenChart}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="4" width="18" height="14" rx="2"/>
<path d="M7 14l3-3 3 2 4-5"/>
</svg>
</button>
<span className="vtd-card-strat">{strategy?.name ?? '전략 미지정'}</span>
</div>
</div>
<div className="vtd-card-meta">
<span className="vtd-card-tf"> {tf}</span>
{isDetail && evalCount > 0 && (
<span className="vtd-card-met-count">{metCount}/{evalCount} </span>
)}
{running && <VirtualLiveBadge status={liveStatus} />}
</div>
{rows.length === 0 ? (
<p className="vtd-muted vtd-card-empty"> · </p>
) : (
<>
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="vtd-sig-visual">
<VirtualSignalEqualizer matchRate={matchRate} />
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
</div>
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {formatUpdatedTime(snapshot?.updatedAt)}</span>
<span className="vtd-card-match-summary"> {matchRate}%</span>
</div>
</>
)}
</div>
);
};
export default VirtualTargetCard;
@@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react';
import VirtualTargetCard from './VirtualTargetCard';
import VirtualStrategyChartPopup from './VirtualStrategyChartPopup';
import type { StrategyDto } from '../../utils/backendApi';
import type { Theme } from '../../types';
import type { VirtualTargetItem, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
interface Props {
targets: VirtualTargetItem[];
strategies: StrategyDto[];
snapshots: Record<string, VirtualIndicatorSnapshot>;
running: boolean;
globalStrategyId: number | null;
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
theme?: Theme;
}
const VirtualTargetGrid: React.FC<Props> = ({
targets, strategies, snapshots, running, globalStrategyId, liveStatusByMarket = {}, theme = 'dark',
}) => {
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
const [chartTarget, setChartTarget] = useState<{ market: string; strategy: StrategyDto | undefined } | null>(null);
useEffect(() => {
saveVirtualCardViewMode(viewMode);
}, [viewMode]);
if (targets.length === 0) {
return (
<div className="vtd-grid-empty">
<p className="vtd-muted"> .</p>
</div>
);
}
return (
<>
<div className="vtd-grid-wrap">
<div className="vtd-grid-head">
<span className="vtd-grid-head-title"> </span>
<div className="vtd-grid-head-right">
<div className="vtd-view-toggle" role="group" aria-label="카드 표시 방식">
<button
type="button"
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => setViewMode('summary')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => setViewMode('detail')}
>
</button>
</div>
{viewMode === 'detail' && (
<div className="vtd-grid-head-legend">
<span className="vtd-legend vtd-legend--match"> </span>
<span className="vtd-legend vtd-legend--pending"> </span>
<span className="vtd-legend vtd-legend--nomatch"> </span>
</div>
)}
{running && <span className="vtd-grid-live"> 3</span>}
</div>
</div>
<div className="vtd-grid">
{targets.map(t => {
const strat = strategies.find(s => s.id === (t.strategyId ?? globalStrategyId));
return (
<VirtualTargetCard
key={t.market}
market={t.market}
strategy={strat}
snapshot={snapshots[t.market]}
running={running}
liveStatus={liveStatusByMarket[t.market] ?? (running ? 'connecting' : 'idle')}
viewMode={viewMode}
onOpenChart={() => setChartTarget({ market: t.market, strategy: strat })}
/>
);
})}
</div>
</div>
{chartTarget && (
<VirtualStrategyChartPopup
market={chartTarget.market}
strategy={chartTarget.strategy}
theme={theme}
running={running}
onClose={() => setChartTarget(null)}
/>
)}
</>
);
};
export default VirtualTargetGrid;
@@ -0,0 +1,213 @@
/**
* 가상투자 카드 — 종목×전략별 지표·조건 스냅샷
* running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립)
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { StrategyDto } from '../utils/backendApi';
import {
fetchLiveConditionStatus,
pinCandleWatch,
type LiveConditionRowDto,
} from '../utils/backendApi';
import {
extractVirtualConditions,
type VirtualConditionRow,
} from '../utils/virtualStrategyConditions';
export interface VirtualIndicatorSnapshot {
market: string;
strategyId: number;
timeframe: string;
rows: Array<VirtualConditionRow & { currentValue: number | null }>;
updatedAt: number;
/** 백엔드 Ta4j 집계 일치율 (0~100) */
matchRate?: number;
}
function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'indicatorType' | 'conditionType' | 'targetValue' | 'plotKey'>): string {
return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`;
}
function liveFallbackKey(r: LiveConditionRowDto): string {
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${r.indicatorType}`;
}
function mergeRows(
base: VirtualConditionRow[],
live: LiveConditionRowDto[],
): Array<VirtualConditionRow & { currentValue: number | null }> {
const liveById = new Map<string, LiveConditionRowDto>();
const liveByKey = new Map<string, LiveConditionRowDto>();
for (const r of live) {
liveById.set(r.id, r);
liveByKey.set(liveFallbackKey(r), r);
}
if (base.length === 0) {
return live.map(r => ({
id: r.id,
indicatorType: r.indicatorType,
displayName: r.displayName,
conditionType: r.conditionType,
conditionLabel: r.conditionLabel,
targetValue: r.targetValue,
timeframe: r.timeframe,
side: r.side,
plotKey: r.indicatorType,
satisfied: r.satisfied,
thresholdLabel: r.thresholdLabel,
currentValue: r.currentValue,
}));
}
return base.map(row => {
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
if (!liveRow) {
return { ...row, currentValue: null as number | null };
}
return {
...row,
satisfied: liveRow.satisfied,
thresholdLabel: liveRow.thresholdLabel,
currentValue: liveRow.currentValue,
targetValue: liveRow.targetValue ?? row.targetValue,
};
});
}
async function buildSnapshot(
market: string,
strategyId: number,
strategy: StrategyDto | undefined,
running: boolean,
): Promise<VirtualIndicatorSnapshot | null> {
const baseRows = strategy ? extractVirtualConditions(strategy) : [];
if (!running) {
if (baseRows.length === 0) return null;
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => ({ ...r, currentValue: null })),
updatedAt: Date.now(),
matchRate: 0,
};
}
const status = await fetchLiveConditionStatus(market, strategyId);
if (!status || status.market !== market) {
if (baseRows.length === 0) return null;
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => ({ ...r, currentValue: null })),
updatedAt: Date.now(),
matchRate: 0,
};
}
return {
market,
strategyId,
timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: mergeRows(baseRows, status.rows),
updatedAt: status.updatedAt || Date.now(),
matchRate: status.matchRate,
};
}
interface TargetRef {
market: string;
strategyId: number | null;
}
export function useVirtualIndicatorSnapshots(
targets: TargetRef[],
strategies: StrategyDto[],
running: boolean,
pollMs = 3000,
): Record<string, VirtualIndicatorSnapshot> {
const [snapshots, setSnapshots] = useState<Record<string, VirtualIndicatorSnapshot>>({});
const strategiesRef = useRef(strategies);
strategiesRef.current = strategies;
const pinnedRef = useRef<Set<string>>(new Set());
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
const pinTargets = useCallback(async () => {
const strats = strategiesRef.current;
const pins = new Set<string>();
for (const t of targets) {
if (t.strategyId == null) continue;
const strat = strats.find(s => s.id === t.strategyId);
const conditions = strat ? extractVirtualConditions(strat) : [];
const tfs = conditions.length > 0
? [...new Set(conditions.map(c => c.timeframe))]
: ['1m'];
for (const tf of tfs) {
const key = `${t.market}:${tf}`;
pins.add(key);
if (!pinnedRef.current.has(key)) {
await pinCandleWatch(t.market, tf);
pinnedRef.current.add(key);
}
}
const k1 = `${t.market}:1m`;
if (!pins.has(k1) || !tfs.includes('1m')) {
if (!pinnedRef.current.has(k1)) {
await pinCandleWatch(t.market, '1m');
pinnedRef.current.add(k1);
}
}
}
for (const k of pinnedRef.current) {
if (!pins.has(k)) pinnedRef.current.delete(k);
}
}, [targets]);
const refresh = useCallback(async () => {
const strats = strategiesRef.current;
if (running) await pinTargets();
const jobs = targets
.filter(t => t.strategyId != null)
.map(async t => {
const strat = strats.find(s => s.id === t.strategyId);
const snap = await buildSnapshot(t.market, t.strategyId!, strat, running);
if (snap) {
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
}
return snap;
});
await Promise.all(jobs);
// 제거된 종목 스냅샷 정리
const activeMarkets = new Set(targets.map(t => t.market));
setSnapshots(prev => {
const next = { ...prev };
let changed = false;
for (const key of Object.keys(next)) {
if (!activeMarkets.has(key)) {
delete next[key];
changed = true;
}
}
return changed ? next : prev;
});
}, [targets, running, pinTargets]);
useEffect(() => {
if (targets.length === 0) {
setSnapshots({});
pinnedRef.current.clear();
return;
}
void refresh();
if (!running) return;
const id = window.setInterval(() => void refresh(), pollMs);
return () => clearInterval(id);
}, [targetsKey, running, pollMs, refresh]);
return snapshots;
}
@@ -0,0 +1,119 @@
/**
* 가상투자 — 종목별 STOMP 실시간 데이터 수신 상태
*/
import { useEffect, useRef, useState } from 'react';
import { pinCandleWatch } from '../utils/backendApi';
import { parseStompJson } from '../utils/stompMessage';
import {
isStompChartConnected,
subscribeStompConnection,
subscribeStompTopic,
} from '../utils/stompChartBroker';
/** idle=미시작, connecting=연결 중, live=틱 수신 중, disconnected=끊김 */
export type VirtualLiveStatus = 'idle' | 'connecting' | 'live' | 'disconnected';
const STALE_MS = 20_000;
const TICK_CANDLE = '1m';
interface TargetRef {
market: string;
strategyId: number | null;
}
interface MarketLiveState {
status: VirtualLiveStatus;
lastTickAt: number | null;
}
function topicFor(market: string): string {
return `/sub/charts/${market}/${TICK_CANDLE}`;
}
export function useVirtualTargetLiveStatus(
targets: TargetRef[],
running: boolean,
): Record<string, VirtualLiveStatus> {
const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({});
const stateRef = useRef<Record<string, MarketLiveState>>({});
const stompConnectedRef = useRef(isStompChartConnected());
const patchMarket = (market: string, patch: Partial<MarketLiveState>) => {
const prev = stateRef.current[market] ?? { status: 'idle' as VirtualLiveStatus, lastTickAt: null };
const next: MarketLiveState = { ...prev, ...patch };
stateRef.current[market] = next;
setByMarket(cur => ({ ...cur, [market]: next.status }));
};
const recomputeStatus = (market: string) => {
const s = stateRef.current[market];
if (!s || !running) return;
const now = Date.now();
if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) {
if (s.status !== 'live') patchMarket(market, { status: 'live' });
return;
}
if (stompConnectedRef.current) {
patchMarket(market, { status: 'connecting' });
} else {
patchMarket(market, { status: 'disconnected' });
}
};
useEffect(() => {
if (!running) {
stateRef.current = {};
setByMarket({});
return;
}
const activeMarkets = targets
.filter(t => t.strategyId != null)
.map(t => t.market);
for (const market of activeMarkets) {
if (!stateRef.current[market]) {
stateRef.current[market] = { status: 'connecting', lastTickAt: null };
setByMarket(cur => ({ ...cur, [market]: 'connecting' }));
}
void pinCandleWatch(market, TICK_CANDLE);
}
for (const key of Object.keys(stateRef.current)) {
if (!activeMarkets.includes(key)) {
delete stateRef.current[key];
setByMarket(cur => {
const next = { ...cur };
delete next[key];
return next;
});
}
}
const unsubs = activeMarkets.map(market => {
const topic = topicFor(market);
return subscribeStompTopic(topic, msg => {
const data = parseStompJson<{ time?: number }>(msg);
if (data?.time == null) return;
patchMarket(market, { status: 'live', lastTickAt: Date.now() });
});
});
const offConn = subscribeStompConnection(next => {
stompConnectedRef.current = next;
for (const market of activeMarkets) recomputeStatus(market);
});
const staleId = window.setInterval(() => {
for (const market of activeMarkets) recomputeStatus(market);
}, 3000);
return () => {
unsubs.forEach(u => u());
offConn();
clearInterval(staleId);
};
}, [targets, running]);
return byMarket;
}
File diff suppressed because it is too large Load Diff
+53
View File
@@ -1066,3 +1066,56 @@ export async function saveLiveStrategySettingsBulk(
})) ?? [];
return list;
}
// ─────────────────────────────────────────────────────────────────────────────
// 가상투자 — 실시간 조건 충족 현황 (Ta4j Rule.isSatisfied)
// ─────────────────────────────────────────────────────────────────────────────
export interface LiveConditionRowDto {
id: string;
indicatorType: string;
displayName: string;
conditionType: string;
conditionLabel: string;
thresholdLabel: string;
currentValue: number | null;
targetValue: number | null;
satisfied: boolean | null;
timeframe: string;
side: 'buy' | 'sell';
}
export interface LiveConditionStatusDto {
market: string;
strategyId: number;
timeframe: string;
matchRate: number;
rows: LiveConditionRowDto[];
updatedAt: number;
}
/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */
export async function fetchLiveConditionStatus(
market: string,
strategyId: number,
): Promise<LiveConditionStatusDto | null> {
const params = new URLSearchParams({
market,
strategyId: String(strategyId),
});
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
cache: 'no-store',
});
}
/** 차트 실시간 파이프라인 pin (STOMP warm-up) */
export async function pinCandleWatch(market: string, candleType: string): Promise<void> {
try {
await fetch(
`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`,
{ method: 'POST' },
);
} catch {
/* pin 실패해도 평가 API는 시도 */
}
}
+3 -2
View File
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
| 'paper' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'paper', 'strategy-editor', 'backtest', 'settings',
'dashboard', 'chart', 'paper', 'virtual', 'strategy-editor', 'backtest', 'settings',
];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
];
export const ALL_MENU_IDS = [
'dashboard', 'chart', 'paper', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
'dashboard', 'chart', 'paper', 'virtual', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
] as const;
@@ -30,6 +30,7 @@ export const MENU_LABELS: Record<string, string> = {
dashboard: '대시보드',
chart: '실시간차트',
paper: '모의투자',
virtual: '가상투자',
strategy: '투자전략',
'strategy-editor': '전략편집기',
backtest: '백테스팅',
+18 -4
View File
@@ -7,6 +7,7 @@ import SockJS from 'sockjs-client';
import { getStompSockJsUrl } from './backendApi';
type MessageHandler = (msg: IMessage) => void;
type ConnectionListener = (connected: boolean) => void;
interface TopicEntry {
handlers: Set<MessageHandler>;
@@ -16,8 +17,14 @@ interface TopicEntry {
let client: StompClient | null = null;
let connected = false;
const topics = new Map<string, TopicEntry>();
const connectionListeners = new Set<ConnectionListener>();
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
function notifyConnection(next: boolean): void {
connected = next;
connectionListeners.forEach(l => l(next));
}
function handlerCount(): number {
let n = 0;
for (const entry of topics.values()) n += entry.handlers.size;
@@ -51,12 +58,12 @@ function getOrCreateClient(): StompClient {
heartbeatIncoming: 10_000,
heartbeatOutgoing: 10_000,
onConnect: () => {
connected = true;
cancelPendingDisconnect();
notifyConnection(true);
resubscribeAll();
},
onDisconnect: () => {
connected = false;
notifyConnection(false);
for (const entry of topics.values()) {
entry.stompSub = undefined;
}
@@ -68,7 +75,7 @@ function getOrCreateClient(): StompClient {
console.warn('[stompChartBroker] WebSocket error', getStompSockJsUrl(), ev);
},
onWebSocketClose: () => {
connected = false;
notifyConnection(false);
for (const entry of topics.values()) {
entry.stompSub = undefined;
}
@@ -95,10 +102,17 @@ function scheduleDisconnect(): void {
return;
}
client.deactivate();
connected = false;
notifyConnection(false);
}, 5000);
}
/** STOMP 연결 상태 변경 구독 */
export function subscribeStompConnection(listener: ConnectionListener): () => void {
connectionListeners.add(listener);
listener(connected && Boolean(client?.connected));
return () => { connectionListeners.delete(listener); };
}
/**
* STOMP ( ). .
*/
@@ -0,0 +1,246 @@
/**
* DSL IndicatorConfig[] ( )
*/
import type { StrategyDto } from './backendApi';
import type { IndicatorConfig, Timeframe } from '../types';
import type { LogicNode } from './strategyTypes';
import {
enrichIndicatorConfig,
getIndicatorDef,
getHLineLabel,
type HLineDef,
} from './indicatorRegistry';
import { createDefaultSmaPlotVisibility, normalizeSmaConfig, smaPeriodKey } from './smaConfig';
import { normalizeIchimokuConfig } from './ichimokuConfig';
import { extractVirtualConditions } from './virtualStrategyConditions';
const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI',
MACD: 'MACD',
MA: 'SMA',
EMA: 'EMA',
BOLLINGER: 'BollingerBands',
STOCHASTIC: 'Stochastic',
WILLIAMS_R: 'WilliamsPercentRange',
CCI: 'CCI',
ADX: 'ADX',
DMI: 'DMI',
OBV: 'OBV',
TRIX: 'TRIX',
VOLUME_OSC: 'VolumeOscillator',
VR: 'VR',
DISPARITY: 'Disparity',
PSYCHOLOGICAL: 'Psychological',
NEW_PSYCHOLOGICAL: 'Psychological',
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
BWI: 'BBBandWidth',
DONCHIAN: 'DonchianChannels',
ICHIMOKU: 'IchimokuCloud',
ATR: 'ATR',
MFI: 'MFI',
};
interface IndicatorRef {
dslType: string;
registryType: string;
period?: number;
leftPeriod?: number;
rightPeriod?: number;
targetValue?: number | null;
}
type ParamRecord = Record<string, number | string | boolean>;
type GetParams = (type: string, defaults?: ParamRecord) => ParamRecord;
type GetVisual = (
type: string,
plots: import('./indicatorRegistry').PlotDef[],
hlines: HLineDef[],
) => {
plots: import('./indicatorRegistry').PlotDef[];
hlines: HLineDef[];
cloudColors?: import('./ichimokuConfig').IchimokuCloudColors;
};
let _idSeq = 0;
function newIndId(): string {
_idSeq += 1;
return `vstrat_${_idSeq}_${Date.now()}`;
}
export function candleTypeToTimeframe(candleType: string): Timeframe {
const c = (candleType ?? '1m').trim().toLowerCase();
if (c === '1d') return '1D';
if (c === '1h') return '1h';
if (c === '4h') return '4h';
if (['1m', '3m', '5m', '15m', '30m'].includes(c)) return c as Timeframe;
return '1m';
}
export function resolveStrategyTimeframes(strategy: StrategyDto | undefined): Timeframe[] {
if (!strategy) return ['1m'];
const conditions = extractVirtualConditions(strategy);
const set = new Set<Timeframe>();
for (const row of conditions) {
set.add(candleTypeToTimeframe(row.timeframe));
}
if (set.size === 0) set.add('1m');
return [...set].sort((a, b) => {
const order: Timeframe[] = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1D', '1W', '1M'];
return order.indexOf(a) - order.indexOf(b);
});
}
export function resolveStrategyPrimaryTimeframe(strategy: StrategyDto | undefined): Timeframe {
const tfs = resolveStrategyTimeframes(strategy);
return tfs[0] ?? '1m';
}
function walkIndicatorRefs(
node: LogicNode | null | undefined,
out: IndicatorRef[],
seen: Set<string>,
): void {
if (!node) return;
if (node.type === 'TIMEFRAME') {
node.children?.forEach(c => walkIndicatorRefs(c, out, seen));
return;
}
if (node.type === 'CONDITION' && node.condition) {
const c = node.condition;
const dslType = c.indicatorType;
const registryType = DSL_TO_REGISTRY[dslType] ?? dslType;
const key = `${registryType}:${c.period ?? ''}:${c.leftPeriod ?? ''}:${c.rightPeriod ?? ''}:${c.targetValue ?? ''}`;
if (seen.has(key)) return;
seen.add(key);
out.push({
dslType,
registryType,
period: c.period,
leftPeriod: c.leftPeriod,
rightPeriod: c.rightPeriod,
targetValue: c.targetValue ?? c.compareValue ?? null,
});
return;
}
node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen));
}
function collectIndicatorRefs(strategy: StrategyDto): IndicatorRef[] {
const out: IndicatorRef[] = [];
const seen = new Set<string>();
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
return out;
}
function applyTargetHlines(
hlines: HLineDef[],
target: number | null | undefined,
): HLineDef[] {
if (target == null || Number.isNaN(target)) return hlines;
const next = hlines.map(h => ({ ...h }));
const prices = next.map(h => h.price);
const idx = next.findIndex(h => Math.abs(h.price - target) < 1e-6);
if (idx >= 0) {
next[idx] = { ...next[idx], visible: true, price: target };
return next;
}
next.push({
price: target,
color: '#ffb300',
label: getHLineLabel(target, [...prices, target]),
visible: true,
lineStyle: 'dashed',
lineWidth: 1,
});
return next;
}
function buildOneIndicator(
ref: IndicatorRef,
getParams: GetParams,
getVisual: GetVisual,
): IndicatorConfig | null {
const def = getIndicatorDef(ref.registryType);
if (!def) return null;
const params = getParams(ref.registryType, def.defaultParams as ParamRecord);
const { plots, hlines, cloudColors } = getVisual(ref.registryType, def.plots, def.hlines ?? []);
let cfg: IndicatorConfig = {
id: newIndId(),
type: def.type,
params: { ...params },
plots,
hlines,
...(cloudColors ? { cloudColors } : {}),
};
if (ref.registryType === 'SMA') {
const periods = [ref.period, ref.leftPeriod, ref.rightPeriod]
.filter((p): p is number => typeof p === 'number' && p > 0);
if (periods.length > 0) {
const p = { ...cfg.params };
periods.slice(0, 11).forEach((val, i) => {
p[smaPeriodKey(i + 1)] = val;
});
cfg = { ...cfg, params: p };
}
cfg = normalizeSmaConfig({
...cfg,
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
});
} else if (ref.period != null && ref.period > 0) {
cfg = {
...cfg,
params: { ...cfg.params, length: ref.period },
};
}
if (ref.registryType === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg);
}
if (ref.targetValue != null) {
cfg = { ...cfg, hlines: applyTargetHlines(cfg.hlines ?? [], ref.targetValue) };
}
return enrichIndicatorConfig(cfg);
}
/** 전략에 포함된 보조지표를 차트용 IndicatorConfig 로 변환 (중복 type 제거) */
export function buildStrategyChartIndicators(
strategy: StrategyDto | undefined,
getParams: GetParams,
getVisual: GetVisual,
): IndicatorConfig[] {
if (!strategy) return [];
const refs = collectIndicatorRefs(strategy);
const byType = new Map<string, IndicatorRef>();
for (const ref of refs) {
const existing = byType.get(ref.registryType);
if (!existing) {
byType.set(ref.registryType, ref);
continue;
}
byType.set(ref.registryType, {
...existing,
period: existing.period ?? ref.period,
leftPeriod: existing.leftPeriod ?? ref.leftPeriod,
rightPeriod: existing.rightPeriod ?? ref.rightPeriod,
targetValue: existing.targetValue ?? ref.targetValue,
});
}
const result: IndicatorConfig[] = [];
for (const ref of byType.values()) {
const cfg = buildOneIndicator(ref, getParams, getVisual);
if (cfg) result.push(cfg);
}
return result;
}
+154
View File
@@ -0,0 +1,154 @@
import {
formatIndicatorValue,
isConditionMet,
type VirtualConditionRow,
} from './virtualStrategyConditions';
export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown';
export interface ConditionMetric {
row: VirtualConditionRow & { currentValue: number | null };
status: ConditionStatus;
progress: number | null;
}
export type TrafficLightState = 'red' | 'yellow' | 'blue';
const PENDING_PROGRESS = 72;
/** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */
export function computeConditionProgress(
conditionType: string,
current: number | null,
target: number | null,
): number | null {
if (current == null || target == null) return null;
const met = isConditionMet(conditionType, current, target);
if (met === true) return 100;
const absTarget = Math.abs(target);
const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(current), 1);
switch (conditionType) {
case 'LT':
case 'CROSS_DOWN':
case 'LTE': {
if (current <= target) return 100;
const gap = current - target;
return Math.min(99, Math.max(0, 100 - (gap / scale) * 100));
}
case 'GT':
case 'CROSS_UP':
case 'GTE': {
if (current >= target) return 100;
if (target <= 0 && current <= 0) return 0;
return Math.min(99, Math.max(0, (current / scale) * 100));
}
case 'EQ': {
const diff = Math.abs(current - target);
return Math.min(99, Math.max(0, 100 - (diff / scale) * 100));
}
default:
return Math.min(99, Math.max(0, 100 - (Math.abs(current - target) / scale) * 100));
}
}
export function getConditionStatus(
conditionType: string,
current: number | null,
target: number | null,
satisfied?: boolean | null,
): ConditionStatus {
if (satisfied === true) return 'match';
if (satisfied === false) {
const progress = computeConditionProgress(conditionType, current, target);
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
return 'nomatch';
}
const met = isConditionMet(conditionType, current, target);
if (met === true) return 'match';
if (met === false) {
const progress = computeConditionProgress(conditionType, current, target);
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
return 'nomatch';
}
return 'unknown';
}
export function formatStrategyThreshold(
conditionType: string,
target: number | null,
conditionLabel: string,
): string {
if (target == null) return '—';
const v = formatIndicatorValue(target);
switch (conditionType) {
case 'LT':
case 'CROSS_DOWN':
return `< ${v}`;
case 'GT':
case 'CROSS_UP':
return `> ${v}`;
case 'GTE':
return `${v}`;
case 'LTE':
return `${v}`;
case 'EQ':
return `= ${v}`;
case 'NEQ':
return `${v}`;
default:
return `${conditionLabel} ${v}`;
}
}
export function buildConditionMetrics(
rows: Array<VirtualConditionRow & { currentValue: number | null }>,
): ConditionMetric[] {
return rows.map(row => ({
row,
status: getConditionStatus(
row.conditionType,
row.currentValue,
row.targetValue,
row.satisfied,
),
progress: row.satisfied === true
? 100
: row.satisfied === false
? (computeConditionProgress(row.conditionType, row.currentValue, row.targetValue) ?? 0)
: computeConditionProgress(row.conditionType, row.currentValue, row.targetValue),
}));
}
/** 전체 매매조건 일치율 — 백엔드 matchRate 우선 */
export function computeMatchRate(
metrics: ConditionMetric[],
backendMatchRate?: number,
): number {
if (backendMatchRate != null && Number.isFinite(backendMatchRate)) {
return Math.round(Math.min(100, Math.max(0, backendMatchRate)));
}
const evaluable = metrics.filter(m => m.status !== 'unknown');
if (evaluable.length === 0) return 0;
const met = evaluable.filter(m => m.status === 'match').length;
return Math.round((met / evaluable.length) * 100);
}
export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState {
if (matchRate >= 100) return 'blue';
const hasPending = metrics.some(m => m.status === 'pending');
const hasPartial = metrics.some(m => m.status === 'match');
if (hasPending || hasPartial || matchRate >= 40) return 'yellow';
return 'red';
}
export function formatUpdatedTime(ts: number | undefined): string {
if (!ts) return '—';
return new Date(ts).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
@@ -0,0 +1,100 @@
/**
* DSL
*/
import type { LogicNode } from './strategyTypes';
import { CONDITION_LABEL } from './strategyTypes';
import type { StrategyDto } from './backendApi';
import { getIndicatorDef } from './indicatorRegistry';
export interface VirtualConditionRow {
id: string;
indicatorType: string;
displayName: string;
conditionType: string;
conditionLabel: string;
targetValue: number | null;
timeframe: string;
side: 'buy' | 'sell';
/** 지표 계산 plot id (RSI, MACD 등) */
plotKey: string;
/** 백엔드 Ta4j Rule.isSatisfied 결과 (있으면 우선) */
satisfied?: boolean | null;
/** 백엔드 임계값 라벨 (MA cross 등) */
thresholdLabel?: string;
}
function walk(
node: LogicNode | null | undefined,
timeframe: string,
side: 'buy' | 'sell',
out: VirtualConditionRow[],
seen: Set<string>,
): void {
if (!node) return;
if (node.type === 'TIMEFRAME') {
const tf = node.candleType ?? timeframe;
node.children?.forEach(c => walk(c, tf, side, out, seen));
return;
}
if (node.type === 'CONDITION' && node.condition) {
const c = node.condition;
const key = `${side}:${timeframe}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`;
if (seen.has(key)) return;
seen.add(key);
const def = getIndicatorDef(c.indicatorType);
const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType;
const target = c.targetValue ?? c.compareValue ?? null;
const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType;
out.push({
id: `${node.id}-${side}`,
indicatorType: c.indicatorType,
displayName: def?.koreanName ?? def?.shortName ?? c.indicatorType,
conditionType: c.conditionType,
conditionLabel: condLabel,
targetValue: target,
timeframe,
side,
plotKey,
});
return;
}
node.children?.forEach(c => walk(c, timeframe, side, out, seen));
}
export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] {
if (!strategy) return [];
const out: VirtualConditionRow[] = [];
const seen = new Set<string>();
walk(strategy.buyCondition as LogicNode | null, '1m', 'buy', out, seen);
walk(strategy.sellCondition as LogicNode | null, '1m', 'sell', out, seen);
return out;
}
/** 조건 충족 여부 (단순 임계값 비교) */
export function isConditionMet(
conditionType: string,
current: number | null,
target: number | null,
): boolean | null {
if (current == null || target == null) return null;
switch (conditionType) {
case 'GT': case 'CROSS_UP': return current > target;
case 'LT': case 'CROSS_DOWN': return current < target;
case 'GTE': return current >= target;
case 'LTE': return current <= target;
case 'EQ': return Math.abs(current - target) < 1e-6;
case 'NEQ': return Math.abs(current - target) >= 1e-6;
default: return null;
}
}
export function formatIndicatorValue(v: number | null): string {
if (v == null || Number.isNaN(v)) return '—';
if (Math.abs(v) >= 1000) return v.toLocaleString(undefined, { maximumFractionDigits: 0 });
return v.toFixed(2);
}
@@ -0,0 +1,86 @@
/** 가상투자 대상 종목·세션 설정 localStorage */
export interface VirtualTargetItem {
market: string;
strategyId: number | null;
koreanName?: string;
englishName?: string;
}
export interface VirtualSessionConfig {
globalStrategyId: number | null;
executionType: 'CANDLE_CLOSE' | 'REALTIME_TICK';
/** LONG_ONLY = 보유 자산 기준, SIGNAL_ONLY = 순수 지표 기준 */
positionMode: 'LONG_ONLY' | 'SIGNAL_ONLY';
running: boolean;
}
const TARGETS_KEY = 'gc_virtual_targets_v1';
const SESSION_KEY = 'gc_virtual_session_v1';
const CARD_VIEW_KEY = 'gc_virtual_card_view_v1';
/** 카드 표시: summary=이퀄라이저·신호등·푸터만, detail=지표 테이블 포함 전체 */
export type VirtualCardViewMode = 'summary' | 'detail';
export function loadVirtualTargets(): VirtualTargetItem[] {
try {
const raw = localStorage.getItem(TARGETS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as VirtualTargetItem[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
try {
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
} catch { /* ignore */ }
}
export function loadVirtualSession(): VirtualSessionConfig {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return defaultSession();
const parsed = JSON.parse(raw) as Partial<VirtualSessionConfig>;
return {
globalStrategyId: parsed.globalStrategyId ?? null,
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
running: parsed.running === true,
};
} catch {
return defaultSession();
}
}
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
} catch { /* ignore */ }
}
function defaultSession(): VirtualSessionConfig {
return {
globalStrategyId: null,
executionType: 'CANDLE_CLOSE',
positionMode: 'LONG_ONLY',
running: false,
};
}
export function loadVirtualCardViewMode(): VirtualCardViewMode {
try {
const raw = localStorage.getItem(CARD_VIEW_KEY);
return raw === 'detail' ? 'detail' : 'summary';
} catch {
return 'summary';
}
}
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
try {
localStorage.setItem(CARD_VIEW_KEY, mode);
} catch { /* ignore */ }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB