가상투자 메뉴 기능 구현
This commit is contained in:
@@ -7,7 +7,7 @@ public final class MenuIds {
|
||||
private MenuIds() {}
|
||||
|
||||
public static final List<String> ALL = List.of(
|
||||
"dashboard", "chart", "paper", "strategy", "strategy-editor", "backtest", "notifications", "settings",
|
||||
"dashboard", "chart", "paper", "virtual", "strategy", "strategy-editor", "backtest", "notifications", "settings",
|
||||
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
|
||||
"settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.LiveConditionStatusDto;
|
||||
import com.goldenchart.service.LiveConditionStatusService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 가상투자 — 실시간 조건 충족 현황 API.
|
||||
*
|
||||
* GET /api/strategy/live-conditions?market=KRW-BTC&strategyId=1
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/strategy/live-conditions")
|
||||
@RequiredArgsConstructor
|
||||
public class LiveConditionController {
|
||||
|
||||
private final LiveConditionStatusService service;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<LiveConditionStatusDto> get(
|
||||
@RequestParam String market,
|
||||
@RequestParam long strategyId,
|
||||
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
|
||||
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
|
||||
|
||||
return ResponseEntity.ok(service.evaluate(parseUserId(userIdHeader), deviceId, market, strategyId));
|
||||
}
|
||||
|
||||
private Long parseUserId(String header) {
|
||||
if (header == null || header.isBlank()) return null;
|
||||
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class LiveConditionRowDto {
|
||||
|
||||
private String id;
|
||||
private String indicatorType;
|
||||
private String displayName;
|
||||
private String conditionType;
|
||||
private String conditionLabel;
|
||||
private String thresholdLabel;
|
||||
private Double currentValue;
|
||||
private Double targetValue;
|
||||
/** true=충족, false=미충족, null=평가 불가 */
|
||||
private Boolean satisfied;
|
||||
private String timeframe;
|
||||
/** buy | sell */
|
||||
private String side;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class LiveConditionStatusDto {
|
||||
|
||||
private String market;
|
||||
private Long strategyId;
|
||||
private String timeframe;
|
||||
/** 0~100 (충족 조건 비율) */
|
||||
private int matchRate;
|
||||
private List<LiveConditionRowDto> rows;
|
||||
private long updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.goldenchart.dto.LiveConditionRowDto;
|
||||
import com.goldenchart.dto.LiveConditionStatusDto;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 가상투자 — 종목×전략별 조건 충족 현황 (Ta4j Rule.isSatisfied).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LiveConditionStatusService {
|
||||
|
||||
private static final Map<String, String> CONDITION_LABEL = Map.ofEntries(
|
||||
Map.entry("GT", "초과(>)"),
|
||||
Map.entry("LT", "미만(<)"),
|
||||
Map.entry("GTE", "이상(≥)"),
|
||||
Map.entry("LTE", "이하(≤)"),
|
||||
Map.entry("EQ", "같음(=)"),
|
||||
Map.entry("NEQ", "다름(≠)"),
|
||||
Map.entry("CROSS_UP", "상향 돌파"),
|
||||
Map.entry("CROSS_DOWN", "하향 돌파"),
|
||||
Map.entry("SLOPE_UP", "상승 중"),
|
||||
Map.entry("SLOPE_DOWN", "하락 중")
|
||||
);
|
||||
|
||||
private record PendingCond(String id, JsonNode condition, String timeframe, String side) {}
|
||||
|
||||
private final GcStrategyRepository strategyRepo;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final StrategyDslToTa4jAdapter adapter;
|
||||
private final IndicatorSettingsService indicatorSettingsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
|
||||
String market, long strategyId) {
|
||||
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
||||
if (opt.isEmpty()) {
|
||||
return empty(market, strategyId);
|
||||
}
|
||||
GcStrategy strategy = opt.get();
|
||||
|
||||
Map<String, Map<String, Object>> params =
|
||||
indicatorSettingsService.getAll(userId, deviceId);
|
||||
|
||||
List<PendingCond> pending = new ArrayList<>();
|
||||
collectConditions(strategy.getBuyConditionJson(), "1m", "buy", pending);
|
||||
collectConditions(strategy.getSellConditionJson(), "1m", "sell", pending);
|
||||
|
||||
if (pending.isEmpty()) {
|
||||
return empty(market, strategyId);
|
||||
}
|
||||
|
||||
// 데이터 수신 보장 — 평가 전 해당 종목·봉 타입 pin + 동기 warm-up
|
||||
Set<String> timeframes = new LinkedHashSet<>();
|
||||
for (PendingCond p : pending) {
|
||||
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
|
||||
timeframes.add(tf);
|
||||
subscriptionManager.ensureBarsReadySync(market, tf, 60);
|
||||
}
|
||||
subscriptionManager.ensureBarsReadySync(market, "1m", 60);
|
||||
|
||||
List<LiveConditionRowDto> rows = new ArrayList<>();
|
||||
int met = 0;
|
||||
int evaluable = 0;
|
||||
|
||||
for (PendingCond p : pending) {
|
||||
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
|
||||
if (!ta4jStorage.exists(market, tf)) {
|
||||
rows.add(toRowUnevaluated(p));
|
||||
continue;
|
||||
}
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, tf);
|
||||
if (series.isEmpty()) {
|
||||
rows.add(toRowUnevaluated(p));
|
||||
continue;
|
||||
}
|
||||
int index = series.getEndIndex();
|
||||
try {
|
||||
ObjectNode wrapper = objectMapper.createObjectNode();
|
||||
wrapper.put("type", "CONDITION");
|
||||
wrapper.set("condition", p.condition());
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, market, ta4jStorage);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
p.condition(), series, params, index, true);
|
||||
Double target = readTargetNumeric(p.condition());
|
||||
if (target == null) {
|
||||
target = adapter.readConditionFieldValue(
|
||||
p.condition(), series, params, index, false);
|
||||
}
|
||||
rows.add(toRow(p, current, target, satisfied, series, params, index));
|
||||
evaluable++;
|
||||
if (satisfied) met++;
|
||||
} catch (Exception e) {
|
||||
log.debug("[LiveCondition] eval fail {} {}: {}", market, p.id(), e.getMessage());
|
||||
rows.add(toRowUnevaluated(p));
|
||||
}
|
||||
}
|
||||
|
||||
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
||||
String tfSummary = String.join(", ", timeframes);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
.strategyId(strategyId)
|
||||
.timeframe(tfSummary)
|
||||
.matchRate(matchRate)
|
||||
.rows(rows)
|
||||
.updatedAt(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
|
||||
private LiveConditionStatusDto empty(String market, long strategyId) {
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
.strategyId(strategyId)
|
||||
.timeframe("")
|
||||
.matchRate(0)
|
||||
.rows(List.of())
|
||||
.updatedAt(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
|
||||
private void collectConditions(String json, String timeframe, String side,
|
||||
List<PendingCond> out) {
|
||||
if (json == null || json.isBlank()) return;
|
||||
try {
|
||||
walk(objectMapper.readTree(json), timeframe, side, out, new HashSet<>());
|
||||
} catch (Exception e) {
|
||||
log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void walk(JsonNode node, String timeframe, String side,
|
||||
List<PendingCond> out, Set<String> seen) {
|
||||
if (node == null || node.isNull()) return;
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
String tf = LiveStrategyTimeframeService.normalize(
|
||||
node.path("candleType").asText(timeframe));
|
||||
JsonNode children = node.path("children");
|
||||
if (children.isArray()) {
|
||||
for (JsonNode c : children) walk(c, tf, side, out, seen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ("AND".equals(type) || "OR".equals(type)) {
|
||||
JsonNode children = node.path("children");
|
||||
if (children.isArray()) {
|
||||
for (JsonNode c : children) walk(c, timeframe, side, out, seen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ("NOT".equals(type)) {
|
||||
walk(node.path("child"), timeframe, side, out, seen);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("CONDITION".equals(type) && node.has("condition")) {
|
||||
JsonNode cond = node.path("condition");
|
||||
String nodeId = node.path("id").asText("");
|
||||
String id = !nodeId.isBlank()
|
||||
? nodeId + "-" + side
|
||||
: side + ":" + timeframe + ":" + cond.hashCode();
|
||||
String key = id + ":" + cond.toString();
|
||||
if (seen.add(key)) {
|
||||
out.add(new PendingCond(id, cond, timeframe, side));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LiveConditionRowDto toRowUnevaluated(PendingCond p) {
|
||||
JsonNode c = p.condition();
|
||||
String indType = c.path("indicatorType").asText("");
|
||||
String condType = c.path("conditionType").asText("GT");
|
||||
String condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
|
||||
Double target = readTargetNumeric(c);
|
||||
return LiveConditionRowDto.builder()
|
||||
.id(p.id())
|
||||
.indicatorType(indType)
|
||||
.displayName(indType)
|
||||
.conditionType(condType)
|
||||
.conditionLabel(condLabel)
|
||||
.thresholdLabel(formatThresholdStatic(c, target))
|
||||
.currentValue(null)
|
||||
.targetValue(target)
|
||||
.satisfied(null)
|
||||
.timeframe(p.timeframe())
|
||||
.side(p.side())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String formatThresholdStatic(JsonNode cond, Double target) {
|
||||
if (target != null && !target.isNaN()) {
|
||||
String ct = cond.path("conditionType").asText("GT");
|
||||
String v = formatNum(target);
|
||||
return switch (ct) {
|
||||
case "LT", "CROSS_DOWN" -> "< " + v;
|
||||
case "GT", "CROSS_UP" -> "> " + v;
|
||||
case "GTE" -> "≥ " + v;
|
||||
case "LTE" -> "≤ " + v;
|
||||
case "EQ" -> "= " + v;
|
||||
default -> cond.path("rightField").asText(v);
|
||||
};
|
||||
}
|
||||
String rf = cond.path("rightField").asText("");
|
||||
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
|
||||
return rf.replace('_', ' ');
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
private LiveConditionRowDto toRow(PendingCond p, Double current, Double target,
|
||||
Boolean satisfied, BarSeries series,
|
||||
Map<String, Map<String, Object>> params, int index) {
|
||||
JsonNode c = p.condition();
|
||||
String indType = c.path("indicatorType").asText("");
|
||||
String condType = c.path("conditionType").asText("GT");
|
||||
String condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
|
||||
|
||||
return LiveConditionRowDto.builder()
|
||||
.id(p.id())
|
||||
.indicatorType(indType)
|
||||
.displayName(indType)
|
||||
.conditionType(condType)
|
||||
.conditionLabel(condLabel)
|
||||
.thresholdLabel(formatThreshold(c, target, series, params, index))
|
||||
.currentValue(current)
|
||||
.targetValue(target)
|
||||
.satisfied(satisfied)
|
||||
.timeframe(p.timeframe())
|
||||
.side(p.side())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String formatThreshold(JsonNode cond, Double target, BarSeries series,
|
||||
Map<String, Map<String, Object>> params, int index) {
|
||||
String ct = cond.path("conditionType").asText("GT");
|
||||
if (target != null && !target.isNaN()) {
|
||||
String v = formatNum(target);
|
||||
return switch (ct) {
|
||||
case "LT", "CROSS_DOWN" -> "< " + v;
|
||||
case "GT", "CROSS_UP" -> "> " + v;
|
||||
case "GTE" -> "≥ " + v;
|
||||
case "LTE" -> "≤ " + v;
|
||||
case "EQ" -> "= " + v;
|
||||
default -> cond.path("rightField").asText(v);
|
||||
};
|
||||
}
|
||||
String rf = cond.path("rightField").asText("");
|
||||
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
|
||||
Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false);
|
||||
if (rightLive != null && !rightLive.isNaN()) {
|
||||
String v = formatNum(rightLive);
|
||||
return switch (ct) {
|
||||
case "LT", "CROSS_DOWN" -> "< " + v;
|
||||
case "GT", "CROSS_UP" -> "> " + v;
|
||||
case "GTE" -> "≥ " + v;
|
||||
case "LTE" -> "≤ " + v;
|
||||
case "EQ" -> "= " + v;
|
||||
default -> rf.replace('_', ' ') + " (" + v + ")";
|
||||
};
|
||||
}
|
||||
return rf.replace('_', ' ');
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
private static String formatNum(double v) {
|
||||
if (Math.abs(v) >= 1000) return String.format("%.0f", v);
|
||||
return String.format("%.2f", v);
|
||||
}
|
||||
|
||||
private Double readTargetNumeric(JsonNode cond) {
|
||||
if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
|
||||
return cond.path("targetValue").asDouble();
|
||||
}
|
||||
if (cond.has("compareValue") && !cond.path("compareValue").isNull()) {
|
||||
return cond.path("compareValue").asDouble();
|
||||
}
|
||||
String rf = cond.path("rightField").asText("");
|
||||
if (rf.startsWith("K_")) {
|
||||
try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,22 +49,15 @@ public class LiveStrategySettingsService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<LiveStrategySettingsDto> listActive(Long userId, String deviceId) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
|
||||
List<GcLiveStrategySettings> rows = userId != null
|
||||
? repo.findByUserIdAndIsLiveCheckTrue(userId)
|
||||
: repo.findByDeviceIdAndIsLiveCheckTrue(
|
||||
deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous");
|
||||
if (rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> watchlist = listWatchlistSymbols(userId, deviceId);
|
||||
Long strategyId = app.getLiveStrategyId();
|
||||
List<String> strategyCandleTypes = conditionTimeframes.collectForStrategyList(strategyId);
|
||||
return watchlist.stream()
|
||||
.map(m -> LiveStrategySettingsDto.builder()
|
||||
.market(m)
|
||||
.strategyId(strategyId)
|
||||
.liveCheck(true)
|
||||
.executionType(app.getLiveExecutionType())
|
||||
.positionMode(app.getLivePositionMode())
|
||||
.strategyCandleTypes(strategyCandleTypes)
|
||||
.build())
|
||||
return rows.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -125,6 +118,17 @@ public class LiveStrategySettingsService {
|
||||
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
||||
LiveStrategySettingsDto dto) {
|
||||
persistGlobalTemplate(userId, deviceId, dto);
|
||||
|
||||
if (dto.getMarket() != null && !dto.getMarket().isBlank()) {
|
||||
enrichStrategyTimeframes(dto);
|
||||
LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, dto);
|
||||
if (dto.isLiveCheck()) {
|
||||
pinIfReady(dto.getMarket(), saved);
|
||||
} else {
|
||||
liveStrategyEvaluator.invalidateCache(dto.getMarket());
|
||||
}
|
||||
}
|
||||
|
||||
syncAllWatchlistMarkets(userId, deviceId, dto);
|
||||
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
|
||||
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
|
||||
@@ -139,6 +143,10 @@ public class LiveStrategySettingsService {
|
||||
if (dto.getPositionMode() != null) {
|
||||
out.setPositionMode(dto.getPositionMode());
|
||||
}
|
||||
if (dto.getStrategyId() != null) {
|
||||
out.setStrategyId(dto.getStrategyId());
|
||||
out.setLiveCheck(dto.isLiveCheck());
|
||||
}
|
||||
enrichStrategyTimeframes(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,39 @@ public class StrategyDslToTa4jAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/** 조건 DSL left/right 필드의 현재 봉 수치 (가상투자 UI 표시용) */
|
||||
public Double readConditionFieldValue(JsonNode cond, BarSeries series,
|
||||
Map<String, Map<String, Object>> params,
|
||||
int index, boolean leftSide) {
|
||||
if (cond == null || cond.isNull() || series == null || index < 0) return null;
|
||||
if (index >= series.getBarCount()) return null;
|
||||
|
||||
String field = leftSide
|
||||
? cond.path("leftField").asText("NONE")
|
||||
: cond.path("rightField").asText("NONE");
|
||||
if (field == null || field.isBlank() || "NONE".equals(field)) return null;
|
||||
|
||||
String indType = cond.path("indicatorType").asText("");
|
||||
int condPeriod = cond.path("period").asInt(-1);
|
||||
int leftPeriod = cond.path("leftPeriod").asInt(-1);
|
||||
int rightPeriod = cond.path("rightPeriod").asInt(-1);
|
||||
int sidePeriod = leftSide ? leftPeriod : rightPeriod;
|
||||
|
||||
String registryKey = toRegistryKey(indType);
|
||||
Map<String, Object> indParams = params != null
|
||||
? params.getOrDefault(registryKey, Map.of())
|
||||
: Map.of();
|
||||
|
||||
try {
|
||||
Indicator<Num> ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod);
|
||||
Num v = ind.getValue(index);
|
||||
return v != null ? v.doubleValue() : null;
|
||||
} catch (Exception e) {
|
||||
log.debug("[Adapter] field value read fail {}: {}", field, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
|
||||
String ct = node.path("candleType").asText("1m");
|
||||
BarSeries branchSeries = ctx.resolveSeries(ct);
|
||||
|
||||
@@ -147,9 +147,36 @@ public class DynamicSubscriptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* live-conditions API 등 — 최소 봉 수 확보까지 동기 warm-up (종목별 독립).
|
||||
*/
|
||||
public void ensureBarsReadySync(String market, String candleType, int minBars) {
|
||||
upbitWsClient.addMarket(market);
|
||||
if (ta4jStorage.getBarCount(market, candleType) >= minBars) {
|
||||
return;
|
||||
}
|
||||
String key = market + ":" + candleType;
|
||||
if (warmingUp.putIfAbsent(key, Boolean.TRUE) == null) {
|
||||
try {
|
||||
warmUp(market, candleType);
|
||||
} finally {
|
||||
warmingUp.remove(key);
|
||||
}
|
||||
} else {
|
||||
// 다른 스레드 warm-up 중 — 짧게 대기 후 재확인
|
||||
for (int i = 0; i < 30 && ta4jStorage.getBarCount(market, candleType) < minBars; i++) {
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 전략 체크 등 STOMP 클라이언트 없이도 백엔드가 틱·캔들을 수집하도록 마켓을 고정 구독한다.
|
||||
* (관심종목 모니터링 — 프론트가 해당 토픽을 구독하면 평가·시그널 발행이 연결됨)
|
||||
*/
|
||||
public void ensureMarketPinned(String market, String candleType) {
|
||||
String key = market + ":" + candleType;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 가상투자 메뉴 권한 (ADMIN·USER 허용, GUEST 차단)
|
||||
INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES
|
||||
('ADMIN', 'virtual', 1),
|
||||
('USER', 'virtual', 1),
|
||||
('GUEST', 'virtual', 0)
|
||||
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed);
|
||||
Reference in New Issue
Block a user