가상투자 메뉴 기능 구현
This commit is contained in:
@@ -7,7 +7,7 @@ public final class MenuIds {
|
|||||||
private MenuIds() {}
|
private MenuIds() {}
|
||||||
|
|
||||||
public static final List<String> ALL = List.of(
|
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_general", "settings_chart", "settings_indicators", "settings_backtest",
|
||||||
"settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin"
|
"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)
|
@Transactional(readOnly = true)
|
||||||
public List<LiveStrategySettingsDto> listActive(Long userId, String deviceId) {
|
public List<LiveStrategySettingsDto> listActive(Long userId, String deviceId) {
|
||||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
List<GcLiveStrategySettings> rows = userId != null
|
||||||
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
|
? repo.findByUserIdAndIsLiveCheckTrue(userId)
|
||||||
|
: repo.findByDeviceIdAndIsLiveCheckTrue(
|
||||||
|
deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous");
|
||||||
|
if (rows.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
List<String> watchlist = listWatchlistSymbols(userId, deviceId);
|
return rows.stream()
|
||||||
Long strategyId = app.getLiveStrategyId();
|
.map(this::toDto)
|
||||||
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())
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +118,17 @@ public class LiveStrategySettingsService {
|
|||||||
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
||||||
LiveStrategySettingsDto dto) {
|
LiveStrategySettingsDto dto) {
|
||||||
persistGlobalTemplate(userId, deviceId, 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);
|
syncAllWatchlistMarkets(userId, deviceId, dto);
|
||||||
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
|
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
|
||||||
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
|
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
|
||||||
@@ -139,6 +143,10 @@ public class LiveStrategySettingsService {
|
|||||||
if (dto.getPositionMode() != null) {
|
if (dto.getPositionMode() != null) {
|
||||||
out.setPositionMode(dto.getPositionMode());
|
out.setPositionMode(dto.getPositionMode());
|
||||||
}
|
}
|
||||||
|
if (dto.getStrategyId() != null) {
|
||||||
|
out.setStrategyId(dto.getStrategyId());
|
||||||
|
out.setLiveCheck(dto.isLiveCheck());
|
||||||
|
}
|
||||||
enrichStrategyTimeframes(out);
|
enrichStrategyTimeframes(out);
|
||||||
return 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) {
|
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
|
||||||
String ct = node.path("candleType").asText("1m");
|
String ct = node.path("candleType").asText("1m");
|
||||||
BarSeries branchSeries = ctx.resolveSeries(ct);
|
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 클라이언트 없이도 백엔드가 틱·캔들을 수집하도록 마켓을 고정 구독한다.
|
* 실시간 전략 체크 등 STOMP 클라이언트 없이도 백엔드가 틱·캔들을 수집하도록 마켓을 고정 구독한다.
|
||||||
* (관심종목 모니터링 — 프론트가 해당 토픽을 구독하면 평가·시그널 발행이 연결됨)
|
|
||||||
*/
|
*/
|
||||||
public void ensureMarketPinned(String market, String candleType) {
|
public void ensureMarketPinned(String market, String candleType) {
|
||||||
String key = market + ":" + 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 { BacktestHistoryPage } from './components/BacktestHistoryPage';
|
||||||
import SettingsPage from './components/SettingsPage';
|
import SettingsPage from './components/SettingsPage';
|
||||||
import PaperTradingPage from './components/PaperTradingPage';
|
import PaperTradingPage from './components/PaperTradingPage';
|
||||||
|
import VirtualTradingPage from './components/VirtualTradingPage';
|
||||||
import DashboardPage from './components/DashboardPage';
|
import DashboardPage from './components/DashboardPage';
|
||||||
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
||||||
import ChartLegendBar from './components/ChartLegendBar';
|
import ChartLegendBar from './components/ChartLegendBar';
|
||||||
@@ -99,6 +100,7 @@ import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
import './styles/appPopup.css';
|
import './styles/appPopup.css';
|
||||||
import './styles/paperDashboard.css';
|
import './styles/paperDashboard.css';
|
||||||
|
import './styles/virtualTradingDashboard.css';
|
||||||
import './styles/backtestDashboard.css';
|
import './styles/backtestDashboard.css';
|
||||||
|
|
||||||
let _indCounter = 0;
|
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' && (
|
{menuPage === 'notifications' && (
|
||||||
<TradeNotificationListPage
|
<TradeNotificationListPage
|
||||||
|
|||||||
@@ -42,16 +42,35 @@ interface MarketSearchPanelProps {
|
|||||||
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
|
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
|
||||||
*/
|
*/
|
||||||
anchorRect?: DOMRect | null;
|
anchorRect?: DOMRect | null;
|
||||||
|
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
|
||||||
|
variant?: 'overlay' | 'embedded';
|
||||||
/** 외부 입력과 검색어 동기화 */
|
/** 외부 입력과 검색어 동기화 */
|
||||||
initialQuery?: string;
|
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> = ({
|
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||||
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
|
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
|
||||||
|
variant = 'overlay', query: controlledQuery, onQueryChange,
|
||||||
|
actionMode = 'favorite', onAdd, addedMarkets,
|
||||||
}) => {
|
}) => {
|
||||||
|
const embedded = variant === 'embedded';
|
||||||
const [markets, setMarkets] = useState<MarketInfo[]>([]);
|
const [markets, setMarkets] = useState<MarketInfo[]>([]);
|
||||||
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
|
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 [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -84,13 +103,13 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
})
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 50);
|
if (!embedded) setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
refreshFavs();
|
refreshFavs();
|
||||||
}, [refreshFavs]);
|
}, [refreshFavs, embedded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuery(initialQuery);
|
if (controlledQuery === undefined) setInternalQuery(initialQuery);
|
||||||
}, [initialQuery]);
|
}, [initialQuery, controlledQuery]);
|
||||||
|
|
||||||
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
|
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -145,6 +164,30 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [markets]);
|
}, [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. 필터 + 정렬 ───────────────────────────────────────────────────────
|
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
const filtered = markets.filter(m => {
|
const filtered = markets.filter(m => {
|
||||||
@@ -176,7 +219,9 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
||||||
const PANEL_Z = 99999;
|
const PANEL_Z = 99999;
|
||||||
|
|
||||||
const panelStyle: React.CSSProperties | undefined = anchorRect
|
const panelStyle: React.CSSProperties | undefined = embedded
|
||||||
|
? undefined
|
||||||
|
: anchorRect
|
||||||
? {
|
? {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: anchorRect.top + HEADER_H,
|
top: anchorRect.top + HEADER_H,
|
||||||
@@ -206,22 +251,26 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const handleRowSelect = useCallback((market: string) => {
|
||||||
|
tryAddMarket(market);
|
||||||
|
}, [tryAddMarket]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 배경 오버레이 (클릭 시 닫기) */}
|
{!embedded && (
|
||||||
<div
|
<div
|
||||||
className="msp-backdrop"
|
className="msp-backdrop"
|
||||||
style={backdropStyle}
|
style={backdropStyle}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
|
|
||||||
<div
|
<div
|
||||||
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}`}
|
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}${embedded ? ' msp-panel--embedded' : ''}`}
|
||||||
style={panelStyle}
|
style={panelStyle}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* 검색 입력 */}
|
{!embedded && (
|
||||||
<div className="msp-search-row">
|
<div className="msp-search-row">
|
||||||
<span className="msp-search-icon">
|
<span className="msp-search-icon">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onClose();
|
||||||
if (e.key === 'Enter' && sorted.length > 0) {
|
if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market);
|
||||||
onSelect(sorted[0].market);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
@@ -255,14 +301,18 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 컬럼 헤더 */}
|
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}`}>
|
||||||
<div className="msp-header">
|
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
|
||||||
<span className="msp-col msp-col-fav" />
|
|
||||||
<span className="msp-col msp-col-name">한글명</span>
|
<span className="msp-col msp-col-name">한글명</span>
|
||||||
|
{!embedded && (
|
||||||
|
<>
|
||||||
<span className="msp-col msp-col-price">현재가</span>
|
<span className="msp-col msp-col-price">현재가</span>
|
||||||
<span className="msp-col msp-col-change">전일대비</span>
|
<span className="msp-col msp-col-change">전일대비</span>
|
||||||
<span className="msp-col msp-col-vol">거래대금</span>
|
<span className="msp-col msp-col-vol">거래대금</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 목록 */}
|
{/* 목록 */}
|
||||||
@@ -276,6 +326,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
{sorted.map(m => {
|
{sorted.map(m => {
|
||||||
const tk = tickers[m.market];
|
const tk = tickers[m.market];
|
||||||
const isFav = favs.has(m.market);
|
const isFav = favs.has(m.market);
|
||||||
|
const isAdded = addedMarkets?.has(m.market) ?? false;
|
||||||
const isActive = m.market === currentMarket;
|
const isActive = m.market === currentMarket;
|
||||||
const rate = tk ? tk.signed_change_rate * 100 : null;
|
const rate = tk ? tk.signed_change_rate * 100 : null;
|
||||||
const isUp = rate !== null && rate >= 0;
|
const isUp = rate !== null && rate >= 0;
|
||||||
@@ -283,10 +334,28 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.market}
|
key={m.market}
|
||||||
className={`msp-item${isActive ? ' active' : ''}`}
|
className={`msp-item${embedded ? ' msp-item--embedded' : ''}${isActive ? ' active' : ''}`}
|
||||||
onClick={() => { onSelect(m.market); onClose(); }}
|
onClick={() => handleRowSelect(m.market)}
|
||||||
>
|
>
|
||||||
{/* 즐겨찾기 */}
|
{actionMode === 'add' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
|
||||||
|
onClick={e => handleAddClick(m.market, e)}
|
||||||
|
disabled={isAdded}
|
||||||
|
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
|
||||||
|
>
|
||||||
|
{isAdded ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||||
onClick={e => toggleFav(m.market, e)}
|
onClick={e => toggleFav(m.market, e)}
|
||||||
@@ -294,29 +363,28 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{isFav ? '★' : '☆'}
|
{isFav ? '★' : '☆'}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 이름 */}
|
|
||||||
<span className="msp-col msp-col-name">
|
<span className="msp-col msp-col-name">
|
||||||
<span className="msp-kor">{m.korean_name}</span>
|
<span className="msp-kor">{m.korean_name}</span>
|
||||||
<span className="msp-sym">{m.symbol}</span>
|
<span className="msp-sym">{m.symbol}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 현재가 */}
|
{!embedded && (
|
||||||
|
<>
|
||||||
<span className="msp-col msp-col-price">
|
<span className="msp-col msp-col-price">
|
||||||
{tk ? tk.trade_price.toLocaleString() : '-'}
|
{tk ? tk.trade_price.toLocaleString() : '-'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 전일대비 */}
|
|
||||||
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
|
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
|
||||||
{rate === null
|
{rate === null
|
||||||
? '-'
|
? '-'
|
||||||
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
|
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 거래대금 */}
|
|
||||||
<span className="msp-col msp-col-vol">
|
<span className="msp-col msp-col-vol">
|
||||||
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
|
|||||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||||
import { canAccessMenu } from '../utils/permissions';
|
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 {
|
interface TopMenuBarProps {
|
||||||
activePage: MenuPage;
|
activePage: MenuPage;
|
||||||
@@ -129,10 +129,19 @@ const IcStrategyEditor = () => (
|
|||||||
</svg>
|
</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 }[] = [
|
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||||
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
||||||
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
||||||
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
|
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
|
||||||
|
{ page: 'virtual', label: '가상투자', icon: <IcVirtual /> },
|
||||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
{ 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 type { Theme } from '../../types';
|
||||||
import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize';
|
import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize';
|
||||||
import '../../styles/strategyEditorTheme.css';
|
import '../../styles/strategyEditorTheme.css';
|
||||||
@@ -7,6 +7,9 @@ import '../../styles/builderPageShell.css';
|
|||||||
const LEFT_MIN = 220;
|
const LEFT_MIN = 220;
|
||||||
const LEFT_MAX = 520;
|
const LEFT_MAX = 520;
|
||||||
const LEFT_DEFAULT = 280;
|
const LEFT_DEFAULT = 280;
|
||||||
|
const RIGHT_MIN = 260;
|
||||||
|
const RIGHT_MAX = 520;
|
||||||
|
const RIGHT_DEFAULT = 280;
|
||||||
const FOOTER_MIN = 88;
|
const FOOTER_MIN = 88;
|
||||||
const FOOTER_MAX = 420;
|
const FOOTER_MAX = 420;
|
||||||
const FOOTER_DEFAULT = 140;
|
const FOOTER_DEFAULT = 140;
|
||||||
@@ -29,6 +32,9 @@ export interface BuilderPageShellProps {
|
|||||||
footerLabel?: string;
|
footerLabel?: string;
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
leftStorageKey?: string;
|
leftStorageKey?: string;
|
||||||
|
leftDefaultWidth?: number;
|
||||||
|
rightStorageKey?: string;
|
||||||
|
rightDefaultWidth?: number;
|
||||||
footerStorageKey?: string;
|
footerStorageKey?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
loadingText?: string;
|
loadingText?: string;
|
||||||
@@ -52,15 +58,21 @@ export default function BuilderPageShell({
|
|||||||
footerLabel,
|
footerLabel,
|
||||||
footer,
|
footer,
|
||||||
leftStorageKey = 'bps-left-width',
|
leftStorageKey = 'bps-left-width',
|
||||||
|
leftDefaultWidth = LEFT_DEFAULT,
|
||||||
|
rightStorageKey = 'bps-right-width',
|
||||||
|
rightDefaultWidth = RIGHT_DEFAULT,
|
||||||
footerStorageKey = 'bps-footer-height',
|
footerStorageKey = 'bps-footer-height',
|
||||||
loading = false,
|
loading = false,
|
||||||
loadingText = '로딩 중…',
|
loadingText = '로딩 중…',
|
||||||
}: BuilderPageShellProps) {
|
}: 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 [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT));
|
||||||
const leftWidthRef = useRef(leftWidth);
|
const leftWidthRef = useRef(leftWidth);
|
||||||
|
const rightWidthRef = useRef(rightWidth);
|
||||||
const footerHeightRef = useRef(footerHeight);
|
const footerHeightRef = useRef(footerHeight);
|
||||||
leftWidthRef.current = leftWidth;
|
leftWidthRef.current = leftWidth;
|
||||||
|
rightWidthRef.current = rightWidth;
|
||||||
footerHeightRef.current = footerHeight;
|
footerHeightRef.current = footerHeight;
|
||||||
|
|
||||||
const onLeftSplitter = usePanelResize(
|
const onLeftSplitter = usePanelResize(
|
||||||
@@ -72,6 +84,37 @@ export default function BuilderPageShell({
|
|||||||
v => storeSize(leftStorageKey, v),
|
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(
|
const onFooterSplitter = usePanelResize(
|
||||||
'horizontal',
|
'horizontal',
|
||||||
setFooterHeight,
|
setFooterHeight,
|
||||||
@@ -165,7 +208,18 @@ export default function BuilderPageShell({
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{right && (
|
{right && (
|
||||||
<aside className="bps-right">
|
<>
|
||||||
|
<div
|
||||||
|
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="우측 패널 너비 조절"
|
||||||
|
onPointerDown={handleRightSplitter}
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
className="bps-right"
|
||||||
|
style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}
|
||||||
|
>
|
||||||
{rightTabs ?? (rightTitle ? (
|
{rightTabs ?? (rightTitle ? (
|
||||||
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
|
<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>
|
<h2 className="bps-panel-title">{rightTitle}</h2>
|
||||||
@@ -173,6 +227,7 @@ export default function BuilderPageShell({
|
|||||||
) : null)}
|
) : null)}
|
||||||
<div className="bps-right-body">{right}</div>
|
<div className="bps-right-body">{right}</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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;
|
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';
|
| 'paper' | 'alert' | 'network' | 'admin';
|
||||||
|
|
||||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
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[] = [
|
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||||
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ALL_MENU_IDS = [
|
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),
|
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ export const MENU_LABELS: Record<string, string> = {
|
|||||||
dashboard: '대시보드',
|
dashboard: '대시보드',
|
||||||
chart: '실시간차트',
|
chart: '실시간차트',
|
||||||
paper: '모의투자',
|
paper: '모의투자',
|
||||||
|
virtual: '가상투자',
|
||||||
strategy: '투자전략',
|
strategy: '투자전략',
|
||||||
'strategy-editor': '전략편집기',
|
'strategy-editor': '전략편집기',
|
||||||
backtest: '백테스팅',
|
backtest: '백테스팅',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SockJS from 'sockjs-client';
|
|||||||
import { getStompSockJsUrl } from './backendApi';
|
import { getStompSockJsUrl } from './backendApi';
|
||||||
|
|
||||||
type MessageHandler = (msg: IMessage) => void;
|
type MessageHandler = (msg: IMessage) => void;
|
||||||
|
type ConnectionListener = (connected: boolean) => void;
|
||||||
|
|
||||||
interface TopicEntry {
|
interface TopicEntry {
|
||||||
handlers: Set<MessageHandler>;
|
handlers: Set<MessageHandler>;
|
||||||
@@ -16,8 +17,14 @@ interface TopicEntry {
|
|||||||
let client: StompClient | null = null;
|
let client: StompClient | null = null;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
const topics = new Map<string, TopicEntry>();
|
const topics = new Map<string, TopicEntry>();
|
||||||
|
const connectionListeners = new Set<ConnectionListener>();
|
||||||
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function notifyConnection(next: boolean): void {
|
||||||
|
connected = next;
|
||||||
|
connectionListeners.forEach(l => l(next));
|
||||||
|
}
|
||||||
|
|
||||||
function handlerCount(): number {
|
function handlerCount(): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const entry of topics.values()) n += entry.handlers.size;
|
for (const entry of topics.values()) n += entry.handlers.size;
|
||||||
@@ -51,12 +58,12 @@ function getOrCreateClient(): StompClient {
|
|||||||
heartbeatIncoming: 10_000,
|
heartbeatIncoming: 10_000,
|
||||||
heartbeatOutgoing: 10_000,
|
heartbeatOutgoing: 10_000,
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
connected = true;
|
|
||||||
cancelPendingDisconnect();
|
cancelPendingDisconnect();
|
||||||
|
notifyConnection(true);
|
||||||
resubscribeAll();
|
resubscribeAll();
|
||||||
},
|
},
|
||||||
onDisconnect: () => {
|
onDisconnect: () => {
|
||||||
connected = false;
|
notifyConnection(false);
|
||||||
for (const entry of topics.values()) {
|
for (const entry of topics.values()) {
|
||||||
entry.stompSub = undefined;
|
entry.stompSub = undefined;
|
||||||
}
|
}
|
||||||
@@ -68,7 +75,7 @@ function getOrCreateClient(): StompClient {
|
|||||||
console.warn('[stompChartBroker] WebSocket error', getStompSockJsUrl(), ev);
|
console.warn('[stompChartBroker] WebSocket error', getStompSockJsUrl(), ev);
|
||||||
},
|
},
|
||||||
onWebSocketClose: () => {
|
onWebSocketClose: () => {
|
||||||
connected = false;
|
notifyConnection(false);
|
||||||
for (const entry of topics.values()) {
|
for (const entry of topics.values()) {
|
||||||
entry.stompSub = undefined;
|
entry.stompSub = undefined;
|
||||||
}
|
}
|
||||||
@@ -95,10 +102,17 @@ function scheduleDisconnect(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
client.deactivate();
|
client.deactivate();
|
||||||
connected = false;
|
notifyConnection(false);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** STOMP 연결 상태 변경 구독 */
|
||||||
|
export function subscribeStompConnection(listener: ConnectionListener): () => void {
|
||||||
|
connectionListeners.add(listener);
|
||||||
|
listener(connected && Boolean(client?.connected));
|
||||||
|
return () => { connectionListeners.delete(listener); };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* STOMP 토픽 구독 (참조 카운트). 반환 함수로 해제.
|
* 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