전략조건 상세설명 기능 추가
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
package com.goldenchart.config;
|
||||
|
||||
import com.goldenchart.entity.GcAppSettings;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.entity.GcWatchlist;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.repository.GcWatchlistRepository;
|
||||
import com.goldenchart.service.AppSettingsService;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
||||
import com.goldenchart.websocket.UpbitWebSocketClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,9 +25,9 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
|
||||
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final GcWatchlistRepository watchlistRepo;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
private final UpbitWebSocketClient upbitWebSocketClient;
|
||||
|
||||
@Override
|
||||
@@ -44,11 +42,7 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
|
||||
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
|
||||
continue;
|
||||
}
|
||||
String ct = resolvePinnedCandleType(w);
|
||||
subscriptionManager.ensureMarketPinned(w.getSymbol(), "1m");
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(w.getSymbol(), ct);
|
||||
}
|
||||
pinStrategyTimeframes(w.getSymbol(), app.getLiveStrategyId());
|
||||
liveStrategyEvaluator.invalidateCache(w.getSymbol());
|
||||
pinned++;
|
||||
}
|
||||
@@ -59,19 +53,12 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
|
||||
upbitWebSocketClient.resubscribeAll();
|
||||
}
|
||||
|
||||
private String resolvePinnedCandleType(GcWatchlist w) {
|
||||
if (w.getUserId() != null) {
|
||||
return liveSettingsRepo.findByUserIdAndMarket(w.getUserId(), w.getSymbol())
|
||||
.map(GcLiveStrategySettings::getCandleType)
|
||||
.map(LiveStrategyTimeframeService::normalize)
|
||||
.orElse("1m");
|
||||
private void pinStrategyTimeframes(String market, Long strategyId) {
|
||||
subscriptionManager.ensureMarketPinned(market, "1m");
|
||||
for (String ct : conditionTimeframes.collectForStrategy(strategyId)) {
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(market, ct);
|
||||
}
|
||||
}
|
||||
if (w.getDeviceId() != null) {
|
||||
return liveSettingsRepo.findByDeviceIdAndMarket(w.getDeviceId(), w.getSymbol())
|
||||
.map(GcLiveStrategySettings::getCandleType)
|
||||
.map(LiveStrategyTimeframeService::normalize)
|
||||
.orElse("1m");
|
||||
}
|
||||
return "1m";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.StrategyDto;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.service.StrategyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -23,6 +24,7 @@ import java.util.List;
|
||||
public class StrategyController {
|
||||
|
||||
private final StrategyService service;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/** 전략 목록 조회 */
|
||||
@GetMapping
|
||||
@@ -49,6 +51,12 @@ public class StrategyController {
|
||||
return ResponseEntity.ok(service.save(dto, parseUserId(userIdHeader), deviceId));
|
||||
}
|
||||
|
||||
/** 전략 DSL에 포함된 평가 시간봉 목록 */
|
||||
@GetMapping("/{id}/timeframes")
|
||||
public ResponseEntity<List<String>> timeframes(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(conditionTimeframes.collectForStrategyList(id));
|
||||
}
|
||||
|
||||
/** 전략 삭제 */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 실시간 전략 체크 설정 DTO.
|
||||
* PUT /api/strategy/settings (요청/응답 공용)
|
||||
@@ -29,10 +31,16 @@ public class LiveStrategySettingsDto {
|
||||
/** "CANDLE_CLOSE" | "REALTIME_TICK" */
|
||||
private String executionType;
|
||||
|
||||
/** 전략 평가 분봉: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d */
|
||||
/**
|
||||
* @deprecated 전략 DSL의 START/TIMEFRAME에서 시간봉을 자동 추출합니다. 응답 전용 필드는 {@link #strategyCandleTypes}를 사용하세요.
|
||||
*/
|
||||
@Deprecated
|
||||
@Builder.Default
|
||||
private String candleType = "1m";
|
||||
|
||||
/** 전략 조건 DSL에 포함된 평가 시간봉 목록 (응답 전용) */
|
||||
private List<String> strategyCandleTypes;
|
||||
|
||||
/** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */
|
||||
@Builder.Default
|
||||
private String positionMode = "LONG_ONLY";
|
||||
|
||||
@@ -55,6 +55,7 @@ public class LiveStrategyEvaluator {
|
||||
private final IndicatorSettingsService indicatorSettingsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StrategySignalDeterminer determiner;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/**
|
||||
* Strategy 캐시: "market:candleType:strategyId" → Strategy
|
||||
@@ -103,8 +104,7 @@ public class LiveStrategyEvaluator {
|
||||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
|
||||
if (!"CANDLE_CLOSE".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
if (!candleType.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m"))) continue;
|
||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||
|
||||
String result = evaluate(market, candleType, s.getStrategyId(),
|
||||
s.getPositionMode(), maturedIndex,
|
||||
@@ -155,8 +155,7 @@ public class LiveStrategyEvaluator {
|
||||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
|
||||
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
if (!candleType.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m"))) continue;
|
||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||
|
||||
// ★ Ta4j CachedIndicator 캐시 무효화:
|
||||
// updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는
|
||||
@@ -202,8 +201,7 @@ public class LiveStrategyEvaluator {
|
||||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue;
|
||||
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
if (!candleType.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m"))) continue;
|
||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||
|
||||
// CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산
|
||||
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
||||
|
||||
@@ -30,7 +30,7 @@ public class LiveStrategySettingsService {
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
private final LiveStrategyTimeframeService timeframeService;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
// ── 조회 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +42,9 @@ public class LiveStrategySettingsService {
|
||||
? toDto(entity.get())
|
||||
: defaultDtoFromApp(market, app);
|
||||
// 실시간 체크 마스터 스위치·전략 ID는 앱 전역 설정(gc_app_settings) 기준
|
||||
return mergeGlobalTemplate(dto, app, market);
|
||||
dto = mergeGlobalTemplate(dto, app, market);
|
||||
enrichStrategyTimeframes(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -52,21 +54,17 @@ public class LiveStrategySettingsService {
|
||||
return List.of();
|
||||
}
|
||||
List<String> watchlist = listWatchlistSymbols(userId, deviceId);
|
||||
Long strategyId = app.getLiveStrategyId();
|
||||
List<String> strategyCandleTypes = conditionTimeframes.collectForStrategyList(strategyId);
|
||||
return watchlist.stream()
|
||||
.map(m -> {
|
||||
Optional<GcLiveStrategySettings> row = findEntity(userId, deviceId, m);
|
||||
String ct = row.map(GcLiveStrategySettings::getCandleType)
|
||||
.map(LiveStrategyTimeframeService::normalize)
|
||||
.orElseGet(() -> timeframeService.resolveCandleType(m, userId, deviceId));
|
||||
return LiveStrategySettingsDto.builder()
|
||||
.map(m -> LiveStrategySettingsDto.builder()
|
||||
.market(m)
|
||||
.strategyId(app.getLiveStrategyId())
|
||||
.strategyId(strategyId)
|
||||
.liveCheck(true)
|
||||
.executionType(app.getLiveExecutionType())
|
||||
.positionMode(app.getLivePositionMode())
|
||||
.candleType(ct)
|
||||
.build();
|
||||
})
|
||||
.strategyCandleTypes(strategyCandleTypes)
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -77,18 +75,12 @@ public class LiveStrategySettingsService {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
LiveStrategySettingsDto template = findEntity(userId, deviceId, market)
|
||||
.map(e -> mergeGlobalTemplate(toDto(e), app, market))
|
||||
.orElseGet(() -> {
|
||||
LiveStrategySettingsDto dto = defaultDtoFromApp(market, app);
|
||||
String inherited = resolveSharedCandleType(userId, deviceId);
|
||||
if (inherited != null) {
|
||||
dto.setCandleType(inherited);
|
||||
}
|
||||
return mergeGlobalTemplate(dto, app, market);
|
||||
});
|
||||
.orElseGet(() -> mergeGlobalTemplate(defaultDtoFromApp(market, app), app, market));
|
||||
template.setLiveCheck(true);
|
||||
enrichStrategyTimeframes(template);
|
||||
upsertEntity(userId, deviceId, template);
|
||||
pinIfReady(market, template);
|
||||
log.debug("[LiveStrategy] watchlist add → enabled {} candleType={}", market, template.getCandleType());
|
||||
log.debug("[LiveStrategy] watchlist add → enabled {}", market);
|
||||
}
|
||||
|
||||
/** 관심종목 해제 시 — 해당 종목 전략 체크 비활성화 */
|
||||
@@ -121,7 +113,6 @@ public class LiveStrategySettingsService {
|
||||
.liveCheck(true)
|
||||
.executionType(template.getExecutionType())
|
||||
.positionMode(template.getPositionMode())
|
||||
.candleType(template.getCandleType() != null ? template.getCandleType() : "1m")
|
||||
.build();
|
||||
results.add(upsertEntity(userId, deviceId, one));
|
||||
}
|
||||
@@ -148,9 +139,7 @@ public class LiveStrategySettingsService {
|
||||
if (dto.getPositionMode() != null) {
|
||||
out.setPositionMode(dto.getPositionMode());
|
||||
}
|
||||
if (dto.getCandleType() != null) {
|
||||
out.setCandleType(LiveStrategyTimeframeService.normalize(dto.getCandleType()));
|
||||
}
|
||||
enrichStrategyTimeframes(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -170,9 +159,6 @@ public class LiveStrategySettingsService {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck())
|
||||
&& app.getLiveStrategyId() != null;
|
||||
String candleType = template != null && template.getCandleType() != null
|
||||
? LiveStrategyTimeframeService.normalize(template.getCandleType())
|
||||
: resolveSharedCandleType(userId, deviceId);
|
||||
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
|
||||
LiveStrategySettingsDto row = findEntity(userId, deviceId, symbol)
|
||||
.map(e -> mergeGlobalTemplate(toDto(e), app, symbol))
|
||||
@@ -183,9 +169,7 @@ public class LiveStrategySettingsService {
|
||||
? app.getLiveExecutionType() : row.getExecutionType());
|
||||
row.setPositionMode(app.getLivePositionMode() != null
|
||||
? app.getLivePositionMode() : row.getPositionMode());
|
||||
if (candleType != null) {
|
||||
row.setCandleType(candleType);
|
||||
}
|
||||
enrichStrategyTimeframes(row);
|
||||
LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row);
|
||||
if (active) {
|
||||
pinIfReady(symbol, saved);
|
||||
@@ -193,15 +177,21 @@ public class LiveStrategySettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 관심종목 중 이미 저장된 평가 분봉 (전역 템플릿 동기화용) */
|
||||
private String resolveSharedCandleType(Long userId, String deviceId) {
|
||||
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
|
||||
Optional<GcLiveStrategySettings> row = findEntity(userId, deviceId, symbol);
|
||||
if (row.isPresent() && row.get().getCandleType() != null) {
|
||||
return LiveStrategyTimeframeService.normalize(row.get().getCandleType());
|
||||
private void enrichStrategyTimeframes(LiveStrategySettingsDto dto) {
|
||||
if (dto.getStrategyId() != null) {
|
||||
dto.setStrategyCandleTypes(
|
||||
conditionTimeframes.collectForStrategyList(dto.getStrategyId()));
|
||||
}
|
||||
}
|
||||
|
||||
private void pinAllStrategyTimeframes(String market, Long strategyId) {
|
||||
if (strategyId == null) return;
|
||||
subscriptionManager.ensureMarketPinned(market, "1m");
|
||||
for (String ct : conditionTimeframes.collectForStrategy(strategyId)) {
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(market, ct);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId,
|
||||
@@ -222,8 +212,6 @@ public class LiveStrategySettingsService {
|
||||
"REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE");
|
||||
entity.setPositionMode(
|
||||
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
|
||||
entity.setCandleType(LiveStrategyTimeframeService.normalize(
|
||||
dto.getCandleType() != null ? dto.getCandleType() : "1m"));
|
||||
|
||||
repo.save(entity);
|
||||
liveStrategyEvaluator.invalidateCache(market);
|
||||
@@ -232,12 +220,7 @@ public class LiveStrategySettingsService {
|
||||
|
||||
private void pinIfReady(String market, LiveStrategySettingsDto dto) {
|
||||
if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(
|
||||
dto.getCandleType() != null ? dto.getCandleType() : "1m");
|
||||
subscriptionManager.ensureMarketPinned(market, "1m");
|
||||
if (!"1m".equals(ct)) {
|
||||
subscriptionManager.ensureMarketPinned(market, ct);
|
||||
}
|
||||
pinAllStrategyTimeframes(market, dto.getStrategyId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,14 +260,15 @@ public class LiveStrategySettingsService {
|
||||
}
|
||||
|
||||
private LiveStrategySettingsDto toDto(GcLiveStrategySettings e) {
|
||||
return LiveStrategySettingsDto.builder()
|
||||
LiveStrategySettingsDto dto = LiveStrategySettingsDto.builder()
|
||||
.market(e.getMarket())
|
||||
.strategyId(e.getStrategyId())
|
||||
.liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck()))
|
||||
.executionType(e.getExecutionType())
|
||||
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
|
||||
.candleType(e.getCandleType() != null ? e.getCandleType() : "1m")
|
||||
.build();
|
||||
enrichStrategyTimeframes(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 앱 전역 실시간 체크 ON/OFF·전략 ID를 DTO에 반영 (종목별 행과 분리) */
|
||||
@@ -309,17 +293,6 @@ public class LiveStrategySettingsService {
|
||||
.liveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck()))
|
||||
.executionType(app.getLiveExecutionType() != null ? app.getLiveExecutionType() : "CANDLE_CLOSE")
|
||||
.positionMode(app.getLivePositionMode() != null ? app.getLivePositionMode() : "LONG_ONLY")
|
||||
.candleType(LiveStrategyTimeframeService.normalize(
|
||||
app.getDefaultTimeframe() != null
|
||||
? mapAppTf(app.getDefaultTimeframe()) : "1m"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String mapAppTf(String tf) {
|
||||
return switch (tf) {
|
||||
case "1m", "3m", "5m", "15m", "30m", "1h", "4h" -> tf;
|
||||
case "1D" -> "1d";
|
||||
default -> "1m";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 전략 DSL(buy/sell LogicNode)에 포함된 조건 판별 시간봉 수집.
|
||||
* START / TIMEFRAME / AND|OR+TIMEFRAME 분기와 동일 규칙.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StrategyConditionTimeframeService {
|
||||
|
||||
private final GcStrategyRepository strategyRepo;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final ConcurrentHashMap<Long, Set<String>> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public Set<String> collectForStrategy(long strategyId) {
|
||||
return cache.computeIfAbsent(strategyId, this::loadFromDb);
|
||||
}
|
||||
|
||||
public List<String> collectForStrategyList(long strategyId) {
|
||||
return List.copyOf(collectForStrategy(strategyId));
|
||||
}
|
||||
|
||||
public boolean usesTimeframe(long strategyId, String candleType) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(candleType);
|
||||
return collectForStrategy(strategyId).contains(ct);
|
||||
}
|
||||
|
||||
public void invalidate(long strategyId) {
|
||||
cache.remove(strategyId);
|
||||
}
|
||||
|
||||
public void invalidateAll() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
private Set<String> loadFromDb(long strategyId) {
|
||||
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
||||
if (opt.isEmpty()) return Set.of("1m");
|
||||
GcStrategy strategy = opt.get();
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
collectFromJson(strategy.getBuyConditionJson(), out);
|
||||
collectFromJson(strategy.getSellConditionJson(), out);
|
||||
return out.isEmpty() ? Set.of("1m") : out;
|
||||
}
|
||||
|
||||
private void collectFromJson(String json, Set<String> out) {
|
||||
if (json == null || json.isBlank()) return;
|
||||
try {
|
||||
collectFromNode(objectMapper.readTree(json), out);
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage());
|
||||
out.add("1m");
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFromNode(JsonNode node, Set<String> out) {
|
||||
if (node == null || node.isNull()) return;
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")));
|
||||
return;
|
||||
}
|
||||
|
||||
if ("AND".equals(type) || "OR".equals(type)) {
|
||||
JsonNode children = node.path("children");
|
||||
if (children.isArray() && !children.isEmpty()) {
|
||||
if (allTimeframeChildren(children)) {
|
||||
for (JsonNode tf : children) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m")));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (JsonNode child : children) {
|
||||
collectFromNode(child, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode child = node.path("child");
|
||||
if (!child.isMissingNode() && !child.isNull()) {
|
||||
collectFromNode(child, out);
|
||||
return;
|
||||
}
|
||||
|
||||
// 단일 START(기본 1m) 또는 TIMEFRAME 래핑 없는 트리
|
||||
out.add("1m");
|
||||
}
|
||||
|
||||
private static boolean allTimeframeChildren(JsonNode children) {
|
||||
for (JsonNode c : children) {
|
||||
if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public class StrategyService {
|
||||
|
||||
private final GcStrategyRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
// ── 조회 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,13 +62,16 @@ public class StrategyService {
|
||||
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return toDto(repository.save(entity));
|
||||
GcStrategy saved = repository.save(entity);
|
||||
conditionTimeframes.invalidate(saved.getId());
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
// ── 삭제 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
conditionTimeframes.invalidate(id);
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.service.TradeSignalService;
|
||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -46,6 +47,7 @@ public class BarBuilder {
|
||||
private final TradeSignalService tradeSignalService;
|
||||
private final OrderExecutionQueue orderExecutionQueue;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 15, 30, 60, 240, 1440) */
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 15, 30, 60, 240, 1440};
|
||||
@@ -155,12 +157,12 @@ public class BarBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/** 활성 실시간 전략 설정 중 해당 분봉(candleType)으로 평가하는 항목이 있는지 */
|
||||
/** 활성 실시간 전략 설정 중 전략 DSL에 해당 분봉이 포함된 항목이 있는지 */
|
||||
private boolean shouldEvaluateOnClose(String market, String candleType) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(candleType);
|
||||
return liveSettingsRepo.findActiveByMarket(market).stream()
|
||||
.anyMatch(s -> ct.equals(LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m")));
|
||||
.filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null)
|
||||
.anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.goldenchart.dto.CandleBarDto;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||
import com.goldenchart.service.TradeSignalService;
|
||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -15,7 +15,6 @@ import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 3초 주기 라운드 로빈 스케줄러.
|
||||
@@ -39,9 +38,9 @@ public class LiveStrategyScheduler {
|
||||
private final LiveStrategyEvaluator evaluator;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final TradingWebSocketBroker broker;
|
||||
private final BarBuilder barBuilder;
|
||||
private final TradeSignalService tradeSignalService;
|
||||
private final OrderExecutionQueue orderExecutionQueue;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/**
|
||||
* 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행.
|
||||
@@ -56,14 +55,19 @@ public class LiveStrategyScheduler {
|
||||
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
|
||||
String market = s.getMarket();
|
||||
String candleType = LiveStrategyTimeframeService.normalize(
|
||||
s.getCandleType() != null ? s.getCandleType() : "1m");
|
||||
String market = s.getMarket();
|
||||
for (String candleType : conditionTimeframes.collectForStrategy(s.getStrategyId())) {
|
||||
evaluateRealtimeTickForMarket(s, market, candleType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ta4jStorage.exists(market, candleType)) continue;
|
||||
private void evaluateRealtimeTickForMarket(GcLiveStrategySettings s, String market,
|
||||
String candleType) {
|
||||
if (!ta4jStorage.exists(market, candleType)) return;
|
||||
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||
if (series.isEmpty()) continue;
|
||||
if (series.isEmpty()) return;
|
||||
|
||||
String signal = evaluator.evaluateRealtimeTick(market, candleType);
|
||||
if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
||||
@@ -105,6 +109,5 @@ public class LiveStrategyScheduler {
|
||||
log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import com.goldenchart.trading.pipeline.TradingPipelineProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.ta4j.core.TradingRecord;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 사용자 복합 전략 DSL(다중 START · AND/OR · MACD/DMI/ADX/CCI/RSI/VR)이
|
||||
* StrategyDslToTa4jAdapter 에서 Rule 로 변환·평가 가능한지 검증.
|
||||
*/
|
||||
class ComplexStrategyDslAdapterTest {
|
||||
|
||||
private static final String MARKET = "KRW-BTC";
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private StrategyDslToTa4jAdapter adapter;
|
||||
private Ta4jStorage storage;
|
||||
private BarSeries primary1m;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new StrategyDslToTa4jAdapter();
|
||||
TradingPipelineProperties props = new TradingPipelineProperties();
|
||||
props.setEnabled(false);
|
||||
storage = new Ta4jStorage(props);
|
||||
primary1m = buildSyntheticSeries("1m", 260);
|
||||
seedStorage(MARKET, "1m", primary1m);
|
||||
seedStorage(MARKET, "3m", buildSyntheticSeries("3m", 120));
|
||||
seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80));
|
||||
}
|
||||
|
||||
/** Logic Expression 예시와 동일 구조의 매수 조건 */
|
||||
@Test
|
||||
void buyCondition_compilesAndEvaluates() throws Exception {
|
||||
JsonNode buy = MAPPER.readTree("""
|
||||
{
|
||||
"id": "buy-root",
|
||||
"type": "OR",
|
||||
"children": [
|
||||
{
|
||||
"id": "buy-and-1",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-macd",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "MACD",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "MACD_LINE",
|
||||
"rightField": "SIGNAL_LINE",
|
||||
"candleRange": 1,
|
||||
"period": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-dmi",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "DMI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "PDI",
|
||||
"rightField": "MDI",
|
||||
"candleRange": 1,
|
||||
"period": 14
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "buy-and-2",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-adx",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "ADX",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "ADX_VALUE",
|
||||
"rightField": "K_25",
|
||||
"candleRange": 1,
|
||||
"period": 14
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-cci",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "CCI_VALUE",
|
||||
"rightField": "K_100",
|
||||
"candleRange": 1,
|
||||
"period": 13
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Rule rule = adapter.toRule(buy, primary1m, Map.of(), MARKET, storage);
|
||||
assertNotNull(rule);
|
||||
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
|
||||
}
|
||||
|
||||
/** Logic Expression 예시와 동일 구조의 매도 조건 (1m AND 5m AND 3m) */
|
||||
@Test
|
||||
void sellCondition_multiTimeframe_compilesAndEvaluates() throws Exception {
|
||||
JsonNode sell = MAPPER.readTree("""
|
||||
{
|
||||
"id": "sell-root",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "tf-1m",
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"children": [{
|
||||
"id": "sell-1m-and",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-rsi-1m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "RSI_VALUE",
|
||||
"rightField": "K_70",
|
||||
"candleRange": 1,
|
||||
"period": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-macd-1m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "MACD",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "MACD_LINE",
|
||||
"rightField": "SIGNAL_LINE",
|
||||
"candleRange": 1,
|
||||
"period": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-vr-1m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "VR",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "VR_VALUE",
|
||||
"rightField": "K_200",
|
||||
"candleRange": 1,
|
||||
"period": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "tf-5m",
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "5m",
|
||||
"children": [{
|
||||
"id": "sell-5m-and",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-adx-5m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "ADX",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "ADX_VALUE",
|
||||
"rightField": "K_28",
|
||||
"candleRange": 1,
|
||||
"period": 14
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-cci-5m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "CCI",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "CCI_VALUE",
|
||||
"rightField": "K_26",
|
||||
"candleRange": 1,
|
||||
"period": 13
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "tf-3m",
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "3m",
|
||||
"children": [{
|
||||
"id": "sell-3m-and",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{
|
||||
"id": "c-rsi-3m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "RSI_VALUE",
|
||||
"rightField": "K_20",
|
||||
"candleRange": 1,
|
||||
"period": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c-vr-3m",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "VR",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"leftField": "VR_VALUE",
|
||||
"rightField": "K_20",
|
||||
"candleRange": 1,
|
||||
"period": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Rule rule = adapter.toRule(sell, primary1m, Map.of(), MARKET, storage);
|
||||
assertNotNull(rule);
|
||||
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
|
||||
|
||||
// CrossSeriesRule: 1m 트리거 인덱스에서 5m·3m 시리즈 endIndex 로 평가
|
||||
int idx = primary1m.getEndIndex();
|
||||
boolean result = rule.isSatisfied(idx, null);
|
||||
assertTrue(result || !result, "다중 시간봉 AND Rule 평가가 예외 없이 boolean 반환");
|
||||
}
|
||||
|
||||
/** 백테스트 경로( storage 미전달 ) — 단일 시리즈 fallback 동작 확인 */
|
||||
@Test
|
||||
void sellCondition_backtestSingleSeries_fallback() throws Exception {
|
||||
JsonNode sell = MAPPER.readTree("""
|
||||
{
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{ "type": "TIMEFRAME", "candleType": "1m", "children": [
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "RSI", "conditionType": "CROSS_DOWN",
|
||||
"leftField": "RSI_VALUE", "rightField": "K_70", "period": 9
|
||||
}}
|
||||
]},
|
||||
{ "type": "TIMEFRAME", "candleType": "5m", "children": [
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "ADX", "conditionType": "CROSS_DOWN",
|
||||
"leftField": "ADX_VALUE", "rightField": "K_28", "period": 14
|
||||
}}
|
||||
]}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Rule rule = adapter.toRule(sell, primary1m, Map.of());
|
||||
assertNotNull(rule);
|
||||
assertDoesNotThrow(() -> rule.isSatisfied(primary1m.getEndIndex(), null));
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void evaluateRange(Rule rule, int endIndex) {
|
||||
TradingRecord record = null;
|
||||
int start = Math.max(30, endIndex - 20);
|
||||
for (int i = start; i <= endIndex; i++) {
|
||||
rule.isSatisfied(i, record);
|
||||
}
|
||||
}
|
||||
|
||||
private static BarSeries buildSyntheticSeries(String timeframe, int barCount) {
|
||||
Duration period = switch (timeframe) {
|
||||
case "3m" -> Duration.ofMinutes(3);
|
||||
case "5m" -> Duration.ofMinutes(5);
|
||||
default -> Duration.ofMinutes(1);
|
||||
};
|
||||
BarSeries series = new BaseBarSeriesBuilder()
|
||||
.withName("test-" + timeframe)
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||
Instant base = Instant.parse("2024-06-01T00:00:00Z");
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
double wave = Math.sin(i * 0.07) * 8 + Math.cos(i * 0.02) * 4;
|
||||
double close = 50_000_000 + i * 120 + wave * 1000;
|
||||
double open = close - 500;
|
||||
double high = close + 800;
|
||||
double low = close - 800;
|
||||
Instant end = base.plus(period.multipliedBy(i + 1L));
|
||||
factory.createBarBuilder(series)
|
||||
.timePeriod(period)
|
||||
.endTime(end)
|
||||
.openPrice(open).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(12.5 + i * 0.01)
|
||||
.add();
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
private void seedStorage(String market, String candleType, BarSeries source) {
|
||||
for (int i = source.getBeginIndex(); i <= source.getEndIndex(); i++) {
|
||||
storage.addBar(market, candleType, source.getBar(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-25
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import DrawingToolbar from './components/DrawingToolbar';
|
||||
import BottomBar from './components/BottomBar';
|
||||
import TradingChart from './components/TradingChart';
|
||||
@@ -70,7 +70,7 @@ import { BacktestHistoryPage } from './components/BacktestHistoryPage';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import PaperTradingPage from './components/PaperTradingPage';
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings } from './utils/backendApi';
|
||||
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
||||
import ChartLegendBar from './components/ChartLegendBar';
|
||||
import RightSidePanel from './components/RightSidePanel';
|
||||
import {
|
||||
@@ -697,16 +697,14 @@ function App() {
|
||||
// ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ─────────────────
|
||||
const [showLivePanel, setShowLivePanel] = useState(false);
|
||||
const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]);
|
||||
const [liveCandleType, setLiveCandleType] = useState('1m');
|
||||
const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState<string[] | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!appSettingsLoaded) return;
|
||||
loadLiveStrategySettings(symbol)
|
||||
.then(s => {
|
||||
if (s?.candleType) setLiveCandleType(s.candleType);
|
||||
})
|
||||
.catch(() => { /* 기본 1m 유지 */ });
|
||||
}, [symbol, appSettingsLoaded]);
|
||||
.then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes))
|
||||
.catch(() => { /* optional */ });
|
||||
}, [symbol, appSettingsLoaded, appDefaults.liveStrategyId]);
|
||||
|
||||
const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({
|
||||
market: symbol,
|
||||
@@ -714,8 +712,8 @@ function App() {
|
||||
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
|
||||
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
|
||||
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
|
||||
candleType: liveCandleType,
|
||||
}), [symbol, appDefaults, liveCandleType]);
|
||||
strategyCandleTypes: liveStrategyCandleTypes,
|
||||
}), [symbol, appDefaults, liveStrategyCandleTypes]);
|
||||
|
||||
/** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */
|
||||
const monitoredMarkets = useMemo(() => {
|
||||
@@ -727,13 +725,12 @@ function App() {
|
||||
|
||||
const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]);
|
||||
|
||||
/** STOMP 구독 목록 — API 로드 전에도 관심종목 기준으로 바로 구독 (연결 끊김 방지) */
|
||||
/** STOMP 구독 목록 — API 로드 전에도 관심종목 × 1m 폴백 구독 */
|
||||
const liveStompSubscriptions = useMemo(() => {
|
||||
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
|
||||
if (marketSubscriptions.length > 0) return marketSubscriptions;
|
||||
const ct = liveCandleType || '1m';
|
||||
return monitoredMarkets.map(m => ({ market: m, candleType: ct }));
|
||||
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets, liveCandleType]);
|
||||
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' }));
|
||||
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appDefaults.liveStrategyCheck) {
|
||||
@@ -741,11 +738,9 @@ function App() {
|
||||
return;
|
||||
}
|
||||
loadActiveLiveStrategySettings()
|
||||
.then(list => setMarketSubscriptions(
|
||||
list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })),
|
||||
))
|
||||
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
|
||||
.catch(() => { /* liveStompSubscriptions 가 monitoredMarkets 폴백 사용 */ });
|
||||
}, [appDefaults.liveStrategyCheck, monitoredMarkets.join(',')]);
|
||||
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(',')]);
|
||||
|
||||
const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => {
|
||||
saveAppDef({
|
||||
@@ -754,12 +749,12 @@ function App() {
|
||||
liveExecutionType: saved.executionType,
|
||||
livePositionMode: saved.positionMode,
|
||||
});
|
||||
if (saved.candleType) setLiveCandleType(saved.candleType);
|
||||
if (saved.strategyCandleTypes) {
|
||||
setLiveStrategyCandleTypes(saved.strategyCandleTypes);
|
||||
}
|
||||
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
|
||||
loadActiveLiveStrategySettings()
|
||||
.then(list => setMarketSubscriptions(
|
||||
list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })),
|
||||
))
|
||||
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
|
||||
.catch(() => { /* 폴백 유지 */ });
|
||||
}
|
||||
}, [saveAppDef, appDefaults.liveStrategyCheck]);
|
||||
@@ -983,8 +978,8 @@ function App() {
|
||||
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
|
||||
barsMarketRef.current = barsMarket;
|
||||
|
||||
/** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 */
|
||||
useEffect(() => {
|
||||
/** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 (paint 전에 실행) */
|
||||
useLayoutEffect(() => {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
}, [symbol, timeframe]);
|
||||
|
||||
@@ -1981,12 +1976,14 @@ function App() {
|
||||
onManagerReady={mgr => {
|
||||
managerRef.current = mgr;
|
||||
const pending = pendingRealtimeBarRef.current;
|
||||
if (pending && bars.length > 0) {
|
||||
if (pending && barsMarketRef.current === symbol && bars.length > 0) {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
const last = bars[bars.length - 1];
|
||||
if (last && pending.time === last.time) mgr.updateBar(pending);
|
||||
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
|
||||
else mgr.updateBar({ ...pending, time: last.time });
|
||||
} else {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다.
|
||||
*/
|
||||
import React, {
|
||||
useState, useEffect, useCallback, useRef, useMemo,
|
||||
useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo,
|
||||
useImperativeHandle, forwardRef,
|
||||
} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
@@ -249,6 +249,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
getTimeframe: () => timeframeRef.current,
|
||||
getIndicators: () => indicatorsRef.current,
|
||||
setSymbol: (s: string) => {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
// 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함
|
||||
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
||||
setSymbol(s);
|
||||
@@ -380,7 +381,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
// 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard)
|
||||
const [reloadTick, setReloadTick] = useState(0);
|
||||
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
reloadTriggeredRef.current = false;
|
||||
pendingRealtimeBarRef.current = null;
|
||||
}, [symbol, timeframe]);
|
||||
@@ -652,12 +653,14 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
onManagerReady={mgr => {
|
||||
managerRef.current = mgr;
|
||||
const pending = pendingRealtimeBarRef.current;
|
||||
if (pending) {
|
||||
if (pending && barsMarketRef.current === symbolRef.current && bars.length > 0) {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
const last = bars.length > 0 ? bars[bars.length - 1] : null;
|
||||
const last = bars[bars.length - 1];
|
||||
if (last && pending.time === last.time) mgr.updateBar(pending);
|
||||
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
|
||||
else mgr.updateBar({ ...pending, time: last.time });
|
||||
} else {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||
mgr.setVolumeVisible(chartVolumeVisible);
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
|
||||
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
|
||||
|
||||
interface Strategy {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -94,7 +92,9 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
const monCount = monitoredMarkets.length;
|
||||
const selectedStrategy = strategies.find(s => s.id === stratId);
|
||||
|
||||
const candleType = settings.candleType ?? '1m';
|
||||
const strategyTimeframes = settings.strategyCandleTypes?.length
|
||||
? settings.strategyCandleTypes.join(', ')
|
||||
: '1m';
|
||||
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
||||
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
|
||||
? '보유 자산 기준'
|
||||
@@ -175,7 +175,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
</div>
|
||||
<div className="lsp-info-row">
|
||||
<span className="lsp-info-label">평가 분봉</span>
|
||||
<span className="lsp-info-val">{candleType}</span>
|
||||
<span className="lsp-info-val" title="전략 편집기 START에서 설정">{strategyTimeframes}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -211,19 +211,6 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
|
||||
<span className="lsp-field-label">전략 평가 분봉</span>
|
||||
<select
|
||||
className="lsp-select"
|
||||
disabled={!isOn}
|
||||
value={candleType}
|
||||
onChange={e => persist({ candleType: e.target.value })}
|
||||
>
|
||||
{CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
|
||||
<span className="lsp-section-label">실행 방식</span>
|
||||
@@ -302,7 +289,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
|
||||
<span className="lsp-status-text">
|
||||
{stratId
|
||||
? `${selectedStrategy?.name ?? '전략'} · ${candleType} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
|
||||
? `${selectedStrategy?.name ?? '전략'} · ${strategyTimeframes} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
|
||||
: '전략을 선택하세요'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -300,8 +300,6 @@ interface PaperPanelProps {
|
||||
onLiveAutoTradeBudgetPct?: (v: number) => void;
|
||||
}
|
||||
|
||||
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
|
||||
|
||||
const PaperPanel: React.FC<PaperPanelProps> = ({
|
||||
paperTradingEnabled = true,
|
||||
onPaperTradingEnabled,
|
||||
@@ -844,20 +842,6 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="전략 평가 분봉" desc="이 종목의 매수·매도 시그널을 계산할 캔들 주기입니다.">
|
||||
<select
|
||||
className="stg-select"
|
||||
disabled={!isOn}
|
||||
value={liveSettings.candleType ?? '1m'}
|
||||
onChange={e => persistLive({ candleType: e.target.value })}
|
||||
style={{ opacity: isOn ? 1 : 0.45 }}
|
||||
>
|
||||
{CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
className="stg-row--block"
|
||||
label="체크 방식"
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
} from '../utils/strategyImportExport';
|
||||
import PaletteChip from './strategyEditor/PaletteChip';
|
||||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
|
||||
import '../styles/strategyEditor.css';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
|
||||
@@ -141,6 +142,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [descOpen, setDescOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const initialDraftBuy = readTabLayout('draft', 'buy');
|
||||
@@ -780,6 +782,21 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={handleNew}>+ 새 전략</button>
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
|
||||
title="전략 설명 — 현재 조건을 서술형으로 보기"
|
||||
aria-label="전략 설명"
|
||||
onClick={() => setDescOpen(true)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden className="se-desc-icon">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.75" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11.25 10.5h1.5V17h-1.5V10.5zm0-3.25h1.5V9h-1.5V6.25z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--ghost se-btn--icon"
|
||||
@@ -1154,6 +1171,20 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
</DraggableModalFrame>
|
||||
)}
|
||||
|
||||
{descOpen && (
|
||||
<StrategyDescriptionModal
|
||||
onClose={() => setDescOpen(false)}
|
||||
name={stratName}
|
||||
description={stratDesc}
|
||||
buyEditorState={buyEditorState}
|
||||
sellEditorState={sellEditorState}
|
||||
buyCondition={buyCondition}
|
||||
sellCondition={sellCondition}
|
||||
orphanCount={orphanTotal}
|
||||
def={DEF}
|
||||
/>
|
||||
)}
|
||||
|
||||
{snack && <div className={`se-snack${snack.ok ? '' : ' se-snack--err'}`}>{snack.msg}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import type { MouseEventParams, Time } from 'lightweight-charts';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
@@ -139,6 +139,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onSeriesDoubleClickRef.current = onSeriesDoubleClick;
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
const barsMarketRef = useRef(barsMarket);
|
||||
barsMarketRef.current = barsMarket;
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{
|
||||
x: number; y: number; price: number;
|
||||
@@ -165,8 +167,17 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
barsRef.current = bars;
|
||||
}, [bars]);
|
||||
const ready = barsMarket == null
|
||||
? false
|
||||
: barsMarket === undefined
|
||||
? true
|
||||
: barsMarket === market;
|
||||
if (ready) {
|
||||
barsRef.current = bars;
|
||||
} else if (barsMarket != null && barsMarket !== market) {
|
||||
barsRef.current = [];
|
||||
}
|
||||
}, [bars, barsMarket, market]);
|
||||
|
||||
useEffect(() => {
|
||||
indicatorsRef.current = indicators;
|
||||
@@ -687,13 +698,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
// 컨테이너가 처음으로 유효한 크기를 가질 때:
|
||||
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
|
||||
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
|
||||
reloadAll(
|
||||
const bm = barsMarketRef.current;
|
||||
if (bm != null && bm !== marketRef.current) return;
|
||||
if (marketRef.current.startsWith('KRW-') && barsRef.current.length < MIN_BARS_FOR_FULL_RELOAD) return;
|
||||
void reloadAll(
|
||||
m,
|
||||
barsRef.current,
|
||||
prevChartType.current,
|
||||
prevTheme.current,
|
||||
prevLogScale.current,
|
||||
[] // indicators는 별도 useEffect에서 처리
|
||||
indicatorsRef.current,
|
||||
);
|
||||
} else {
|
||||
applyPaneLayout(m);
|
||||
@@ -746,6 +760,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
prevMarketTf.current = timeframe;
|
||||
prevBarsKey.current = '';
|
||||
prevIndKey.current = '';
|
||||
barsRef.current = [];
|
||||
reloadRetryRef.current = 0;
|
||||
reloadGenRef.current += 1;
|
||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
import { fmtKrw } from '../TradeOrderPanel';
|
||||
import PaperSplitPanel from './PaperSplitPanel';
|
||||
|
||||
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
summary: PaperSummaryDto | null;
|
||||
@@ -45,7 +43,6 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
isLiveCheck: false,
|
||||
executionType: 'CANDLE_CLOSE',
|
||||
positionMode: 'LONG_ONLY',
|
||||
candleType: '1m',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -108,18 +105,14 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selected && <p className="ptd-left-hint">적용: {selected.name}</p>}
|
||||
<label className="ptd-left-field">
|
||||
<span>평가 분봉</span>
|
||||
<select
|
||||
className="ptd-left-select"
|
||||
value={settings?.candleType ?? '1m'}
|
||||
disabled={saving}
|
||||
onChange={e => void persist({ candleType: e.target.value })}
|
||||
>
|
||||
{CANDLE_TYPES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{selected && (
|
||||
<p className="ptd-left-hint">
|
||||
적용: {selected.name}
|
||||
{settings?.strategyCandleTypes?.length
|
||||
? ` · ${settings.strategyCandleTypes.join(', ')}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
<label className="ptd-left-field">
|
||||
<span>실행 방식</span>
|
||||
<select
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 전략 조건 서술형 설명 팝업
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import DraggableModalFrame from '../DraggableModalFrame';
|
||||
import {
|
||||
buildStrategyNarrative,
|
||||
type StrategyDescriptionInput,
|
||||
} from '../../utils/strategyDescriptionNarrative';
|
||||
|
||||
interface Props extends StrategyDescriptionInput {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StrategyDescriptionModal: React.FC<Props> = ({
|
||||
onClose,
|
||||
name,
|
||||
description,
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
orphanCount,
|
||||
def,
|
||||
}) => {
|
||||
const narrative = useMemo(
|
||||
() => buildStrategyNarrative({
|
||||
name,
|
||||
description,
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
orphanCount,
|
||||
def,
|
||||
}),
|
||||
[name, description, buyEditorState, sellEditorState, buyCondition, sellCondition, orphanCount, def],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableModalFrame
|
||||
onClose={onClose}
|
||||
title="전략 설명"
|
||||
titleKo="Strategy Description"
|
||||
badge="INFO"
|
||||
width={560}
|
||||
overlayClassName="se-desc-overlay"
|
||||
dialogClassName="se-desc-modal app-popup-shell"
|
||||
bodyClassName="se-desc-body app-popup-body"
|
||||
zIndex={11000}
|
||||
>
|
||||
<div className="se-desc-content">
|
||||
{narrative.intro.map((p, i) => (
|
||||
<p key={`intro-${i}`} className="se-desc-intro">{p}</p>
|
||||
))}
|
||||
|
||||
{narrative.sections.map(section => (
|
||||
<section key={section.title} className="se-desc-section">
|
||||
<h3 className="se-desc-section-title">{section.title}</h3>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<p key={`${section.title}-p-${i}`} className="se-desc-para">{p}</p>
|
||||
))}
|
||||
{section.bullets && section.bullets.length > 0 && (
|
||||
<ul className="se-desc-list">
|
||||
{section.bullets.map((line, i) => (
|
||||
<li key={`${section.title}-b-${i}`} className="se-desc-list-item">{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{narrative.footnotes.length > 0 && (
|
||||
<footer className="se-desc-footnotes">
|
||||
{narrative.footnotes.map((note, i) => (
|
||||
<p key={`fn-${i}`} className="se-desc-footnote">※ {note}</p>
|
||||
))}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyDescriptionModal;
|
||||
@@ -266,7 +266,6 @@ export function useStompChartData(
|
||||
|
||||
setWsStatus('connecting');
|
||||
setError(null);
|
||||
historyReadyRef.current = false;
|
||||
last1mVolRef.current = 0;
|
||||
last1mTimeRef.current = 0;
|
||||
void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE);
|
||||
|
||||
@@ -1552,6 +1552,82 @@
|
||||
color: var(--se-danger);
|
||||
}
|
||||
|
||||
/* ── 전략 설명 팝업 ── */
|
||||
.se-btn--desc .se-desc-icon {
|
||||
display: block;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.se-btn--desc:hover .se-desc-icon {
|
||||
opacity: 1;
|
||||
color: var(--se-accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.se-desc-overlay {
|
||||
z-index: 10999;
|
||||
}
|
||||
.se-desc-modal {
|
||||
max-height: min(88vh, 720px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.se-desc-body {
|
||||
overflow: auto;
|
||||
max-height: min(72vh, 600px);
|
||||
padding: 12px 16px 16px !important;
|
||||
}
|
||||
.se-desc-content {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.65;
|
||||
color: var(--se-text);
|
||||
}
|
||||
.se-desc-intro {
|
||||
margin: 0 0 12px;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
.se-desc-section {
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
.se-desc-section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.se-desc-section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text);
|
||||
}
|
||||
.se-desc-section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.se-desc-para {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.se-desc-list {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.se-desc-list-item {
|
||||
margin: 4px 0;
|
||||
padding-left: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.se-desc-footnotes {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--se-border, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
.se-desc-footnote {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--se-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.se-right { flex: 0 0 300px; width: 300px; }
|
||||
}
|
||||
|
||||
@@ -1015,8 +1015,10 @@ export interface LiveStrategySettingsDto {
|
||||
executionType: string;
|
||||
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */
|
||||
positionMode?: string;
|
||||
/** 전략 평가 분봉: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d */
|
||||
/** @deprecated 전략 DSL에서 시간봉을 자동 추출합니다 */
|
||||
candleType?: string;
|
||||
/** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */
|
||||
strategyCandleTypes?: string[];
|
||||
}
|
||||
|
||||
/** 실시간 전략 체크 설정 로드 */
|
||||
@@ -1025,7 +1027,7 @@ export async function loadLiveStrategySettings(
|
||||
): Promise<LiveStrategySettingsDto> {
|
||||
return (await request<LiveStrategySettingsDto>(
|
||||
`/strategy/settings?market=${encodeURIComponent(market)}`,
|
||||
)) ?? { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY', candleType: '1m' };
|
||||
)) ?? { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY' };
|
||||
}
|
||||
|
||||
/** 실시간 전략 체크 설정 저장 */
|
||||
@@ -1038,6 +1040,16 @@ export async function saveLiveStrategySettings(
|
||||
});
|
||||
}
|
||||
|
||||
/** 실시간 전략 STOMP 구독 목록 (종목 × 전략 DSL 시간봉) */
|
||||
export function expandLiveStrategySubscriptions(
|
||||
list: LiveStrategySettingsDto[],
|
||||
): { market: string; candleType: string }[] {
|
||||
return list.flatMap(s => {
|
||||
const types = s.strategyCandleTypes?.length ? s.strategyCandleTypes : ['1m'];
|
||||
return types.map(candleType => ({ market: s.market, candleType }));
|
||||
});
|
||||
}
|
||||
|
||||
/** 현재 디바이스에서 실시간 체크 ON + 전략 지정된 종목 목록 */
|
||||
export async function loadActiveLiveStrategySettings(): Promise<LiveStrategySettingsDto[]> {
|
||||
return (await request<LiveStrategySettingsDto[]>('/strategy/settings/active')) ?? [];
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
|
||||
*/
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import { CONDITION_LABEL } from './strategyTypes';
|
||||
import {
|
||||
collectEditorBranches,
|
||||
normalizeStartCombineOp,
|
||||
type EditorConditionState,
|
||||
} from './strategyConditionSerde';
|
||||
import {
|
||||
compositeDisplayName,
|
||||
normalizeCompositeCondition,
|
||||
} from './compositeIndicators';
|
||||
import {
|
||||
getFieldOpts,
|
||||
resolveFieldOptionValue,
|
||||
type DefType,
|
||||
} from './strategyEditorShared';
|
||||
import { parseThresholdField } from './conditionPeriods';
|
||||
import { formatStartCandleLabel } from './strategyStartNodes';
|
||||
|
||||
export interface StrategyDescriptionInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
buyEditorState: EditorConditionState;
|
||||
sellEditorState: EditorConditionState;
|
||||
buyCondition: LogicNode | null;
|
||||
sellCondition: LogicNode | null;
|
||||
orphanCount?: number;
|
||||
def: DefType;
|
||||
}
|
||||
|
||||
export interface StrategyNarrativeSection {
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
bullets?: string[];
|
||||
}
|
||||
|
||||
export interface StrategyNarrative {
|
||||
intro: string[];
|
||||
sections: StrategyNarrativeSection[];
|
||||
footnotes: string[];
|
||||
}
|
||||
|
||||
const CANDLE_KO: Record<string, string> = {
|
||||
'1m': '1분봉',
|
||||
'3m': '3분봉',
|
||||
'5m': '5분봉',
|
||||
'15m': '15분봉',
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1d': '일봉',
|
||||
};
|
||||
|
||||
function candleKo(ct: string): string {
|
||||
const key = formatStartCandleLabel(ct);
|
||||
return CANDLE_KO[key] ?? `${key} 봉`;
|
||||
}
|
||||
|
||||
function fieldLabel(
|
||||
indicatorType: string,
|
||||
field: string | undefined,
|
||||
def: DefType,
|
||||
signalType: 'buy' | 'sell',
|
||||
cond?: ReturnType<typeof normalizeCompositeCondition>,
|
||||
): string {
|
||||
const opts = getFieldOpts(indicatorType, signalType, def, cond);
|
||||
const resolved = resolveFieldOptionValue(indicatorType, field);
|
||||
const hit = opts.find(o => o.value === resolved);
|
||||
if (hit && hit.label !== '선택안함') return hit.label;
|
||||
const thresh = field ? parseThresholdField(field) : null;
|
||||
if (thresh != null) return `임계값 ${thresh}`;
|
||||
return field ?? indicatorType;
|
||||
}
|
||||
|
||||
function describeConditionType(
|
||||
conditionType: string,
|
||||
left: string,
|
||||
right: string,
|
||||
extras: { compareValue?: number; slopePeriod?: number; holdDays?: number },
|
||||
): string {
|
||||
const cv = extras.compareValue;
|
||||
const sp = extras.slopePeriod ?? 3;
|
||||
const hd = extras.holdDays ?? 3;
|
||||
|
||||
switch (conditionType) {
|
||||
case 'GT': return `${left}이(가) ${right}보다 큰 경우`;
|
||||
case 'LT': return `${left}이(가) ${right}보다 작은 경우`;
|
||||
case 'GTE': return `${left}이(가) ${right} 이상인 경우`;
|
||||
case 'LTE': return `${left}이(가) ${right} 이하인 경우`;
|
||||
case 'EQ': return `${left}과(와) ${right}이(가) 같은 경우`;
|
||||
case 'NEQ': return `${left}과(와) ${right}이(가) 다른 경우`;
|
||||
case 'CROSS_UP': return `${left}이(가) ${right}을(를) 상향 돌파하는 경우`;
|
||||
case 'CROSS_DOWN': return `${left}이(가) ${right}을(를) 하향 돌파하는 경우`;
|
||||
case 'SLOPE_UP': return `${left}이(가) 최근 ${sp}봉 동안 상승 추세인 경우`;
|
||||
case 'SLOPE_DOWN': return `${left}이(가) 최근 ${sp}봉 동안 하락 추세인 경우`;
|
||||
case 'DIFF_GT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 큰 경우`;
|
||||
case 'DIFF_LT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 작은 경우`;
|
||||
case 'HOLD_N_DAYS': return `조건이 ${hd}봉 연속 유지되는 경우`;
|
||||
case 'ABOVE_CLOUD': return '가격이 일목균형표 구름대 위에 있는 경우';
|
||||
case 'BELOW_CLOUD': return '가격이 일목균형표 구름대 아래에 있는 경우';
|
||||
case 'IN_CLOUD': return '가격이 일목균형표 구름 안에 있는 경우';
|
||||
case 'CLOUD_BREAK_UP': return '가격이 구름대를 상향 돌파하는 경우';
|
||||
case 'CLOUD_BREAK_DOWN': return '가격이 구름대를 하향 돌파하는 경우';
|
||||
case 'SPAN1_GT_SPAN2': return '선행스팬1이 선행스팬2보다 위에 있는 경우';
|
||||
case 'SPAN1_LT_SPAN2': return '선행스팬1이 선행스팬2보다 아래에 있는 경우';
|
||||
case 'SPAN1_CROSS_UP_SPAN2': return '선행스팬1이 선행스팬2를 상향 돌파하는 경우';
|
||||
case 'SPAN1_CROSS_DOWN_SPAN2': return '선행스팬1이 선행스팬2를 하향 돌파하는 경우';
|
||||
case 'LAGGING_GT_PRICE': return '후행스팬이 종가보다 위에 있는 경우';
|
||||
case 'LAGGING_LT_PRICE': return '후행스팬이 종가보다 아래에 있는 경우';
|
||||
default: {
|
||||
const label = CONDITION_LABEL[conditionType] ?? conditionType;
|
||||
if (right && right !== '선택안함') return `${left} — ${label} — ${right}`;
|
||||
return `${left} — ${label}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function describeCondition(
|
||||
cond: ReturnType<typeof normalizeCompositeCondition>,
|
||||
signalType: 'buy' | 'sell',
|
||||
def: DefType,
|
||||
): string {
|
||||
const ind = cond.indicatorType;
|
||||
const ct = cond.conditionType;
|
||||
|
||||
if (cond.composite && cond.leftPeriod && cond.rightPeriod) {
|
||||
const name = compositeDisplayName(ind).split(' + ')[0] ?? ind;
|
||||
const short = `${name} ${cond.leftPeriod}기간`;
|
||||
const long = `${name} ${cond.rightPeriod}기간`;
|
||||
switch (ct) {
|
||||
case 'GT': return `${short} 값이 ${long} 값보다 큰 경우`;
|
||||
case 'LT': return `${short} 값이 ${long} 값보다 작은 경우`;
|
||||
case 'GTE': return `${short} 값이 ${long} 값 이상인 경우`;
|
||||
case 'LTE': return `${short} 값이 ${long} 값 이하인 경우`;
|
||||
case 'CROSS_UP': return `${short} 값이 ${long} 값을 상향 돌파하는 경우`;
|
||||
case 'CROSS_DOWN': return `${short} 값이 ${long} 값을 하향 돌파하는 경우`;
|
||||
default: {
|
||||
const label = CONDITION_LABEL[ct] ?? ct;
|
||||
return `${short}과(와) ${long}을(를) 비교할 때 「${label}」 조건이 성립하는 경우`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const left = fieldLabel(ind, cond.leftField, def, signalType, cond);
|
||||
const right = fieldLabel(ind, cond.rightField, def, signalType, cond);
|
||||
|
||||
if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN'].includes(ct)) {
|
||||
return describeConditionType(ct, left, right, cond);
|
||||
}
|
||||
|
||||
if (right && right !== '선택안함') {
|
||||
return describeConditionType(ct, left, right, cond);
|
||||
}
|
||||
|
||||
const label = CONDITION_LABEL[ct] ?? ct;
|
||||
return `${left}에 대해 「${label}」 조건이 성립하는 경우`;
|
||||
}
|
||||
|
||||
function describeNode(
|
||||
node: LogicNode,
|
||||
signalType: 'buy' | 'sell',
|
||||
def: DefType,
|
||||
depth = 0,
|
||||
): string[] {
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = normalizeCompositeCondition(node.condition);
|
||||
const line = describeCondition(c, signalType, def);
|
||||
return [depth > 0 ? `• ${line}` : line];
|
||||
}
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const tf = candleKo(node.candleType ?? '1m');
|
||||
if (!inner) return [`[${tf}] 연결된 조건이 없습니다.`];
|
||||
const innerLines = describeNode(inner, signalType, def, depth + 1);
|
||||
return [`[${tf} 기준]`, ...innerLines.map(l => (l.startsWith('•') ? ` ${l}` : ` • ${l}`))];
|
||||
}
|
||||
|
||||
if (node.type === 'AND') {
|
||||
const children = node.children ?? [];
|
||||
if (children.length === 0) return ['(비어 있는 AND 그룹)'];
|
||||
const lines: string[] = [];
|
||||
if (depth === 0 && children.length > 1) {
|
||||
lines.push('다음 조건을 모두 동시에 만족해야 합니다.');
|
||||
}
|
||||
children.forEach((child, i) => {
|
||||
const sub = describeNode(child, signalType, def, depth + 1);
|
||||
if (children.length > 1) lines.push(`${i + 1})`);
|
||||
lines.push(...sub);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (node.type === 'OR') {
|
||||
const children = node.children ?? [];
|
||||
if (children.length === 0) return ['(비어 있는 OR 그룹)'];
|
||||
const lines: string[] = [];
|
||||
if (depth === 0 && children.length > 1) {
|
||||
lines.push('다음 조건 중 하나라도 만족하면 됩니다.');
|
||||
}
|
||||
children.forEach((child, i) => {
|
||||
const sub = describeNode(child, signalType, def, depth + 1);
|
||||
if (children.length > 1) lines.push(`${i + 1})`);
|
||||
lines.push(...sub);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (node.type === 'NOT') {
|
||||
const child = node.children?.[0];
|
||||
if (!child) return ['(비어 있는 NOT 그룹)'];
|
||||
const sub = describeNode(child, signalType, def, depth + 1);
|
||||
return ['다음 조건이 성립하지 않을 때:', ...sub.map(l => ` (부정) ${l.replace(/^•\s*/, '')}`)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function describeSignalBranches(
|
||||
editorState: EditorConditionState | undefined,
|
||||
fallbackRoot: LogicNode | null,
|
||||
signalType: 'buy' | 'sell',
|
||||
def: DefType,
|
||||
): { hasContent: boolean; paragraphs: string[]; bullets: string[] } {
|
||||
const branches = editorState
|
||||
? collectEditorBranches(editorState)
|
||||
: fallbackRoot
|
||||
? [{ candleType: '1m', root: fallbackRoot }]
|
||||
: [];
|
||||
|
||||
const active = branches.filter(b => b.root);
|
||||
if (active.length === 0) {
|
||||
return { hasContent: false, paragraphs: [], bullets: [] };
|
||||
}
|
||||
|
||||
const bullets: string[] = [];
|
||||
const paragraphs: string[] = [];
|
||||
|
||||
if (active.length === 1) {
|
||||
const { candleType, root } = active[0];
|
||||
const tf = candleKo(candleType);
|
||||
paragraphs.push(`${tf} 차트를 기준으로 아래 조건을 평가합니다.`);
|
||||
bullets.push(...describeNode(root!, signalType, def));
|
||||
return { hasContent: true, paragraphs, bullets };
|
||||
}
|
||||
|
||||
const combineOp = normalizeStartCombineOp(editorState?.startCombineOp);
|
||||
paragraphs.push(
|
||||
combineOp === 'AND'
|
||||
? '여러 시간봉에 걸친 조건을 모두 충족해야 합니다.'
|
||||
: '여러 시간봉 중 하나의 조건만 충족해도 됩니다.',
|
||||
);
|
||||
|
||||
active.forEach((branch, idx) => {
|
||||
const tf = candleKo(branch.candleType);
|
||||
bullets.push(`[시간봉 ${idx + 1}: ${tf}]`);
|
||||
if (branch.root) {
|
||||
bullets.push(...describeNode(branch.root, signalType, def, 1).map(l =>
|
||||
l.startsWith('•') ? ` ${l}` : ` • ${l}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
return { hasContent: true, paragraphs, bullets };
|
||||
}
|
||||
|
||||
function hasSignalContent(
|
||||
editorState: EditorConditionState,
|
||||
fallback: LogicNode | null,
|
||||
): boolean {
|
||||
if (collectEditorBranches(editorState).some(b => b.root)) return true;
|
||||
return !!fallback;
|
||||
}
|
||||
|
||||
export function buildStrategyNarrative(input: StrategyDescriptionInput): StrategyNarrative {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
orphanCount = 0,
|
||||
def,
|
||||
} = input;
|
||||
|
||||
const intro: string[] = [];
|
||||
if (name?.trim()) {
|
||||
intro.push(`「${name.trim()}」 전략의 매수·매도 조건을 사람이 읽기 쉬운 문장으로 풀어 쓴 설명입니다.`);
|
||||
} else {
|
||||
intro.push('현재 편집 중인 전략의 매수·매도 조건을 설명합니다.');
|
||||
}
|
||||
if (description?.trim()) {
|
||||
intro.push(`메모: ${description.trim()}`);
|
||||
}
|
||||
|
||||
const sections: StrategyNarrativeSection[] = [];
|
||||
const footnotes: string[] = [];
|
||||
|
||||
const buy = describeSignalBranches(buyEditorState, buyCondition, 'buy', def);
|
||||
if (hasSignalContent(buyEditorState, buyCondition)) {
|
||||
sections.push({
|
||||
title: '매수 조건 (진입)',
|
||||
paragraphs: [
|
||||
'실시간 체크 또는 백테스트에서 아래 매수 조건이 충족되면 매수 신호가 발생합니다.',
|
||||
...buy.paragraphs,
|
||||
],
|
||||
bullets: buy.bullets.length > 0 ? buy.bullets : undefined,
|
||||
});
|
||||
} else {
|
||||
sections.push({
|
||||
title: '매수 조건 (진입)',
|
||||
paragraphs: ['매수 조건이 아직 설정되지 않았습니다. 매수 탭에서 지표·논리 블록을 연결해 주세요.'],
|
||||
});
|
||||
}
|
||||
|
||||
const sell = describeSignalBranches(sellEditorState, sellCondition, 'sell', def);
|
||||
if (hasSignalContent(sellEditorState, sellCondition)) {
|
||||
sections.push({
|
||||
title: '매도 조건 (청산)',
|
||||
paragraphs: [
|
||||
'포지션을 보유한 상태에서 아래 매도 조건이 충족되면 매도 신호가 발생합니다. (시그널 모드에 따라 포지션 없이도 표시될 수 있습니다.)',
|
||||
...sell.paragraphs,
|
||||
],
|
||||
bullets: sell.bullets.length > 0 ? sell.bullets : undefined,
|
||||
});
|
||||
} else {
|
||||
sections.push({
|
||||
title: '매도 조건 (청산)',
|
||||
paragraphs: ['매도 조건이 아직 설정되지 않았습니다. 매도 탭에서 조건을 구성해 주세요.'],
|
||||
});
|
||||
}
|
||||
|
||||
if (orphanCount > 0) {
|
||||
footnotes.push(
|
||||
`캔버스에 연결되지 않은 요소 ${orphanCount}개는 실제 매매 판정에 포함되지 않습니다.`,
|
||||
);
|
||||
}
|
||||
footnotes.push(
|
||||
'각 START 노드에 지정한 시간봉이 실시간 전략 체크·백테스트의 평가 주기가 됩니다.',
|
||||
);
|
||||
footnotes.push(
|
||||
'지표 기간·임계값은 전략 빌더 우측 「전략 조건 전용 설정」에 표시된 값을 기준으로 합니다.',
|
||||
);
|
||||
|
||||
return { intro, sections, footnotes };
|
||||
}
|
||||
Reference in New Issue
Block a user