가상투자 메뉴 기능 구현
This commit is contained in:
@@ -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&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);
|
||||
@@ -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
|
||||
|
||||
@@ -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,21 +219,23 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
||||
const PANEL_Z = 99999;
|
||||
|
||||
const panelStyle: React.CSSProperties | undefined = anchorRect
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchorRect.top + HEADER_H,
|
||||
left: anchorRect.left,
|
||||
width: Math.max(anchorRect.width, MIN_W),
|
||||
height: anchorRect.height - HEADER_H,
|
||||
maxWidth: anchorRect.width,
|
||||
zIndex: PANEL_Z,
|
||||
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
|
||||
background: 'var(--bg2, #24283b)',
|
||||
color: 'var(--text, #c0caf5)',
|
||||
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
||||
}
|
||||
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
|
||||
const panelStyle: React.CSSProperties | undefined = embedded
|
||||
? undefined
|
||||
: anchorRect
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchorRect.top + HEADER_H,
|
||||
left: anchorRect.left,
|
||||
width: Math.max(anchorRect.width, MIN_W),
|
||||
height: anchorRect.height - HEADER_H,
|
||||
maxWidth: anchorRect.width,
|
||||
zIndex: PANEL_Z,
|
||||
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
|
||||
background: 'var(--bg2, #24283b)',
|
||||
color: 'var(--text, #c0caf5)',
|
||||
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
||||
}
|
||||
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
|
||||
|
||||
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
|
||||
const backdropStyle: React.CSSProperties | undefined = anchorRect
|
||||
@@ -206,22 +251,26 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const handleRowSelect = useCallback((market: string) => {
|
||||
tryAddMarket(market);
|
||||
}, [tryAddMarket]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 배경 오버레이 (클릭 시 닫기) */}
|
||||
<div
|
||||
className="msp-backdrop"
|
||||
style={backdropStyle}
|
||||
onClick={onClose}
|
||||
/>
|
||||
{!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>
|
||||
<span className="msp-col msp-col-price">현재가</span>
|
||||
<span className="msp-col msp-col-change">전일대비</span>
|
||||
<span className="msp-col msp-col-vol">거래대금</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,40 +334,57 @@ 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)}
|
||||
>
|
||||
{/* 즐겨찾기 */}
|
||||
<span
|
||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||
onClick={e => toggleFav(m.market, e)}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
{isFav ? '★' : '☆'}
|
||||
</span>
|
||||
{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)}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* 현재가 */}
|
||||
<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>
|
||||
{!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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,14 +208,26 @@ export default function BuilderPageShell({
|
||||
</main>
|
||||
|
||||
{right && (
|
||||
<aside className="bps-right">
|
||||
{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>
|
||||
</div>
|
||||
) : null)}
|
||||
<div className="bps-right-body">{right}</div>
|
||||
</aside>
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
) : 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 & 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
@@ -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는 시도 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '백테스팅',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user