Compare commits
10 Commits
3cc75ff770
...
5edbb4d318
| Author | SHA1 | Date | |
|---|---|---|---|
| 5edbb4d318 | |||
| e72af1a9ed | |||
| 935ae02373 | |||
| 5438696fb3 | |||
| a3f65c26d0 | |||
| 25cbadbffb | |||
| f9447ea52f | |||
| ee8cd50781 | |||
| 2ae79ff71d | |||
| a2816b9163 |
@@ -0,0 +1,46 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.StrategyAgentChatRequest;
|
||||
import com.goldenchart.dto.StrategyAgentChatResponse;
|
||||
import com.goldenchart.service.StrategyAgentChatService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL 생성/수정.
|
||||
*
|
||||
* POST /api/strategy/agent/chat
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/strategy/agent")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StrategyAgentController {
|
||||
|
||||
private final StrategyAgentChatService agentService;
|
||||
|
||||
@PostMapping("/chat")
|
||||
public ResponseEntity<?> chat(
|
||||
@RequestBody StrategyAgentChatRequest body,
|
||||
@RequestHeader Map<String, String> headers,
|
||||
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
|
||||
TradingControllerSupport.requireRegisteredUser(headers);
|
||||
try {
|
||||
Long userId = TradingControllerSupport.parseUserId(headers);
|
||||
StrategyAgentChatResponse res = agentService.chat(body, userId, deviceId);
|
||||
return ResponseEntity.ok(res);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(503).body(Map.of("message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] chat 오류: {}", e.getMessage());
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("message", "AI 전략 생성 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.LlmModelsRequest;
|
||||
import com.goldenchart.dto.LlmModelsResponse;
|
||||
import com.goldenchart.dto.LlmTestResponse;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest;
|
||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse;
|
||||
import com.goldenchart.service.StrategyAgentChatService;
|
||||
import com.goldenchart.service.StrategyEvaluationAiVerifyJobService;
|
||||
import com.goldenchart.service.StrategyEvaluationLlmService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -28,6 +31,7 @@ public class StrategyEvaluationController {
|
||||
|
||||
private final StrategyEvaluationLlmService llmService;
|
||||
private final StrategyEvaluationAiVerifyJobService aiVerifyJobService;
|
||||
private final StrategyAgentChatService agentChatService;
|
||||
|
||||
@PostMapping("/ai-verify/jobs")
|
||||
public ResponseEntity<?> startAiVerifyJob(
|
||||
@@ -89,6 +93,20 @@ public class StrategyEvaluationController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/llm-models")
|
||||
public ResponseEntity<?> llmModels(
|
||||
@RequestBody(required = false) LlmModelsRequest body,
|
||||
@RequestHeader Map<String, String> headers) {
|
||||
TradingControllerSupport.requireRegisteredUser(headers);
|
||||
if (body == null || body.getHost() == null || body.getHost().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "호스트가 필요합니다."));
|
||||
}
|
||||
LlmModelsResponse res = agentChatService.listModels(
|
||||
body.getHost(), body.getPort(), body.getUseHttps(),
|
||||
body.getModelsPath(), body.getTimeoutMs());
|
||||
return ResponseEntity.ok(res);
|
||||
}
|
||||
|
||||
@PostMapping("/llm-test")
|
||||
public ResponseEntity<?> llmTest(
|
||||
@RequestHeader Map<String, String> headers,
|
||||
|
||||
@@ -114,4 +114,21 @@ public class BacktestSettingsDto {
|
||||
*/
|
||||
@Builder.Default
|
||||
private String tradeExecutionMode = "BACKTEST_ENGINE";
|
||||
|
||||
// ── 추세선 조건 (TREND_LINE_CROSS_*) ─────────────────────────────────────
|
||||
/** TWO_POINT | REGRESSION | SWING */
|
||||
@Builder.Default
|
||||
private String trendLineMode = "TWO_POINT";
|
||||
|
||||
/** 조건에 lookback 미지정 시 기본 N봉 */
|
||||
@Builder.Default
|
||||
private Integer trendLineDefaultLookback = 10;
|
||||
|
||||
/** SWING 모드 — 스윙 저점 좌측 확인 봉 수 */
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingPrecedingBars = 1;
|
||||
|
||||
/** SWING 모드 — 스윙 저점 우측 확인 봉 수 */
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingFollowingBars = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** LLM 모델 목록 조회 — 저장 전 입력값으로 /v1/models 프록시 */
|
||||
@Data
|
||||
public class LlmModelsRequest {
|
||||
private String host;
|
||||
private Integer port;
|
||||
private Boolean useHttps;
|
||||
/** OpenAI 호환 모델 목록 경로 (기본 /v1/models) */
|
||||
private String modelsPath;
|
||||
private Long timeoutMs;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** LLM 모델 목록 조회 결과 */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LlmModelsResponse {
|
||||
private boolean ok;
|
||||
private List<String> models;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** AI 전략 에이전트 — 멀티턴 대화 요청 */
|
||||
@Data
|
||||
public class StrategyAgentChatRequest {
|
||||
|
||||
/** user/assistant 대화 히스토리 (system 제외) */
|
||||
private List<ChatMessage> messages;
|
||||
|
||||
/** 현재 편집기 상태 컨텍스트 (buy/sell DSL, candleType, 지표 설정 등) */
|
||||
private JsonNode context;
|
||||
|
||||
@Data
|
||||
public static class ChatMessage {
|
||||
private String role;
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** AI 전략 에이전트 — 응답 */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StrategyAgentChatResponse {
|
||||
/** 사용자에게 보여줄 한국어 설명 (json 블록 제거됨) */
|
||||
private String reply;
|
||||
/** "ask"(추가 질문) | "build"(전략 생성/수정) */
|
||||
private String action;
|
||||
/** build일 때 매수 조건 LogicNode 트리 (없으면 null) */
|
||||
private JsonNode buyCondition;
|
||||
/** build일 때 매도 조건 LogicNode 트리 (없으면 null) */
|
||||
private JsonNode sellCondition;
|
||||
private String model;
|
||||
private long latencyMs;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 추세선 조건 평가 설정 — gc_backtest_settings 와 동기.
|
||||
* SWING: 스윙 피벗(저점·고점) 2개 연결 · TWO_POINT/REGRESSION: 피벗 실패 시 폴백
|
||||
*/
|
||||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
public class TrendLineConfig {
|
||||
|
||||
public static final String MODE_TWO_POINT = "TWO_POINT";
|
||||
public static final String MODE_REGRESSION = "REGRESSION";
|
||||
public static final String MODE_SWING = "SWING";
|
||||
|
||||
@Builder.Default
|
||||
private String mode = MODE_SWING;
|
||||
|
||||
@Builder.Default
|
||||
private int defaultLookback = 10;
|
||||
|
||||
@Builder.Default
|
||||
private int swingPrecedingBars = 1;
|
||||
|
||||
@Builder.Default
|
||||
private int swingFollowingBars = 1;
|
||||
|
||||
public static TrendLineConfig defaults() {
|
||||
return TrendLineConfig.builder().build();
|
||||
}
|
||||
|
||||
public static TrendLineConfig fromSettings(BacktestSettingsDto dto) {
|
||||
if (dto == null) return defaults();
|
||||
String mode = dto.getTrendLineMode();
|
||||
if (mode == null || mode.isBlank()) mode = MODE_TWO_POINT;
|
||||
if (!MODE_TWO_POINT.equals(mode) && !MODE_REGRESSION.equals(mode) && !MODE_SWING.equals(mode)) {
|
||||
mode = MODE_TWO_POINT;
|
||||
}
|
||||
int lookback = dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
|
||||
? dto.getTrendLineDefaultLookback() : 10;
|
||||
int prec = dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
|
||||
? dto.getTrendLineSwingPrecedingBars() : 1;
|
||||
int fol = dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
|
||||
? dto.getTrendLineSwingFollowingBars() : 1;
|
||||
return TrendLineConfig.builder()
|
||||
.mode(mode)
|
||||
.defaultLookback(lookback)
|
||||
.swingPrecedingBars(prec)
|
||||
.swingFollowingBars(fol)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
/**
|
||||
* 추세선 종류 — 돌파 방향과 짝을 이룸.
|
||||
* <ul>
|
||||
* <li>{@link #UP} 상승(지지) 추세선 — 스윙 저점(Low) 연결, 저점이 높아져야 함</li>
|
||||
* <li>{@link #DOWN} 하락(저항) 추세선 — 스윙 고점(High) 연결, 고점이 낮아져야 함</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum TrendLineKind {
|
||||
/** 지지선 — TREND_LINE_CROSS_DOWN (하향 돌파) */
|
||||
UP,
|
||||
/** 저항선 — TREND_LINE_CROSS_UP (상향 돌파) */
|
||||
DOWN;
|
||||
|
||||
public static TrendLineKind forCrossUp() {
|
||||
return DOWN;
|
||||
}
|
||||
|
||||
public static TrendLineKind forCrossDown() {
|
||||
return UP;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,23 @@ public class GcBacktestSettings {
|
||||
@Builder.Default
|
||||
private String tradeExecutionMode = "BACKTEST_ENGINE";
|
||||
|
||||
/** TWO_POINT | REGRESSION | SWING */
|
||||
@Column(name = "trend_line_mode", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private String trendLineMode = "TWO_POINT";
|
||||
|
||||
@Column(name = "trend_line_default_lookback", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineDefaultLookback = 10;
|
||||
|
||||
@Column(name = "trend_line_swing_preceding_bars", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingPrecedingBars = 1;
|
||||
|
||||
@Column(name = "trend_line_swing_following_bars", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingFollowingBars = 1;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -125,6 +125,21 @@ public class BacktestSettingsService {
|
||||
"SCAN_SIGNALS".equals(dto.getTradeExecutionMode())
|
||||
? "SCAN_SIGNALS"
|
||||
: "BACKTEST_ENGINE");
|
||||
entity.setTrendLineMode(normalizeTrendLineMode(dto.getTrendLineMode()));
|
||||
entity.setTrendLineDefaultLookback(
|
||||
dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
|
||||
? dto.getTrendLineDefaultLookback() : 10);
|
||||
entity.setTrendLineSwingPrecedingBars(
|
||||
dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
|
||||
? dto.getTrendLineSwingPrecedingBars() : 1);
|
||||
entity.setTrendLineSwingFollowingBars(
|
||||
dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
|
||||
? dto.getTrendLineSwingFollowingBars() : 1);
|
||||
}
|
||||
|
||||
private static String normalizeTrendLineMode(String mode) {
|
||||
if ("REGRESSION".equals(mode) || "SWING".equals(mode)) return mode;
|
||||
return "TWO_POINT";
|
||||
}
|
||||
|
||||
// ── 변환 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
@@ -157,6 +172,10 @@ public class BacktestSettingsService {
|
||||
"SCAN_SIGNALS".equals(e.getTradeExecutionMode())
|
||||
? "SCAN_SIGNALS"
|
||||
: "BACKTEST_ENGINE")
|
||||
.trendLineMode(normalizeTrendLineMode(e.getTrendLineMode()))
|
||||
.trendLineDefaultLookback(e.getTrendLineDefaultLookback() != null ? e.getTrendLineDefaultLookback() : 10)
|
||||
.trendLineSwingPrecedingBars(e.getTrendLineSwingPrecedingBars() != null ? e.getTrendLineSwingPrecedingBars() : 1)
|
||||
.trendLineSwingFollowingBars(e.getTrendLineSwingFollowingBars() != null ? e.getTrendLineSwingFollowingBars() : 1)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +132,11 @@ public class BacktestingService {
|
||||
series, primaryTf, buyDsl, sellDsl);
|
||||
|
||||
String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null;
|
||||
com.goldenchart.dto.TrendLineConfig trendLineConfig =
|
||||
com.goldenchart.dto.TrendLineConfig.fromSettings(cfg);
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, null, false, seriesOverrides);
|
||||
series, params, visual, market, null, false, seriesOverrides, trendLineConfig);
|
||||
Rule entryRule = adapter.toRule(buyDsl, ruleCtx);
|
||||
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
|
||||
? adapter.toRule(sellDsl, ruleCtx)
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.goldenchart.dto.BacktestResponse;
|
||||
import com.goldenchart.dto.BacktestSettingsDto;
|
||||
import com.goldenchart.dto.LiveConditionRowDto;
|
||||
import com.goldenchart.dto.LiveConditionStatusDto;
|
||||
import com.goldenchart.dto.OhlcvBar;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -38,6 +40,8 @@ public class LiveConditionStatusService {
|
||||
Map.entry("NEQ", "다름(≠)"),
|
||||
Map.entry("CROSS_UP", "상향 돌파"),
|
||||
Map.entry("CROSS_DOWN", "하향 돌파"),
|
||||
Map.entry("TREND_LINE_CROSS_UP", "추세선 상향 돌파"),
|
||||
Map.entry("TREND_LINE_CROSS_DOWN", "추세선 하향 돌파"),
|
||||
Map.entry("SLOPE_UP", "상승 중"),
|
||||
Map.entry("LOWEST_LTE", "N봉 최저 ≤"),
|
||||
Map.entry("LOWEST_GTE", "N봉 최저 ≥"),
|
||||
@@ -53,6 +57,27 @@ public class LiveConditionStatusService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
private final StrategyDslTimeframeNormalizer timeframeNormalizer;
|
||||
private final BacktestSettingsService backtestSettingsService;
|
||||
|
||||
private TrendLineConfig trendLineConfigFor(Long userId, String deviceId) {
|
||||
BacktestSettingsDto settings = backtestSettingsService.get(userId, deviceId);
|
||||
return TrendLineConfig.fromSettings(settings);
|
||||
}
|
||||
|
||||
private StrategyDslToTa4jAdapter.RuleBuildContext buildRuleCtx(
|
||||
BarSeries series,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
String market,
|
||||
Ta4jStorage storage,
|
||||
boolean useConfirmedOnly,
|
||||
Map<String, BarSeries> seriesOverrides,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
return new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, storage, useConfirmedOnly, seriesOverrides,
|
||||
trendLineConfigFor(userId, deviceId));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
|
||||
@@ -104,7 +129,7 @@ public class LiveConditionStatusService {
|
||||
}
|
||||
|
||||
if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) {
|
||||
return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe,
|
||||
return evaluateOnChartBars(userId, deviceId, strategy, market, strategyId, chartBars, chartTimeframe,
|
||||
barTimeSec, params, visual);
|
||||
}
|
||||
|
||||
@@ -142,8 +167,8 @@ public class LiveConditionStatusService {
|
||||
wrapper.set("condition", p.condition());
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
||||
buildRuleCtx(series, params, visual, market, ta4jStorage, false,
|
||||
java.util.Map.of(), userId, deviceId);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
@@ -167,9 +192,9 @@ public class LiveConditionStatusService {
|
||||
|
||||
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
|
||||
Boolean overallEntryMet = evaluateOverallRule(
|
||||
strategy.getBuyConditionJson(), market, params, visual, barTimeSec);
|
||||
strategy.getBuyConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
|
||||
Boolean overallExitMet = evaluateOverallRule(
|
||||
strategy.getSellConditionJson(), market, params, visual, barTimeSec);
|
||||
strategy.getSellConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
@@ -267,6 +292,8 @@ public class LiveConditionStatusService {
|
||||
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
|
||||
*/
|
||||
private LiveConditionStatusDto evaluateOnChartBars(
|
||||
Long userId,
|
||||
String deviceId,
|
||||
GcStrategy strategy,
|
||||
String market,
|
||||
long strategyId,
|
||||
@@ -314,8 +341,8 @@ public class LiveConditionStatusService {
|
||||
wrapper.set("condition", p.condition());
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(series, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
@@ -336,9 +363,11 @@ public class LiveConditionStatusService {
|
||||
|
||||
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
||||
Boolean overallEntryMet = evaluateOverallRuleOnChart(
|
||||
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
|
||||
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
|
||||
userId, deviceId);
|
||||
Boolean overallExitMet = evaluateOverallRuleOnChart(
|
||||
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
|
||||
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
|
||||
userId, deviceId);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
@@ -392,14 +421,16 @@ public class LiveConditionStatusService {
|
||||
String market,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
Long barTimeSec) {
|
||||
Long barTimeSec,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
if (dsl == null || dsl.isNull()) return null;
|
||||
try {
|
||||
if (primarySeries == null || primarySeries.isEmpty()) return null;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(primarySeries, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule rule = adapter.toRule(dsl, ctx);
|
||||
int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
|
||||
return rule.isSatisfied(index, null);
|
||||
@@ -441,7 +472,9 @@ public class LiveConditionStatusService {
|
||||
private Boolean evaluateOverallRule(String conditionJson, String market,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
Long barTimeSec) {
|
||||
Long barTimeSec,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
if (conditionJson == null || conditionJson.isBlank()) return null;
|
||||
try {
|
||||
String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper);
|
||||
@@ -461,8 +494,8 @@ public class LiveConditionStatusService {
|
||||
if (series.isEmpty()) return null;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
||||
buildRuleCtx(series, params, visual, market, ta4jStorage, false,
|
||||
java.util.Map.of(), userId, deviceId);
|
||||
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
|
||||
return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
|
||||
} catch (Exception e) {
|
||||
@@ -798,8 +831,8 @@ public class LiveConditionStatusService {
|
||||
int startIdx = resolveChartScanStartIndex(primarySeries, evalCount);
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(primarySeries, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule entryRule = (buyDsl != null && !buyDsl.isNull())
|
||||
? adapter.toRule(buyDsl, ruleCtx)
|
||||
: new org.ta4j.core.rules.BooleanRule(false);
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.goldenchart.dto.LlmModelsResponse;
|
||||
import com.goldenchart.dto.StrategyAgentChatRequest;
|
||||
import com.goldenchart.dto.StrategyAgentChatResponse;
|
||||
import com.goldenchart.service.LlmSettingsResolver.ResolvedLlmSettings;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL(LogicNode 트리)을 생성/수정한다.
|
||||
*
|
||||
* <p>설정에 저장된 LLM(OpenAI 호환)을 사용한다. 주식/코인 매매 전략 전문가로서 조언·전략 추천을 제공하며,
|
||||
* 정보가 부족하면 되묻고(action=ask), 충분하면 전략을 생성/수정하고(action=build), 초기화 요청 시 비운다(action=clear).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StrategyAgentChatService {
|
||||
|
||||
/** ```json ... ``` 또는 ``` ... ``` 코드펜스 추출 */
|
||||
private static final Pattern JSON_FENCE =
|
||||
Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final String SYSTEM_PROMPT = """
|
||||
당신은 GoldenChart 전략편집기의 AI 전략 에이전트이자, 보조지표·캔들차트·차트패턴을 활용한
|
||||
주식/코인 매매 전략에 정통한 전문 트레이더입니다.
|
||||
사용자의 자연어 요청을 받아 매매 시그널 전략을 GoldenChart 전략 DSL(JSON)로 구성/수정하며,
|
||||
동시에 전문가 관점의 조언을 제공합니다.
|
||||
|
||||
【전문가 역할 — 조언】
|
||||
- 단순히 시키는 대로만 만들지 말고, 트레이더의 시각에서 더 나은 구성을 제안하세요.
|
||||
예: 추세장/횡보장 적합성, 휩쏘(whipsaw) 위험, 후행성·과최적화 위험, 손절·필터 보강, 다중 시간봉 정합성.
|
||||
- 지표의 의미와 신호의 강·약을 짧게 설명하고(왜 이 조건이 매수/매도 신호인지), 보완 필터를 권할 수 있으면 권하세요.
|
||||
- 비현실적이거나 모순된 조건(예: 같은 봉에서 과매수 매수 + 과매도 매도)은 정중히 지적하고 대안을 제시하세요.
|
||||
- 설명은 간결한 한국어로. 과도하게 길게 쓰지 말고 핵심 위주로.
|
||||
|
||||
【전략 추천 — recommend】
|
||||
- 사용자가 "추천해줘"(예: "일목균형표 매수/매도 전략 추천해줘")라고 하면, 보통 2~4개의 구체적 전략 후보를
|
||||
번호로 제시하고 각 후보의 신호 논리·장단점·적합 시장을 한두 줄로 설명한 뒤,
|
||||
"몇 번을 적용할까요?"처럼 선택을 유도하세요. 이때는 DSL을 만들지 말고 질문 턴으로 둡니다. (action=ask)
|
||||
- 사용자가 번호를 고르거나 "그걸로 해줘"라고 하면 해당 후보를 DSL로 구성합니다. (action=build)
|
||||
- 사용자가 처음부터 "바로 만들어줘"라고 하면 가장 대표적인 후보 하나를 바로 build 해도 됩니다.
|
||||
|
||||
【평가 결과 해석·문제 진단 — analyze】
|
||||
- 사용자가 평가탭 결과 해석/분석/문제 진단(예: "매수 시그널이 왜 안 나와?", "이 결과 해석해줘",
|
||||
"매도가 너무 자주 나오는 원인 분석해줘")을 요청하면, 【현재 편집기 상태】의 evaluation 데이터
|
||||
(market·timeframe·barCount·signalsTotal·buyCount·sellCount·recentSignals·summary)와 현재 DSL을 근거로
|
||||
전문 트레이더 관점에서 분석합니다. 이때는 보통 설명만 제공하고 DSL은 바꾸지 않습니다. (action=ask)
|
||||
- evaluation 데이터가 없거나 signalsTotal이 비어있으면, 먼저 "평가탭에서 평가를 실행해 주세요"라고 안내하세요.
|
||||
- 시그널이 전혀/거의 안 나오는 흔한 원인과 해결책을 구체적으로 제시:
|
||||
· 조건 과다 AND 결합(특히 여러 CROSS 이벤트의 동시 충족은 매우 드묾) → 핵심 조건만 남기거나 일부를 EXISTS_IN(N봉 이내)로 완화
|
||||
· 임계값이 극단적(예: RSI 10/90) → 30/70 등 현실값으로 조정
|
||||
· CROSS_UP/DOWN을 같은 봉에서 다수 요구 → 윈도우(candleRangeMode=EXISTS_IN, candleRange=N)로 시간차 허용
|
||||
· 타임프레임 불일치 / 평가봉 수 부족(워밍업 미달로 일목·장기 MA 미계산) → 평가 구간 확대
|
||||
· 상호배타 조건(같은 봉 과매수 매수 + 과매도) → 논리 재구성
|
||||
- 시그널이 과도하게 많으면: 필터(추세 방향, 거래량 확인) 추가, AND 보강, 윈도우 축소를 권하세요.
|
||||
- 분석 끝에 개선책을 제안하고 "적용해 드릴까요?"로 물으세요. 사용자가 동의하면 해당 수정안을 build로 반영합니다.
|
||||
|
||||
【행동 규칙 — 멀티턴 에이전트】
|
||||
- 요청이 모호하거나 핵심 정보가 빠졌으면, 전략을 만들지 말고 한국어로 짧고 구체적으로 되물으세요. (action=ask)
|
||||
핵심 정보 예: 매수/매도 구분, 지표 기간(예: 일목 9/26/52), 임계값(예: RSI 30), 캔들 종류(예: 5분봉),
|
||||
"N봉 이내" 같은 윈도우 조건 여부.
|
||||
- 정보가 충분하면 전략을 생성하거나, 이미 전략이 있으면 그것을 수정합니다. (action=build)
|
||||
- 사용자가 "초기화", "리셋", "새 전략", "새 조건", "처음부터 다시", "다 지워" 등 전략을 비우고 처음부터
|
||||
새로 작성하길 원하면, 모든 조건을 비웁니다. (action=clear)
|
||||
- 현재 편집기 상태(【현재 편집기 상태】의 buyCondition/sellCondition)가 비어있지 않으면, 처음부터 새로
|
||||
만들지 말고 기존 DSL을 최소 변경으로 수정하세요. (단, 초기화 요청이면 clear)
|
||||
- 한 번에 한두 가지만 묻고, 합리적인 기본값이 있으면 가정하고 진행하되 가정한 내용을 설명에 명시하세요.
|
||||
|
||||
【출력 형식】
|
||||
- 항상 한국어 설명을 먼저 작성합니다.
|
||||
- action=build일 때만, 설명 뒤에 단 하나의 ```json 코드블록을 추가합니다. 형식:
|
||||
```json
|
||||
{ "action": "build", "buyCondition": <LogicNode 또는 null>, "sellCondition": <LogicNode 또는 null> }
|
||||
```
|
||||
- action=clear일 때는 설명 뒤에 ```json { "action": "clear" } ``` 블록만 추가합니다(조건 없음).
|
||||
- action=ask일 때는 json 코드블록을 절대 넣지 마세요(질문만).
|
||||
- 매수 전략만 요청되면 sellCondition은 null로 둡니다(반대도 동일).
|
||||
|
||||
【LogicNode 스키마】
|
||||
- 노드: { "id": string, "type": "AND"|"OR"|"NOT"|"CONDITION"|"TIMEFRAME", "children": [LogicNode], "condition": ConditionDSL }
|
||||
- AND/OR: children 배열을 결합. NOT: children[0] 부정. CONDITION: leaf 조건(condition 필수).
|
||||
- TIMEFRAME: { "type":"TIMEFRAME", "candleType":"5m", "children":[...] } — 하위 조건을 해당 분봉으로 평가.
|
||||
- id는 고유 문자열(예: "n1","n2"...).
|
||||
- ConditionDSL: {
|
||||
"indicatorType": string, "conditionType": string,
|
||||
"leftField": string, "rightField": string,
|
||||
"period": number?, "leftPeriod": number?, "rightPeriod": number?,
|
||||
"candleRangeMode": "CURRENT"|"EXISTS_IN"|"NOT_EXISTS_IN"?, "candleRange": number?,
|
||||
"valuePeriodOverride": boolean?, "thresholdOverride": boolean?, "targetValue": number?
|
||||
}
|
||||
|
||||
【conditionType】
|
||||
- 공통: GT, LT, GTE, LTE, EQ, NEQ, CROSS_UP, CROSS_DOWN, SLOPE_UP, SLOPE_DOWN, DIFF_GT, DIFF_LT, HOLD_N_DAYS, LOWEST_LTE, LOWEST_GTE, TREND_LINE_CROSS_UP, TREND_LINE_CROSS_DOWN
|
||||
- 일목 전용(indicatorType=ICHIMOKU): ABOVE_CLOUD, BELOW_CLOUD, IN_CLOUD, CLOUD_BREAK_UP, CLOUD_BREAK_DOWN, SPAN1_GT_SPAN2, SPAN1_LT_SPAN2, SPAN1_CROSS_UP_SPAN2, SPAN1_CROSS_DOWN_SPAN2, LAGGING_GT_PRICE, LAGGING_LT_PRICE, LAGGING_ABOVE_PRIOR_CLOUD, CHIKOU_CROSS_ABOVE_PRIOR_CLOUD
|
||||
- 거래량(indicatorType=VOLUME): VOLUME_GT_MA_RATIO
|
||||
- CROSS_UP/CROSS_DOWN은 "직전봉→현재봉" 교차 이벤트이며 단순 수준 비교(GT/LT)와 다릅니다.
|
||||
|
||||
【indicatorType 과 주요 필드(leftField/rightField)】
|
||||
- 가격/거래량: CLOSE_PRICE, OPEN_PRICE, HIGH_PRICE, LOW_PRICE, VOLUME_VALUE, VOLUME_MA, NONE
|
||||
- RSI: leftField RSI_VALUE / rightField HL_OVER|HL_MID|HL_UNDER 또는 K_{숫자}
|
||||
- MACD: MACD_LINE, SIGNAL_LINE, HISTOGRAM / rightField SIGNAL_LINE 또는 HL_MID(0선)
|
||||
- STOCHASTIC: STOCH_K, STOCH_D / rightField STOCH_D, HL_OVER|HL_UNDER, K_{숫자}
|
||||
- CCI: CCI_VALUE / OVERBOUGHT_100, OVERSOLD_NEG100, K_{숫자}
|
||||
- ADX: ADX_VALUE / ADX_25, K_{숫자}; DMI: PDI, MDI (서로 교차)
|
||||
- MA: MA1..MA11 (period1..11) — SMA 슬롯; EMA: EMA1..EMA11
|
||||
- BOLLINGER: UPPER_BAND, MIDDLE_BAND, LOWER_BAND (가격과 비교)
|
||||
- DONCHIAN: DC_UPPER_{9|10|20|55}, DC_LOWER_{...}, DC_MIDDLE_20
|
||||
- ICHIMOKU: CONVERSION_LINE(전환선), BASE_LINE(기준선), LEADING_SPAN1, LEADING_SPAN2, LAGGING_SPAN(후행스팬)
|
||||
- 임계값 직접지정: rightField "K_{숫자}", thresholdOverride=true, targetValue=숫자
|
||||
|
||||
【기간 지정】
|
||||
- 전략 전용 기간을 쓰려면 valuePeriodOverride=true 와 period 를 함께 지정(예: RSI 9 → period:9).
|
||||
- 일목 기본 9/26/52는 indicatorType=ICHIMOKU의 conditionType만으로 충분(별도 period 불필요).
|
||||
|
||||
【윈도우(N봉 이내)】
|
||||
- "최근 N봉 내 1회라도" → candleRangeMode:"EXISTS_IN", candleRange:N (N>=2)
|
||||
- "최근 N봉 내 한 번도 없음" → "NOT_EXISTS_IN"
|
||||
- 미지정 시 현재 봉만 평가(CURRENT).
|
||||
|
||||
【지표의 의미와 수학 — 조건 설계 기준】
|
||||
- 추세 지표(MA/EMA/일목/볼린저 중심선): 방향성. 골든크로스(단기 MA가 장기 MA를 CROSS_UP)=매수 전환,
|
||||
데드크로스(CROSS_DOWN)=매도 전환. 정배열/역배열로 추세 강도 판단.
|
||||
- 모멘텀/오실레이터(RSI·STOCH·CCI·Williams%R): 과매수/과매도 + 교차. RSI<30 과매도, >70 과매수.
|
||||
"과매도에서 상향돌파"는 RSI가 30선을 CROSS_UP(반등 매수), "과매수 이탈"은 70선 CROSS_DOWN(매도).
|
||||
수준 비교(GT/LT)와 교차(CROSS_UP/DOWN)는 다릅니다 — 교차는 직전봉→현재봉 2봉 이벤트.
|
||||
- MACD: MACD_LINE이 SIGNAL_LINE을 CROSS_UP=매수, 0선(HL_MID) 상향돌파=추세 강화. 히스토그램 부호 전환도 신호.
|
||||
- 볼린저밴드: LOWER_BAND 터치 후 반등=평균회귀 매수, UPPER_BAND 돌파=추세 추종. 밴드폭으로 변동성 판단.
|
||||
- 거래량: 신호의 확인용 필터로 권장. VOLUME_GT_MA_RATIO로 평균 대비 급증을 동반한 돌파만 채택하면 휩쏘 감소.
|
||||
- 다중 조건 결합: 추세(방향) AND 모멘텀(타이밍) AND 거래량(확인) 형태가 견고. 단일 오실레이터만 쓰면 추세장에서 약함.
|
||||
- 좋은 매수=여러 근거의 AND, 좋은 매도=빠른 이탈 위해 OR(손절·반전 중 하나라도)가 흔함.
|
||||
|
||||
【일목균형표(ICHIMOKU) 전략 지식 — 추천 재료】
|
||||
- 구성: 전환선(CONVERSION_LINE, 9), 기준선(BASE_LINE, 26), 선행스팬1/2(LEADING_SPAN1/2, 구름), 후행스팬(LAGGING_SPAN, 26봉 전).
|
||||
- 대표 매수 신호:
|
||||
1) 전환선이 기준선을 상향돌파(CROSS_UP) — 단기 모멘텀 전환(약~중 신호).
|
||||
2) 가격이 구름 위로 상향돌파(CLOUD_BREAK_UP) 또는 구름 위(ABOVE_CLOUD) — 추세 전환/유지(강 신호).
|
||||
3) 후행스팬이 26봉 전 종가를 상향돌파(LAGGING_GT_PRICE) — 후행 확인(신뢰도↑, 후행성 있음).
|
||||
4) 삼역호전(전환>기준 AND 가격>구름 AND 후행>가격)=강력 매수. AND로 결합.
|
||||
- 대표 매도 신호: 전환선<기준선(CROSS_DOWN), 가격 구름 하향돌파(CLOUD_BREAK_DOWN/BELOW_CLOUD), 후행<가격(LAGGING_LT_PRICE).
|
||||
- 추천 시 강도/지연 트레이드오프를 설명: (1)은 빠르지만 휩쏘 위험, (3)/삼역호전은 느리지만 신뢰도 높음.
|
||||
|
||||
【예시 0 — 전략 추천(ask)】
|
||||
사용자: "일목균형표 매수 전략 추천해줘"
|
||||
응답(요지): "일목균형표 매수 전략 3가지를 추천드립니다.
|
||||
1) 전환선 > 기준선 상향돌파 — 빠른 진입, 휩쏘 주의(횡보장 비권장)
|
||||
2) 가격의 구름 상향돌파 — 추세 전환 포착, 신뢰도 중상
|
||||
3) 삼역호전(전환>기준 AND 가격>구름 위 AND 후행스팬>가격) — 가장 보수적·신뢰도 높음, 진입 지연
|
||||
어떤 전략을 적용할까요? (예: 3번, 또는 '거래량 필터도 추가')" (json 블록 없음)
|
||||
|
||||
【예시 0-A — 평가 결과 분석(ask)】
|
||||
사용자: "매수 시그널이 하나도 안 나와. 왜 그런지 분석해줘"
|
||||
응답(요지, evaluation 데이터 기반): "현재 매수 조건이 '전환선>기준 상향돌파 AND 후행스팬>가격 상향돌파 AND
|
||||
RSI 80 상향돌파' 3개의 CROSS 이벤트를 같은 봉에서 모두 요구합니다. 서로 다른 교차가 한 봉에 동시에 일어날
|
||||
확률은 매우 낮아 시그널이 0건입니다. 또 RSI 80 돌파는 과매수 영역이라 매수 타이밍과 상충합니다.
|
||||
해결책: ① RSI 조건 제거 또는 30선 상향돌파로 변경, ② 후행스팬 조건을 '최근 4봉 이내(EXISTS_IN)'로 완화,
|
||||
③ 핵심은 '전환선>기준 상향돌파'만 남기기. 이 방향으로 수정해 적용해 드릴까요?" (json 블록 없음)
|
||||
|
||||
【예시 1 — 정보 부족(ask)】
|
||||
사용자: "골든크로스로 매수해줘"
|
||||
응답: "어떤 이동평균선 조합으로 골든크로스를 잡을까요? 예를 들어 단기 5 / 장기 20처럼 기간을 알려주세요. 캔들은 어떤 분봉인가요?"
|
||||
(json 블록 없음)
|
||||
|
||||
【예시 2 — 일목 전환선이 기준선 상향돌파 매수(build)】
|
||||
응답: "일목균형표 전환선이 기준선을 상향돌파할 때 매수하도록 구성했습니다."
|
||||
```json
|
||||
{ "action": "build",
|
||||
"buyCondition": { "id":"n1","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"CROSS_UP","leftField":"CONVERSION_LINE","rightField":"BASE_LINE","candleRangeMode":"CURRENT"} },
|
||||
"sellCondition": null }
|
||||
```
|
||||
|
||||
【예시 3 — 후행스팬이 26봉 전 캔들 상향돌파 AND 전환선이 기준선 상향돌파(build)】
|
||||
응답: "후행스팬의 가격 상향돌파와 전환선의 기준선 상향돌파를 모두 만족할 때 매수합니다."
|
||||
```json
|
||||
{ "action": "build",
|
||||
"buyCondition": { "id":"r","type":"AND","children":[
|
||||
{ "id":"a","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"LAGGING_GT_PRICE","leftField":"LAGGING_SPAN","rightField":"NONE","candleRangeMode":"CURRENT"} },
|
||||
{ "id":"b","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"CROSS_UP","leftField":"CONVERSION_LINE","rightField":"BASE_LINE","candleRangeMode":"CURRENT"} }
|
||||
] },
|
||||
"sellCondition": null }
|
||||
```
|
||||
""";
|
||||
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final LlmSettingsResolver llmSettingsResolver;
|
||||
|
||||
public StrategyAgentChatResponse chat(StrategyAgentChatRequest request, Long userId, String deviceId) throws Exception {
|
||||
ResolvedLlmSettings cfg = llmSettingsResolver.resolve(userId, deviceId);
|
||||
if (!cfg.enabled()) {
|
||||
throw new IllegalStateException("LLM 사용이 비활성화되어 있습니다. 설정 > LLM에서 사용을 켜 주세요.");
|
||||
}
|
||||
if (request == null || request.getMessages() == null || request.getMessages().isEmpty()) {
|
||||
throw new IllegalArgumentException("messages 가 필요합니다.");
|
||||
}
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
ObjectNode body = buildChatBody(cfg, request);
|
||||
String responseJson = postChat(cfg, body);
|
||||
JsonNode root = objectMapper.readTree(responseJson);
|
||||
String content = extractAssistantContent(root);
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new IllegalStateException("LLM 응답에서 텍스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return parseAssistantContent(content, root.path("model").asText(cfg.model()),
|
||||
System.currentTimeMillis() - started);
|
||||
}
|
||||
|
||||
/** 저장 전 입력값(host/port/useHttps/modelsPath)으로 /v1/models 프록시 조회 */
|
||||
public LlmModelsResponse listModels(String host, Integer port, Boolean useHttps,
|
||||
String modelsPath, Long timeoutMs) {
|
||||
int p = port != null ? Math.max(1, Math.min(65535, port)) : 16000;
|
||||
boolean https = useHttps != null && useHttps;
|
||||
String baseUrl = LlmSettingsResolver.buildBaseUrl(host, p, https);
|
||||
String path = (modelsPath == null || modelsPath.isBlank()) ? "/v1/models"
|
||||
: (modelsPath.startsWith("/") ? modelsPath.trim() : "/" + modelsPath.trim());
|
||||
long timeout = timeoutMs != null ? Math.max(2_000L, Math.min(60_000L, timeoutMs)) : 15_000L;
|
||||
|
||||
try {
|
||||
WebClient client = webClientBuilder.baseUrl(baseUrl).build();
|
||||
String json = client.get()
|
||||
.uri(path)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(timeout));
|
||||
if (json == null || json.isBlank()) {
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 목록 응답이 비어 있습니다.").build();
|
||||
}
|
||||
List<String> models = parseModelIds(objectMapper.readTree(json));
|
||||
if (models.isEmpty()) {
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 목록을 찾을 수 없습니다. (" + baseUrl + path + ")").build();
|
||||
}
|
||||
return LlmModelsResponse.builder().ok(true).models(models)
|
||||
.message(models.size() + "개 모델").build();
|
||||
} catch (WebClientResponseException e) {
|
||||
log.warn("[StrategyAgent] 모델 목록 HTTP {} — {}", e.getStatusCode(), e.getMessage());
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 서버 응답 오류: " + e.getStatusCode().value()).build();
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] 모델 목록 조회 실패: {}", e.getMessage());
|
||||
return LlmModelsResponse.builder().ok(false).models(List.of())
|
||||
.message("모델 서버에 연결할 수 없습니다. (" + baseUrl + path + ")").build();
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseModelIds(JsonNode root) {
|
||||
List<String> out = new ArrayList<>();
|
||||
JsonNode data = root.path("data");
|
||||
if (data.isArray()) {
|
||||
for (JsonNode m : data) {
|
||||
String id = m.path("id").asText(null);
|
||||
if (id != null && !id.isBlank()) out.add(id);
|
||||
}
|
||||
} else if (root.path("models").isArray()) {
|
||||
// 일부 서버는 {"models":[{"name":...}]} 형태(예: Ollama)
|
||||
for (JsonNode m : root.path("models")) {
|
||||
String id = m.path("id").asText(m.path("name").asText(null));
|
||||
if (id != null && !id.isBlank()) out.add(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private ObjectNode buildChatBody(ResolvedLlmSettings cfg, StrategyAgentChatRequest request) throws Exception {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", cfg.model());
|
||||
// DSL 출력은 길 수 있어 최소 2048 토큰 확보
|
||||
body.put("max_tokens", Math.max(cfg.maxTokens(), 2048));
|
||||
body.put("temperature", cfg.temperature());
|
||||
|
||||
StringBuilder system = new StringBuilder(SYSTEM_PROMPT);
|
||||
if (request.getContext() != null && !request.getContext().isNull()) {
|
||||
system.append("\n\n【현재 편집기 상태】\n")
|
||||
.append(objectMapper.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(request.getContext()));
|
||||
}
|
||||
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
messages.addObject().put("role", "system").put("content", system.toString());
|
||||
for (StrategyAgentChatRequest.ChatMessage m : request.getMessages()) {
|
||||
if (m == null || m.getContent() == null || m.getContent().isBlank()) continue;
|
||||
String role = "assistant".equals(m.getRole()) ? "assistant" : "user";
|
||||
messages.addObject().put("role", role).put("content", m.getContent());
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private String postChat(ResolvedLlmSettings cfg, ObjectNode body) {
|
||||
WebClient client = webClientBuilder.baseUrl(cfg.baseUrl()).build();
|
||||
try {
|
||||
String responseJson = client.post()
|
||||
.uri(cfg.chatPath())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofMillis(cfg.timeoutMs()));
|
||||
if (responseJson == null || responseJson.isBlank()) {
|
||||
throw new IllegalStateException("LLM 응답이 비어 있습니다.");
|
||||
}
|
||||
return responseJson;
|
||||
} catch (WebClientResponseException e) {
|
||||
log.warn("[StrategyAgent] LLM HTTP {} — {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw new IllegalStateException("LLM 서버 응답 오류: " + e.getStatusCode().value());
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] LLM 연결 실패: {}", e.getMessage());
|
||||
throw new IllegalStateException("LLM 서버에 연결할 수 없습니다. (" + cfg.baseUrl() + cfg.chatPath() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractAssistantContent(JsonNode root) {
|
||||
JsonNode choices = root.path("choices");
|
||||
if (!choices.isArray() || choices.isEmpty()) return null;
|
||||
JsonNode message = choices.get(0).path("message");
|
||||
if (message.hasNonNull("content")) {
|
||||
return message.path("content").asText(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 어시스턴트 content에서 설명과 ```json DSL 블록을 분리·파싱 */
|
||||
StrategyAgentChatResponse parseAssistantContent(String content, String model, long latencyMs) {
|
||||
String reply = content.trim();
|
||||
JsonNode buy = null;
|
||||
JsonNode sell = null;
|
||||
String action = "ask";
|
||||
|
||||
Matcher matcher = JSON_FENCE.matcher(content);
|
||||
String jsonBlock = null;
|
||||
while (matcher.find()) {
|
||||
String candidate = matcher.group(1).trim();
|
||||
if (candidate.startsWith("{") || candidate.startsWith("[")) {
|
||||
jsonBlock = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonBlock != null) {
|
||||
try {
|
||||
JsonNode parsed = objectMapper.readTree(jsonBlock);
|
||||
JsonNode buyNode = firstNonMissing(parsed, "buyCondition", "buy");
|
||||
JsonNode sellNode = firstNonMissing(parsed, "sellCondition", "sell");
|
||||
buy = nullIfEmpty(buyNode);
|
||||
sell = nullIfEmpty(sellNode);
|
||||
String parsedAction = parsed.path("action").asText(null);
|
||||
if (buy != null || sell != null) {
|
||||
action = "build";
|
||||
} else if (parsedAction != null) {
|
||||
action = parsedAction;
|
||||
}
|
||||
// 설명에서 json 코드펜스 제거
|
||||
reply = JSON_FENCE.matcher(content).replaceAll("").trim();
|
||||
if (reply.isEmpty()) {
|
||||
reply = action.equals("build") ? "요청하신 조건으로 전략을 구성했습니다." : content.trim();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[StrategyAgent] DSL JSON 파싱 실패: {}", e.getMessage());
|
||||
action = "ask";
|
||||
}
|
||||
}
|
||||
|
||||
return StrategyAgentChatResponse.builder()
|
||||
.reply(reply)
|
||||
.action(action)
|
||||
.buyCondition(buy)
|
||||
.sellCondition(sell)
|
||||
.model(model)
|
||||
.latencyMs(latencyMs)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static JsonNode firstNonMissing(JsonNode parent, String... keys) {
|
||||
for (String key : keys) {
|
||||
JsonNode node = parent.get(key);
|
||||
if (node != null && !node.isMissingNode()) return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonNode nullIfEmpty(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) return null;
|
||||
if (node.isObject() && node.isEmpty()) return null;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -122,20 +124,37 @@ public class StrategyDslToTa4jAdapter {
|
||||
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
|
||||
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
|
||||
*/
|
||||
Map<String, BarSeries> seriesOverrides
|
||||
Map<String, BarSeries> seriesOverrides,
|
||||
/** 추세선 조건(TREND_LINE_CROSS_*) — 전략설정 gc_backtest_settings */
|
||||
TrendLineConfig trendLineConfig
|
||||
) {
|
||||
/** visual 미전달 호환 (레거시) */
|
||||
public RuleBuildContext(BarSeries primarySeries,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
String market,
|
||||
Ta4jStorage storage) {
|
||||
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of());
|
||||
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of(), null);
|
||||
}
|
||||
|
||||
public RuleBuildContext(BarSeries primarySeries,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market,
|
||||
Ta4jStorage storage,
|
||||
boolean useConfirmedOnly,
|
||||
Map<String, BarSeries> seriesOverrides) {
|
||||
this(primarySeries, indicatorParams, indicatorVisual, market, storage,
|
||||
useConfirmedOnly, seriesOverrides, null);
|
||||
}
|
||||
|
||||
public TrendLineConfig effectiveTrendLineConfig() {
|
||||
return trendLineConfig != null ? trendLineConfig : TrendLineConfig.defaults();
|
||||
}
|
||||
|
||||
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
|
||||
public RuleBuildContext withPrimary(BarSeries newPrimary) {
|
||||
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
|
||||
market, storage, useConfirmedOnly, seriesOverrides);
|
||||
market, storage, useConfirmedOnly, seriesOverrides, trendLineConfig);
|
||||
}
|
||||
|
||||
/** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */
|
||||
@@ -170,20 +189,20 @@ public class StrategyDslToTa4jAdapter {
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +213,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, BarSeries> seriesOverrides) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false,
|
||||
seriesOverrides != null ? seriesOverrides : Map.of()));
|
||||
seriesOverrides != null ? seriesOverrides : Map.of(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +223,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, RuleBuildContext ctx) {
|
||||
@@ -508,6 +527,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
// 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외)
|
||||
case "CROSS_UP" -> buildCrossUpRule(left, right);
|
||||
case "CROSS_DOWN" -> buildCrossDownRule(left, right);
|
||||
case "TREND_LINE_CROSS_UP", "TREND_LINE_CROSS_DOWN" -> {
|
||||
int lb = cond.path("lookbackPeriod").asInt(0);
|
||||
if (lb <= 0) lb = ctx.effectiveTrendLineConfig().getDefaultLookback();
|
||||
lb = Math.max(2, lb);
|
||||
boolean priceScale = isTrendLinePriceScaleField(leftField);
|
||||
yield "TREND_LINE_CROSS_UP".equals(condType)
|
||||
? buildTrendLineCrossUpRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale)
|
||||
: buildTrendLineCrossDownRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale);
|
||||
}
|
||||
case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
|
||||
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
|
||||
case "DIFF_GT" -> {
|
||||
@@ -1657,6 +1685,66 @@ public class StrategyDslToTa4jAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* N봉 추세선 돌파 — [i−N, i−1] 구간으로 고정된 동일 선을 i−1·i 에서 평가.
|
||||
* (일반 CROSS 와 달리 추세선 창이 봉마다 밀리지 않음)
|
||||
*/
|
||||
private Rule buildTrendLineCrossUpRule(
|
||||
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
if (index < 1) return false;
|
||||
TrendLineKind kind = TrendLineKind.forCrossUp();
|
||||
double linePrev = TrendLineMath.valueAtEval(
|
||||
series, left, index, index - 1, lookback, config, kind, priceScale);
|
||||
double lineCurr = TrendLineMath.valueAtEval(
|
||||
series, left, index, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
|
||||
Num l0 = left.getValue(index - 1);
|
||||
Num l1 = left.getValue(index);
|
||||
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
|
||||
Num r0 = series.numFactory().numOf(linePrev);
|
||||
Num r1 = series.numFactory().numOf(lineCurr);
|
||||
return l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Rule buildTrendLineCrossDownRule(
|
||||
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
if (index < 1) return false;
|
||||
TrendLineKind kind = TrendLineKind.forCrossDown();
|
||||
double linePrev = TrendLineMath.valueAtEval(
|
||||
series, left, index, index - 1, lookback, config, kind, priceScale);
|
||||
double lineCurr = TrendLineMath.valueAtEval(
|
||||
series, left, index, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
|
||||
Num l0 = left.getValue(index - 1);
|
||||
Num l1 = left.getValue(index);
|
||||
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
|
||||
Num r0 = series.numFactory().numOf(linePrev);
|
||||
Num r1 = series.numFactory().numOf(lineCurr);
|
||||
return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 추세선 — 가격축(OHLC·MA 등) vs 오실레이터 */
|
||||
private static boolean isTrendLinePriceScaleField(String field) {
|
||||
if (field == null || field.isBlank() || "NONE".equalsIgnoreCase(field)) return true;
|
||||
String f = field.toUpperCase();
|
||||
return f.contains("CLOSE") || f.contains("PRICE") || f.contains("HIGH") || f.contains("LOW")
|
||||
|| f.contains("OPEN") || f.contains("MA") || f.contains("EMA")
|
||||
|| f.contains("UPPER") || f.contains("MIDDLE") || f.contains("LOWER")
|
||||
|| f.contains("BAND") || f.contains("DC_") || f.contains("VWAP")
|
||||
|| f.contains("CONVERSION") || f.contains("BASE") || f.contains("SPAN")
|
||||
|| f.contains("LAGGING") || f.contains("CLOUD");
|
||||
}
|
||||
|
||||
/**
|
||||
* CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다.
|
||||
* sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* N봉 구간 추세선 — 스윙 피벗(Pivot) 기준.
|
||||
* <p>
|
||||
* fit 구간 [anchor−N, anchor−1] (현재 봉 제외).
|
||||
* 상승 추세선(UP): 스윙 저점 2개(Low, Low₂>Low₁) · 하락 추세선(DOWN): 스윙 고점 2개(High, High₂<High₁).
|
||||
* anchor 봉 i 돌파 판정: 동일 선을 i−1·i 에 extrapolation.
|
||||
* 차트 선분: 피벗 A → anchor(i)까지 연장.
|
||||
*/
|
||||
public final class TrendLineMath {
|
||||
|
||||
private TrendLineMath() {}
|
||||
|
||||
private record PivotPair(int indexA, double yA, int indexB, double yB) {}
|
||||
|
||||
/** anchor 봉 기준 [anchor−N, anchor−1] 추세선을 evalIndex 에서 평가 */
|
||||
public static double valueAtEval(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int anchorIndex,
|
||||
int evalIndex,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (series == null || source == null || config == null || kind == null
|
||||
|| anchorIndex < 1 || lookback < 2) {
|
||||
return Double.NaN;
|
||||
}
|
||||
int start = anchorIndex - lookback;
|
||||
int end = anchorIndex - 1;
|
||||
if (start < series.getBeginIndex() || end < start) return Double.NaN;
|
||||
|
||||
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||
if (pivots != null) {
|
||||
return lineAt(pivots, evalIndex);
|
||||
}
|
||||
return fallbackValueAt(series, source, start, end, evalIndex, config, kind, priceScale);
|
||||
}
|
||||
|
||||
/** anchor = index — i 봉 돌파 판정용 */
|
||||
public static double valueAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int index,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
return valueAtEval(series, source, index, index, lookback, config, kind, priceScale);
|
||||
}
|
||||
|
||||
/** 시그널 차트용 — 피벗 기준 fit 후 index(시그널 봉)까지 연장 */
|
||||
public record Segment(long startTimeSec, double startPrice, long endTimeSec, double endPrice) {}
|
||||
|
||||
public static Segment segmentAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int index,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (series == null || source == null || config == null || kind == null
|
||||
|| index < 1 || lookback < 2) {
|
||||
return null;
|
||||
}
|
||||
int start = index - lookback;
|
||||
int end = index - 1;
|
||||
if (start < series.getBeginIndex() || end < start) return null;
|
||||
|
||||
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||
if (pivots != null) {
|
||||
double yAt = lineAt(pivots, index);
|
||||
if (!Double.isFinite(yAt)) return null;
|
||||
return new Segment(
|
||||
barTimeSec(series, pivots.indexA()), pivots.yA(),
|
||||
barTimeSec(series, index), yAt);
|
||||
}
|
||||
return fallbackSegment(series, source, start, end, index, config, kind, priceScale);
|
||||
}
|
||||
|
||||
// ── 피벗 선정 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static PivotPair resolvePivots(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
int kLeft = Math.max(0, config.getSwingPrecedingBars());
|
||||
int kRight = Math.max(0, config.getSwingFollowingBars());
|
||||
List<Integer> swings = collectSwingIndices(series, source, start, end, kLeft, kRight, kind, priceScale);
|
||||
if (swings.size() < 2) return null;
|
||||
|
||||
int swingA = swings.get(0);
|
||||
for (int j = 1; j < swings.size(); j++) {
|
||||
int swingB = swings.get(j);
|
||||
if (swingB <= swingA) continue;
|
||||
double yA = yAtPivot(series, source, swingA, kind, priceScale);
|
||||
double yB = yAtPivot(series, source, swingB, kind, priceScale);
|
||||
if (!Double.isFinite(yA) || !Double.isFinite(yB)) continue;
|
||||
if (kind == TrendLineKind.UP && yB > yA) {
|
||||
return new PivotPair(swingA, yA, swingB, yB);
|
||||
}
|
||||
if (kind == TrendLineKind.DOWN && yB < yA) {
|
||||
return new PivotPair(swingA, yA, swingB, yB);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Integer> collectSwingIndices(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int kLeft,
|
||||
int kRight,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
List<Integer> out = new ArrayList<>();
|
||||
for (int i = start + kLeft; i <= end - kRight; i++) {
|
||||
boolean isSwing = kind == TrendLineKind.UP
|
||||
? isSwingLow(series, source, i, kLeft, kRight, priceScale)
|
||||
: isSwingHigh(series, source, i, kLeft, kRight, priceScale);
|
||||
if (isSwing) out.add(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double yAtPivot(
|
||||
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||
if (priceScale) {
|
||||
return kind == TrendLineKind.UP
|
||||
? series.getBar(index).getLowPrice().doubleValue()
|
||||
: series.getBar(index).getHighPrice().doubleValue();
|
||||
}
|
||||
Double v = numAt(source, index);
|
||||
return v != null ? v : Double.NaN;
|
||||
}
|
||||
|
||||
private static boolean isSwingLow(
|
||||
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||
double low = priceScale
|
||||
? series.getBar(i).getLowPrice().doubleValue()
|
||||
: requireNum(source, i);
|
||||
if (!Double.isFinite(low)) return false;
|
||||
for (int j = i - prec; j <= i + fol; j++) {
|
||||
if (j == i) continue;
|
||||
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||
double v = priceScale
|
||||
? series.getBar(j).getLowPrice().doubleValue()
|
||||
: requireNum(source, j);
|
||||
if (!Double.isFinite(v) || v < low) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isSwingHigh(
|
||||
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||
double high = priceScale
|
||||
? series.getBar(i).getHighPrice().doubleValue()
|
||||
: requireNum(source, i);
|
||||
if (!Double.isFinite(high)) return false;
|
||||
for (int j = i - prec; j <= i + fol; j++) {
|
||||
if (j == i) continue;
|
||||
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||
double v = priceScale
|
||||
? series.getBar(j).getHighPrice().doubleValue()
|
||||
: requireNum(source, j);
|
||||
if (!Double.isFinite(v) || v > high) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double lineAt(PivotPair p, int evalIndex) {
|
||||
if (p.indexB() == p.indexA()) return p.yA();
|
||||
double t = (evalIndex - p.indexA()) / (double) (p.indexB() - p.indexA());
|
||||
return p.yA() + (p.yB() - p.yA()) * t;
|
||||
}
|
||||
|
||||
// ── 모드별 폴백 (피벗 2개 미충족 시) ─────────────────────────────────────
|
||||
|
||||
private static double fallbackValueAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int evalIndex,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||
return Double.NaN;
|
||||
}
|
||||
if (TrendLineConfig.MODE_REGRESSION.equals(normalizeMode(config.getMode()))) {
|
||||
return regressionAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
return twoPointAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
|
||||
private static Segment fallbackSegment(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int anchorIndex,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||
return null;
|
||||
}
|
||||
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||
if (!Double.isFinite(y0)) return null;
|
||||
double yAt = fallbackValueAt(series, source, start, end, anchorIndex, config, kind, priceScale);
|
||||
if (!Double.isFinite(yAt)) return null;
|
||||
return new Segment(barTimeSec(series, start), y0, barTimeSec(series, anchorIndex), yAt);
|
||||
}
|
||||
|
||||
private static double yAtEndpoint(
|
||||
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||
return yAtPivot(series, source, index, kind, priceScale);
|
||||
}
|
||||
|
||||
private static double twoPointAt(
|
||||
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||
TrendLineKind kind, boolean priceScale) {
|
||||
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||
double y1 = yAtEndpoint(series, source, end, kind, priceScale);
|
||||
if (!Double.isFinite(y0) || !Double.isFinite(y1) || end == start) return Double.NaN;
|
||||
double t = (evalIndex - start) / (double) (end - start);
|
||||
return y0 + (y1 - y0) * t;
|
||||
}
|
||||
|
||||
private static double regressionAt(
|
||||
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||
TrendLineKind kind, boolean priceScale) {
|
||||
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
||||
int count = 0;
|
||||
for (int i = start; i <= end; i++) {
|
||||
double y = yAtEndpoint(series, source, i, kind, priceScale);
|
||||
if (!Double.isFinite(y)) continue;
|
||||
double x = i - start;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumXX += x * x;
|
||||
count++;
|
||||
}
|
||||
if (count < 2) return Double.NaN;
|
||||
double denom = count * sumXX - sumX * sumX;
|
||||
if (Math.abs(denom) < 1e-12) return Double.NaN;
|
||||
double b = (count * sumXY - sumX * sumY) / denom;
|
||||
double a = (sumY - b * sumX) / count;
|
||||
return a + b * (evalIndex - start);
|
||||
}
|
||||
|
||||
private static String normalizeMode(String mode) {
|
||||
if (TrendLineConfig.MODE_REGRESSION.equals(mode)) return TrendLineConfig.MODE_REGRESSION;
|
||||
if (TrendLineConfig.MODE_SWING.equals(mode)) return TrendLineConfig.MODE_SWING;
|
||||
return TrendLineConfig.MODE_TWO_POINT;
|
||||
}
|
||||
|
||||
private static double requireNum(Indicator<Num> source, int index) {
|
||||
Double v = numAt(source, index);
|
||||
return v != null ? v : Double.NaN;
|
||||
}
|
||||
|
||||
private static Double numAt(Indicator<Num> source, int index) {
|
||||
if (index < 0) return null;
|
||||
Num n = source.getValue(index);
|
||||
if (n == null) return null;
|
||||
double v = n.doubleValue();
|
||||
return Double.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
private static long barTimeSec(BarSeries series, int index) {
|
||||
return series.getBar(index).getEndTime().getEpochSecond();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.indicators.AbstractIndicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import static org.ta4j.core.num.NaN.NaN;
|
||||
|
||||
/** bar index 에서 N봉 추세선 Y값 — left 지표·종가와 CROSS 비교용 */
|
||||
public class TrendLineValueIndicator extends AbstractIndicator<Num> {
|
||||
|
||||
private final Indicator<Num> source;
|
||||
private final BarSeries series;
|
||||
private final int lookback;
|
||||
private final TrendLineConfig config;
|
||||
private final TrendLineKind kind;
|
||||
private final boolean priceScale;
|
||||
|
||||
public TrendLineValueIndicator(
|
||||
Indicator<Num> source,
|
||||
BarSeries series,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
super(series);
|
||||
this.source = source;
|
||||
this.series = series;
|
||||
this.lookback = Math.max(2, lookback);
|
||||
this.config = config != null ? config : TrendLineConfig.defaults();
|
||||
this.kind = kind != null ? kind : TrendLineKind.DOWN;
|
||||
this.priceScale = priceScale;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Num getValue(int index) {
|
||||
double v = TrendLineMath.valueAt(series, source, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(v)) {
|
||||
return NaN;
|
||||
}
|
||||
return series.numFactory().numOf(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCountOfUnstableBars() {
|
||||
return lookback + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 추세선 전략 조건 — 백테스트/전략평가 공통 설정
|
||||
ALTER TABLE gc_backtest_settings
|
||||
ADD COLUMN trend_line_mode VARCHAR(20) NOT NULL DEFAULT 'TWO_POINT'
|
||||
COMMENT 'TWO_POINT | REGRESSION | SWING',
|
||||
ADD COLUMN trend_line_default_lookback INT NOT NULL DEFAULT 10,
|
||||
ADD COLUMN trend_line_swing_preceding_bars INT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN trend_line_swing_following_bars INT NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
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.indicators.helpers.ClosePriceIndicator;
|
||||
import org.ta4j.core.num.DecimalNum;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** N봉 추세선 돌파 — 스윙 피벗 (상승 지지 / 하락 저항) */
|
||||
class TrendLineCrossRuleTest {
|
||||
|
||||
private final StrategyDslToTa4jAdapter adapter = new StrategyDslToTa4jAdapter();
|
||||
private final ObjectMapper om = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void trendLineCrossUp_swingPivot_breaksDescendingResistance() throws Exception {
|
||||
BarSeries series = buildDescendingHighsThenBreakUp();
|
||||
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_UP", 8, TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build());
|
||||
|
||||
int last = series.getEndIndex();
|
||||
assertTrue(rule.isSatisfied(last, null), "저항(하락) 추세선 상향 돌파");
|
||||
assertFalse(rule.isSatisfied(last - 1, null), "직전 봉은 미돌파");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trendLineCrossDown_swingPivot_breaksAscendingSupport() throws Exception {
|
||||
BarSeries series = buildAscendingLowsThenBreakDown();
|
||||
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_DOWN", 8, TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build());
|
||||
|
||||
int last = series.getEndIndex();
|
||||
assertTrue(rule.isSatisfied(last, null), "지지(상승) 추세선 하향 돌파");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trendLineValueIndicator_matchesMath() {
|
||||
BarSeries series = buildDescendingHighsThenBreakUp();
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
TrendLineConfig cfg = TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build();
|
||||
TrendLineValueIndicator ind = new TrendLineValueIndicator(
|
||||
close, series, 8, cfg, TrendLineKind.DOWN, true);
|
||||
int idx = series.getEndIndex();
|
||||
double expected = TrendLineMath.valueAt(series, close, idx, 8, cfg, TrendLineKind.DOWN, true);
|
||||
Num v = ind.getValue(idx);
|
||||
assertEquals(expected, v.doubleValue(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void trendLineCross_usesFixedWindow_notCurrentBarInFit() {
|
||||
BarSeries series = buildDescendingHighsThenBreakUp();
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
TrendLineConfig cfg = TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build();
|
||||
int idx = series.getEndIndex();
|
||||
TrendLineKind kind = TrendLineKind.DOWN;
|
||||
|
||||
double lineAtPrev = TrendLineMath.valueAtEval(series, close, idx, idx - 1, 8, cfg, kind, true);
|
||||
double lineAtCurr = TrendLineMath.valueAtEval(series, close, idx, idx, 8, cfg, kind, true);
|
||||
assertTrue(Double.isFinite(lineAtPrev));
|
||||
assertTrue(Double.isFinite(lineAtCurr));
|
||||
|
||||
double shiftedWrong = TrendLineMath.valueAtEval(series, close, idx - 1, idx - 1, 8, cfg, kind, true);
|
||||
assertNotEquals(lineAtPrev, shiftedWrong, 1e-6,
|
||||
"i−1·i는 동일 [idx−N, idx−1] 창의 추세선이어야 함");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pivotUp_requiresHigherSecondLow() {
|
||||
BarSeries series = buildInvalidUpTrendFlatLows();
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
TrendLineConfig cfg = TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build();
|
||||
int idx = series.getEndIndex();
|
||||
double v = TrendLineMath.valueAtEval(
|
||||
series, close, idx, idx, 8, cfg, TrendLineKind.UP, true);
|
||||
assertTrue(Double.isNaN(v), "두 번째 저점이 더 높지 않으면 피벗 추세선 없음");
|
||||
}
|
||||
|
||||
private Rule buildTrendLineRule(BarSeries series, String condType, int lookback, TrendLineConfig cfg)
|
||||
throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "MA",
|
||||
"conditionType": "%s",
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "NONE",
|
||||
"lookbackPeriod": %d
|
||||
}
|
||||
}
|
||||
""".formatted(condType, lookback);
|
||||
var dsl = om.readTree(json);
|
||||
var ctx = new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, Map.of(), Map.of(), null, null, false, Map.of(), cfg);
|
||||
return adapter.toRule(dsl, ctx);
|
||||
}
|
||||
|
||||
/** 하락 저항선(고점↓) — 마지막 봉 종가 상향 돌파 */
|
||||
private static BarSeries buildDescendingHighsThenBreakUp() {
|
||||
double[] highs = {100, 101, 102, 115, 110, 108, 106, 104, 102, 108, 105, 112};
|
||||
double[] closes = {99, 100, 101, 113, 108, 106, 104, 102, 100, 106, 104, 111};
|
||||
return toSeriesFromHighs(highs, closes);
|
||||
}
|
||||
|
||||
/** 상승 지지선(저점↑) — 마지막 봉 종가 하향 돌파 */
|
||||
private static BarSeries buildAscendingLowsThenBreakDown() {
|
||||
double[] lows = {108, 109, 110, 95, 96, 97, 98, 99, 100, 101, 102, 108};
|
||||
double[] closes = {110, 111, 112, 97, 98, 99, 100, 101, 102, 103, 104, 101};
|
||||
return toSeriesFromLows(lows, closes);
|
||||
}
|
||||
|
||||
/** 두 스윙 저점이 같은 높이 — UP 피벗 불성립 */
|
||||
private static BarSeries buildInvalidUpTrendFlatLows() {
|
||||
double[] lows = {98, 98, 98, 95, 95, 95, 95, 95, 95, 95, 95, 94};
|
||||
double[] closes = {100, 100, 100, 97, 97, 97, 97, 97, 97, 97, 97, 96};
|
||||
return toSeriesFromLows(lows, closes);
|
||||
}
|
||||
|
||||
private static BarSeries toSeriesFromHighs(double[] highs, double[] closes) {
|
||||
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
|
||||
Instant t = Instant.parse("2024-01-01T00:00:00Z");
|
||||
for (int i = 0; i < highs.length; i++) {
|
||||
double c = closes[i];
|
||||
double h = highs[i];
|
||||
double l = Math.min(c, h) - 1;
|
||||
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
|
||||
t = t.plus(Duration.ofMinutes(1));
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
private static BarSeries toSeriesFromLows(double[] lows, double[] closes) {
|
||||
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
|
||||
Instant t = Instant.parse("2024-01-01T00:00:00Z");
|
||||
for (int i = 0; i < lows.length; i++) {
|
||||
double c = closes[i];
|
||||
double l = lows[i];
|
||||
double h = Math.max(c, l) + 1;
|
||||
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
|
||||
t = t.plus(Duration.ofMinutes(1));
|
||||
}
|
||||
return series;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,34 @@
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location = /api/strategy/evaluation/llm-models {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_read_timeout 90s;
|
||||
proxy_send_timeout 90s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ── AI 전략 에이전트 (멀티턴 LLM — 일반 /api/ 180s 회피) ───
|
||||
location /api/strategy/agent/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_buffering off;
|
||||
client_max_body_size 16m;
|
||||
}
|
||||
|
||||
# ── GoldenChart Spring Boot Backend 프록시 ────────────────────────
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
|
||||
@@ -6,11 +6,15 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import {
|
||||
loadBacktestSettings,
|
||||
saveBacktestSettings,
|
||||
saveLiveStrategySettings,
|
||||
type BacktestSettingsDto,
|
||||
type LiveStrategySettingsDto,
|
||||
DEFAULT_BACKTEST_SETTINGS,
|
||||
} from '../utils/backendApi';
|
||||
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
|
||||
import { BacktestSettingsPanel } from './BacktestSettingsPanel';
|
||||
import TrendLineSettingsFields from './TrendLineSettingsFields';
|
||||
import IndicatorMainDefaultsPanel, {
|
||||
type IndicatorSaveUiState,
|
||||
} from './IndicatorMainDefaultsPanel';
|
||||
@@ -1366,15 +1370,32 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
);
|
||||
const [liveSaving, setLiveSaving] = useState(false);
|
||||
const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY');
|
||||
const [trendLineCfg, setTrendLineCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
||||
const [trendLineSaving, setTrendLineSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBacktestSettings().then(s => {
|
||||
if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') {
|
||||
if (s) {
|
||||
setTrendLineCfg(s);
|
||||
if (s.positionMode === 'SIGNAL_ONLY' || s.positionMode === 'LONG_ONLY') {
|
||||
setBacktestPositionMode(s.positionMode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const persistTrendLine = useCallback(async (patch: Partial<BacktestSettingsDto>) => {
|
||||
const next = { ...trendLineCfg, ...patch };
|
||||
setTrendLineCfg(next);
|
||||
setTrendLineSaving(true);
|
||||
try {
|
||||
const saved = await saveBacktestSettings(next);
|
||||
if (saved) setTrendLineCfg(saved);
|
||||
} finally {
|
||||
setTrendLineSaving(false);
|
||||
}
|
||||
}, [trendLineCfg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveSettingsProp) setLiveSettings(liveSettingsProp);
|
||||
}, [liveSettingsProp]);
|
||||
@@ -1582,6 +1603,19 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 추세선 설정 ──────────────────────────────────────────────────── */}
|
||||
<SettingSection title="추세선 설정">
|
||||
<p className="stg-hint" style={{ marginBottom: 12 }}>
|
||||
추세선 돌파 조건에서 이전 N봉 구간 추세선을 계산하는 방식입니다.
|
||||
2점·회귀·스윙 중 하나로 고정되며, 시그널 발생 봉마다 해당 시점의 선으로 평가합니다.
|
||||
{trendLineSaving && <span style={{ marginLeft: 8, color: 'var(--text3)' }}>저장 중…</span>}
|
||||
</p>
|
||||
<TrendLineSettingsFields
|
||||
cfg={trendLineCfg}
|
||||
onChange={patch => { void persistTrendLine(patch); }}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 리스크 관리 ──────────────────────────────────────────────────── */}
|
||||
<SettingSection title="리스크 관리" grid>
|
||||
<StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
|
||||
|
||||
@@ -5,7 +5,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import type { Theme } from '../types/index';
|
||||
import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type BacktestSignal, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
|
||||
import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
|
||||
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
|
||||
@@ -45,9 +45,14 @@ import {
|
||||
isStartNodeId,
|
||||
type StartCombineOp,
|
||||
} from '../utils/strategyStartNodes';
|
||||
import SavedStrategyDragBadge from './strategyEditor/SavedStrategyDragBadge';
|
||||
import type { PaletteDragPayload } from '../utils/paletteDragSession';
|
||||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||||
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
|
||||
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
|
||||
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
|
||||
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
|
||||
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
|
||||
import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage';
|
||||
import {
|
||||
BAND_PALETTE_CHIP_ITEMS,
|
||||
@@ -168,7 +173,7 @@ import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal'
|
||||
import ReactDOM from 'react-dom';
|
||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import type { Timeframe } from '../types';
|
||||
import type { Timeframe, OHLCVBar } from '../types';
|
||||
import {
|
||||
BACKTEST_STRATEGY_TIMEFRAME,
|
||||
buildQuickRunTimeframeSelectOptions,
|
||||
@@ -178,6 +183,14 @@ import {
|
||||
} from '../utils/backtestRunTimeframe';
|
||||
import '../styles/strategyEditor.css';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
import '../styles/strategyEvaluation.css';
|
||||
import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup';
|
||||
import {
|
||||
buildStrategyEvaluationToolbarSummary,
|
||||
formatEvaluationReturnPct,
|
||||
} from '../utils/strategyEvaluationReport';
|
||||
import { resolveEvaluationCommissionRate } from '../utils/strategyEvaluationSignals';
|
||||
import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart';
|
||||
|
||||
type SignalTab = 'buy' | 'sell' | 'eval';
|
||||
type BuySellTab = 'buy' | 'sell';
|
||||
@@ -310,8 +323,8 @@ export default function StrategyEditorPage({
|
||||
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
|
||||
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
||||
const [signalTab, setSignalTab] = useState<SignalTab>('buy');
|
||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
|
||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates' | 'ai'>('indicators');
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
|
||||
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
|
||||
@@ -359,6 +372,20 @@ export default function StrategyEditorPage({
|
||||
const [qbMarketQ, setQbMarketQ] = useState('');
|
||||
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// ── 평가 탭 — 수익률 요약 ─────────────────────────────────────────────────
|
||||
const [evalBacktestSignals, setEvalBacktestSignals] = useState<BacktestSignal[]>([]);
|
||||
const [evalBars, setEvalBars] = useState<OHLCVBar[]>([]);
|
||||
const [evalWindowMeta, setEvalWindowMeta] = useState<StrategyEvaluationWindowMeta>({
|
||||
evaluationBarCount: BACKTEST_DISPLAY_BAR_COUNT,
|
||||
warmupBarCount: 0,
|
||||
isAtLatestWindow: true,
|
||||
windowEndTimeSec: null,
|
||||
});
|
||||
const [evalSignalScanRunning, setEvalSignalScanRunning] = useState(false);
|
||||
const [evalMarket, setEvalMarket] = useState('KRW-BTC');
|
||||
const [evalInitialCapital, setEvalInitialCapital] = useState(10_000_000);
|
||||
const [evalCommissionRate, setEvalCommissionRate] = useState(0.0015);
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||||
const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth());
|
||||
const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true));
|
||||
@@ -1414,6 +1441,30 @@ export default function StrategyEditorPage({
|
||||
scheduleStrategyPersist();
|
||||
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]);
|
||||
|
||||
const applyTrendLine = useCallback((item: { indicatorType: string; leftField?: string }, lookback: number) => {
|
||||
const newNode = buildTrendLineConditionNode(item, buySellTab(signalTab), DEF, lookback);
|
||||
handleEditorStateChange(prev => {
|
||||
const root = prev.root;
|
||||
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||||
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||||
});
|
||||
}, [signalTab, DEF, handleEditorStateChange]);
|
||||
|
||||
const buildSavedStrategyDragPayload = useCallback((s: StrategyDto): PaletteDragPayload | null => {
|
||||
const side = buySellTab(signalTab);
|
||||
const primaryRaw = side === 'sell' ? s.sellCondition : s.buyCondition;
|
||||
const fallbackRaw = side === 'sell' ? s.buyCondition : s.sellCondition;
|
||||
const primary = decodeConditionForEditor((primaryRaw ?? null) as LogicNode | null).root;
|
||||
const condition = primary ?? decodeConditionForEditor((fallbackRaw ?? null) as LogicNode | null).root;
|
||||
if (!condition) return null;
|
||||
return {
|
||||
type: 'savedStrategy',
|
||||
value: String(s.id),
|
||||
label: s.name ?? `전략 #${s.id}`,
|
||||
condition,
|
||||
};
|
||||
}, [signalTab]);
|
||||
|
||||
const templateRows = useMemo(
|
||||
() => buildStrategyTemplatePaletteRows({
|
||||
def: DEF,
|
||||
@@ -1430,6 +1481,34 @@ export default function StrategyEditorPage({
|
||||
[templateRows, templateSearch],
|
||||
);
|
||||
|
||||
const applyAiStrategy = useCallback((buyDsl: LogicNode | null, sellDsl: LogicNode | null) => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
|
||||
const buyDecoded = decodeConditionForEditor(buyDsl);
|
||||
const sellDecoded = decodeConditionForEditor(sellDsl);
|
||||
|
||||
setBuyCondition(buyDecoded.root);
|
||||
setSellCondition(sellDecoded.root);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
setSelectedNodeId(null);
|
||||
setBuyLayout(prev => ({
|
||||
...prev,
|
||||
startCombineOp: normalizeStartCombineOp(buyDecoded.startCombineOp),
|
||||
}));
|
||||
setSellLayout(prev => ({
|
||||
...prev,
|
||||
startCombineOp: normalizeStartCombineOp(sellDecoded.startCombineOp),
|
||||
}));
|
||||
|
||||
resetFlowLayout(layoutStrategyKey, 'buy');
|
||||
resetFlowLayout(layoutStrategyKey, 'sell');
|
||||
|
||||
const targetTab: BuySellTab = buyDsl ? 'buy' : (sellDsl ? 'sell' : buySellTab(signalTab));
|
||||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||||
scheduleStrategyPersist();
|
||||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
|
||||
|
||||
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
|
||||
@@ -1690,6 +1769,100 @@ export default function StrategyEditorPage({
|
||||
[qbStrategyTimeframe],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBacktestSettings().then(s => {
|
||||
if (s?.initialCapital) setEvalInitialCapital(s.initialCapital);
|
||||
setEvalCommissionRate(resolveEvaluationCommissionRate(s));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (signalTab !== 'eval') {
|
||||
setEvalBacktestSignals([]);
|
||||
setEvalBars([]);
|
||||
setEvalSignalScanRunning(false);
|
||||
}
|
||||
}, [signalTab]);
|
||||
|
||||
const evalAnalysisSummary = useMemo(() => {
|
||||
if (signalTab !== 'eval' || evalBacktestSignals.length === 0) return null;
|
||||
const { evalBars: slice } = resolveEvaluationBarSlice(evalBars, evalWindowMeta.evaluationBarCount);
|
||||
const lastEvalBar = slice[slice.length - 1];
|
||||
return buildStrategyEvaluationToolbarSummary(evalBacktestSignals, {
|
||||
initialCapital: evalInitialCapital,
|
||||
symbol: evalMarket.replace(/^KRW-/, ''),
|
||||
commissionRate: evalCommissionRate,
|
||||
lastBarClose: lastEvalBar?.close,
|
||||
lastBarTimeSec: lastEvalBar?.time,
|
||||
});
|
||||
}, [
|
||||
signalTab,
|
||||
evalBacktestSignals,
|
||||
evalBars,
|
||||
evalWindowMeta.evaluationBarCount,
|
||||
evalInitialCapital,
|
||||
evalMarket,
|
||||
evalCommissionRate,
|
||||
]);
|
||||
|
||||
const buildAiContext = useCallback((): AiStrategyContext => {
|
||||
const buyEncoded = encodeConditionForSave(buyEditorState);
|
||||
const sellEncoded = encodeConditionForSave(sellEditorState);
|
||||
|
||||
const evaluation = evalBacktestSignals.length > 0 || signalTab === 'eval'
|
||||
? {
|
||||
market: evalMarket,
|
||||
timeframe: qbStrategyTimeframe,
|
||||
barCount: evalBars.length,
|
||||
evaluationBarCount: evalWindowMeta.evaluationBarCount,
|
||||
scanRunning: evalSignalScanRunning,
|
||||
signalsTotal: evalBacktestSignals.length,
|
||||
buyCount: evalBacktestSignals.filter(s => s.type === 'BUY').length,
|
||||
sellCount: evalBacktestSignals.filter(s => s.type === 'SELL').length,
|
||||
recentSignals: evalBacktestSignals.slice(-12).map(s => ({
|
||||
type: s.type,
|
||||
time: s.time,
|
||||
timeLabel: new Date(s.time * 1000).toLocaleString('ko-KR'),
|
||||
price: s.price,
|
||||
barIndex: s.barIndex,
|
||||
})),
|
||||
summary: evalAnalysisSummary ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
buyCondition: buyEncoded,
|
||||
sellCondition: sellEncoded,
|
||||
signalTab: buySellTab(signalTab),
|
||||
candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType,
|
||||
evaluation,
|
||||
};
|
||||
}, [
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
signalTab,
|
||||
currentEditorState,
|
||||
evalBacktestSignals,
|
||||
evalMarket,
|
||||
qbStrategyTimeframe,
|
||||
evalBars,
|
||||
evalWindowMeta.evaluationBarCount,
|
||||
evalSignalScanRunning,
|
||||
evalAnalysisSummary,
|
||||
]);
|
||||
|
||||
const handleEvalSignalsChange = useCallback((signals: BacktestSignal[]) => {
|
||||
setEvalBacktestSignals(signals);
|
||||
}, []);
|
||||
|
||||
const handleEvalBarsLoaded = useCallback((bars: OHLCVBar[]) => {
|
||||
setEvalBars(bars);
|
||||
}, []);
|
||||
|
||||
const handleEvalWindowMetaChange = useCallback((meta: StrategyEvaluationWindowMeta) => {
|
||||
setEvalWindowMeta(meta);
|
||||
}, []);
|
||||
|
||||
const operators = [
|
||||
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
|
||||
{ type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' },
|
||||
@@ -1933,9 +2106,10 @@ export default function StrategyEditorPage({
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className={`se-strat-status${s.enabled ? ' se-strat-status--on' : ''}`}>
|
||||
{s.enabled ? '활성' : '비활성'}
|
||||
</span>
|
||||
<SavedStrategyDragBadge
|
||||
enabled={!!s.enabled}
|
||||
buildPayload={() => buildSavedStrategyDragPayload(s)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1983,6 +2157,36 @@ export default function StrategyEditorPage({
|
||||
</button>
|
||||
</div>
|
||||
{stratName && <span className="se-editing-name">{stratName}</span>}
|
||||
{signalTab === 'eval' && (
|
||||
evalSignalScanRunning ? (
|
||||
<span className="se-eval-return-summary seval-header-analysis-busy">시그널 계산 중…</span>
|
||||
) : evalAnalysisSummary ? (
|
||||
<span
|
||||
className="se-eval-return-summary seval-analysis-summary"
|
||||
title="일괄매수(최초 전액) · 분할매수(40/30/잔여) · 전략평가와 동일 기준"
|
||||
>
|
||||
<span className={`seval-analysis-item seval-analysis-return${
|
||||
evalAnalysisSummary.fullBuyReturnPct > 0
|
||||
? ' seval-analysis-return--pos'
|
||||
: evalAnalysisSummary.fullBuyReturnPct < 0
|
||||
? ' seval-analysis-return--neg'
|
||||
: ''
|
||||
}`}>
|
||||
일괄:<strong>{formatEvaluationReturnPct(evalAnalysisSummary.fullBuyReturnPct)}</strong>
|
||||
</span>
|
||||
<span className="seval-analysis-sep" aria-hidden>·</span>
|
||||
<span className={`seval-analysis-item seval-analysis-return${
|
||||
evalAnalysisSummary.splitBuyReturnPct > 0
|
||||
? ' seval-analysis-return--pos'
|
||||
: evalAnalysisSummary.splitBuyReturnPct < 0
|
||||
? ' seval-analysis-return--neg'
|
||||
: ''
|
||||
}`}>
|
||||
분할:<strong>{formatEvaluationReturnPct(evalAnalysisSummary.splitBuyReturnPct)}</strong>
|
||||
</span>
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
{signalTab !== 'eval' && hasMultipleStartSections(currentEditorState) && (
|
||||
<StartCombineOpControl
|
||||
value={normalizeStartCombineOp(currentEditorState.startCombineOp)}
|
||||
@@ -2044,6 +2248,11 @@ export default function StrategyEditorPage({
|
||||
stratDesc={stratDesc}
|
||||
buyEditorState={buyEditorState}
|
||||
sellEditorState={sellEditorState}
|
||||
onSignalsChange={handleEvalSignalsChange}
|
||||
onBarsLoaded={handleEvalBarsLoaded}
|
||||
onEvalWindowMetaChange={handleEvalWindowMetaChange}
|
||||
onBacktestRunningChange={setEvalSignalScanRunning}
|
||||
onMarketChange={setEvalMarket}
|
||||
/>
|
||||
) : editorMode === 'graph' ? (
|
||||
<>
|
||||
@@ -2068,6 +2277,8 @@ export default function StrategyEditorPage({
|
||||
onStartCandleTypesChange={handleStartCandleTypesChange}
|
||||
onExtraStartIdsChange={handleExtraStartIdsChange}
|
||||
onExtraRootsChange={handleExtraRootsChange}
|
||||
startCombineOp={normalizeStartCombineOp(currentLayout.startCombineOp)}
|
||||
onStartCombineOpChange={handleStartCombineOpChange}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -2302,6 +2513,7 @@ export default function StrategyEditorPage({
|
||||
<div className="se-right-tabs">
|
||||
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}>지표</button>
|
||||
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>템플릿</button>
|
||||
<button type="button" className={rightTab === 'ai' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('ai')}>AI 전략</button>
|
||||
</div>
|
||||
<div className="se-right-body">
|
||||
{rightTab === 'indicators' && (
|
||||
@@ -2382,6 +2594,16 @@ export default function StrategyEditorPage({
|
||||
>
|
||||
횡보필터
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
|
||||
onClick={() => {
|
||||
setIndicatorSubTab('trendline');
|
||||
setSelectedPaletteKey(null);
|
||||
}}
|
||||
>
|
||||
추세
|
||||
</button>
|
||||
</div>
|
||||
{indicatorSubTab === 'auxiliary' ? (
|
||||
<IndicatorPaletteTab
|
||||
@@ -2419,7 +2641,7 @@ export default function StrategyEditorPage({
|
||||
applyPaletteItem(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : indicatorSubTab === 'range' ? (
|
||||
<SidewaysFilterPaletteTab
|
||||
def={DEF}
|
||||
items={sidewaysPalette}
|
||||
@@ -2436,6 +2658,21 @@ export default function StrategyEditorPage({
|
||||
applySidewaysFilter(item.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TrendLinePaletteTab
|
||||
signalType={buySellTab(signalTab)}
|
||||
searchQuery={paletteSearch}
|
||||
selectedItemId={
|
||||
selectedPaletteKey?.startsWith('trendline:')
|
||||
? selectedPaletteKey.slice('trendline:'.length)
|
||||
: null
|
||||
}
|
||||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
|
||||
onAddToCanvas={(item, lookback) => {
|
||||
selectPalette('trendline', item.id);
|
||||
applyTrendLine(item, lookback);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -2452,6 +2689,12 @@ export default function StrategyEditorPage({
|
||||
onDelete={handleTemplateToolbarDelete}
|
||||
/>
|
||||
)}
|
||||
{rightTab === 'ai' && (
|
||||
<AiStrategyPanel
|
||||
getContext={buildAiContext}
|
||||
onApplyStrategy={applyAiStrategy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 설정 화면 — 전략 설정 · 추세선 조건
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { BacktestSettingsDto } from '../utils/backendApi';
|
||||
|
||||
export const TREND_LINE_MODE_OPTIONS = [
|
||||
{
|
||||
value: 'SWING' as const,
|
||||
label: '스윙 피벗 (권장)',
|
||||
desc: 'N봉 내 스윙 고/저점 2개 — 상승=저점↑·하락=고점↓',
|
||||
},
|
||||
{
|
||||
value: 'TWO_POINT' as const,
|
||||
label: '2점 폴백',
|
||||
desc: '피벗 미충족 시 구간 양端(Low/High) 직선',
|
||||
},
|
||||
{
|
||||
value: 'REGRESSION' as const,
|
||||
label: '회귀 폴백',
|
||||
desc: '피벗 미충족 시 N봉 Low/High OLS',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
cfg: BacktestSettingsDto;
|
||||
onChange: (patch: Partial<BacktestSettingsDto>) => void;
|
||||
}
|
||||
|
||||
export const TrendLineSettingsFields: React.FC<Props> = ({ cfg, onChange }) => {
|
||||
const mode = cfg.trendLineMode ?? 'SWING';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">추세선 정의</span>
|
||||
<span className="stg-row-desc">
|
||||
매수(상향 돌파)=하락 저항선(스윙 고점↓) · 매도(하향 돌파)=상승 지지선(스윙 저점↑).
|
||||
가격축은 Low/High, 오실레이터는 지표값 기준 스윙을 사용합니다.
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
className="stg-select stg-select--wide"
|
||||
value={mode}
|
||||
onChange={e => onChange({ trendLineMode: e.target.value as BacktestSettingsDto['trendLineMode'] })}
|
||||
>
|
||||
{TREND_LINE_MODE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">기본 lookback (봉)</span>
|
||||
<span className="stg-row-desc">
|
||||
[i−N, i−1] 구간에서 피벗을 찾고 i 봉까지 연장합니다. 단기 30~50·중기 100~120 권장.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number"
|
||||
min={2}
|
||||
max={500}
|
||||
value={cfg.trendLineDefaultLookback ?? 10}
|
||||
onChange={e => onChange({ trendLineDefaultLookback: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">스윙 — 좌측 k봉</span>
|
||||
<span className="stg-row-desc">
|
||||
피벗 판정: 해당 봉 Low/High가 좌·우 각 k봉보다 극단적일 때 스윙으로 인정
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
value={cfg.trendLineSwingPrecedingBars ?? 1}
|
||||
onChange={e => onChange({ trendLineSwingPrecedingBars: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">스윙 — 우측 k봉</span>
|
||||
<span className="stg-row-desc">피벗 판정 우측 비교 봉 수 (k)</span>
|
||||
</div>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
value={cfg.trendLineSwingFollowingBars ?? 1}
|
||||
onChange={e => onChange({ trendLineSwingFollowingBars: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendLineSettingsFields;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { LlmAppSettings } from '../../utils/llmSettings';
|
||||
import { buildLlmEndpointUrl, buildLlmBaseUrl } from '../../utils/llmSettings';
|
||||
import { fetchLlmConnectionTest } from '../../utils/backendApi';
|
||||
import { fetchLlmConnectionTest, fetchLlmModels } from '../../utils/backendApi';
|
||||
|
||||
interface Props {
|
||||
settings: LlmAppSettings;
|
||||
@@ -34,6 +34,9 @@ const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = (
|
||||
const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [modelsMessage, setModelsMessage] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
const patch = (p: Partial<LlmAppSettings>) => {
|
||||
setTestResult(null);
|
||||
@@ -57,6 +60,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
setLoadingModels(true);
|
||||
setModelsMessage(null);
|
||||
try {
|
||||
const res = await fetchLlmModels({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
useHttps: settings.useHttps,
|
||||
modelsPath: settings.modelsPath,
|
||||
});
|
||||
setModels(res.models ?? []);
|
||||
setModelsMessage({ ok: res.ok, message: res.message ?? (res.ok ? '조회 완료' : '조회 실패') });
|
||||
if (res.ok && res.models?.length && !res.models.includes(settings.model)) {
|
||||
patch({ model: res.models[0] });
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '모델 조회 실패';
|
||||
setModels([]);
|
||||
setModelsMessage({ ok: false, message: msg });
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const set = new Set(models);
|
||||
if (settings.model) set.add(settings.model);
|
||||
return Array.from(set);
|
||||
}, [models, settings.model]);
|
||||
|
||||
return (
|
||||
<div className="llm-settings-panel">
|
||||
<SettingSection title="LLM 사용">
|
||||
@@ -114,6 +147,15 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
placeholder="/v1/chat/completions"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="모델 목록 경로" desc="OpenAI 호환 모델 목록 엔드포인트 (드롭다운 조회용)">
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="text"
|
||||
value={settings.modelsPath}
|
||||
onChange={e => patch({ modelsPath: e.target.value })}
|
||||
placeholder="/v1/models"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="연결 URL 미리보기" desc="저장 후 백엔드가 이 주소로 LLM에 요청합니다.">
|
||||
<code className="stg-code-preview">{endpointPreview}</code>
|
||||
</SettingRow>
|
||||
@@ -137,7 +179,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection title="모델·생성 옵션">
|
||||
<SettingRow label="모델" desc="LLM 서버에 등록된 모델 ID">
|
||||
<SettingRow label="모델" desc="URL 입력 후 '모델 조회'로 목록을 받아 선택하세요. 조회가 안 되면 직접 입력할 수 있습니다.">
|
||||
<div className="stg-row-inline">
|
||||
<select
|
||||
className="stg-input stg-input--wide"
|
||||
value={settings.model}
|
||||
onChange={e => patch({ model: e.target.value })}
|
||||
>
|
||||
{modelOptions.length === 0 && <option value="">모델 없음 — 조회하세요</option>}
|
||||
{modelOptions.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="stg-btn-test"
|
||||
disabled={loadingModels || !settings.host}
|
||||
onClick={handleFetchModels}
|
||||
>
|
||||
{loadingModels ? '조회 중…' : '모델 조회'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
{modelsMessage && (
|
||||
<SettingRow label="" desc="">
|
||||
<span className={`stg-badge ${modelsMessage.ok ? 'stg-badge--ok' : 'stg-badge--err'}`}>
|
||||
● {modelsMessage.message}
|
||||
</span>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label="모델 직접 입력" desc="목록에 없는 모델 ID를 직접 입력할 때 사용">
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="text"
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import {
|
||||
requestStrategyAgentChat,
|
||||
type StrategyAgentChatMessage,
|
||||
} from '../../utils/strategyAgentChat';
|
||||
|
||||
export interface AiStrategyContext {
|
||||
buyCondition: LogicNode | null;
|
||||
sellCondition: LogicNode | null;
|
||||
signalTab: 'buy' | 'sell';
|
||||
candleType?: string;
|
||||
/** 평가탭 결과 (해석·문제 진단용) — 평가 미실행 시 null */
|
||||
evaluation?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 전송·스냅샷 시점의 현재 편집기 상태를 반환 */
|
||||
getContext: () => AiStrategyContext;
|
||||
/** 매수·매도 조건을 편집기에 교체 적용 (null = 해당 측 비움) */
|
||||
onApplyStrategy: (buy: LogicNode | null, sell: LogicNode | null) => void;
|
||||
}
|
||||
|
||||
type Turn = {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
action?: 'ask' | 'build';
|
||||
/** build 턴 — 적용 직전 스냅샷(되돌리기용) */
|
||||
snapshot?: { buy: LogicNode | null; sell: LogicNode | null };
|
||||
/** build 턴 — 적용된 결과 */
|
||||
produced?: { buy: LogicNode | null; sell: LogicNode | null };
|
||||
reverted?: boolean;
|
||||
};
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'일목균형표 매수 전략 추천해줘',
|
||||
'전환선이 기준선을 상향돌파하면 매수',
|
||||
'RSI 30 이하에서 상향돌파하면 매수, 70 이상이면 매도',
|
||||
'평가 결과를 해석하고 매수 시그널이 안 나오는 원인을 분석해줘',
|
||||
];
|
||||
|
||||
const CLEAR_REPLY =
|
||||
'전략을 초기화했습니다. 모든 조건을 비워 처음 상태로 되돌렸어요. 원하는 매매 전략을 자연어로 설명해 주세요.';
|
||||
|
||||
/** 초기화/리셋/새로 작성 의도 감지 — LLM 없이 즉시 처리 (서버 503에도 동작) */
|
||||
export function isResetRequest(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t) return false;
|
||||
// "조건 추가"처럼 추가 의도가 섞이면 초기화로 보지 않음
|
||||
if (/추가|더해|덧붙|넣어|보태/.test(t)) return false;
|
||||
return /(초기화|리셋|reset|clear|처음\s*부터|처음\s*으로|다시\s*처음|새\s*전략|새로운\s*전략|새\s*조건|새로운\s*조건|전부\s*삭제|전체\s*삭제|모두\s*삭제|다\s*지워|다\s*삭제|비우[기게]|싹\s*비워|새로\s*시작|처음\s*상태)/i.test(t);
|
||||
}
|
||||
|
||||
const AiStrategyPanel: React.FC<Props> = ({ getContext, onApplyStrategy }) => {
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const idRef = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const nextId = () => ++idRef.current;
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const history = useMemo<StrategyAgentChatMessage[]>(
|
||||
() => turns.map(t => ({ role: t.role, content: t.content })),
|
||||
[turns],
|
||||
);
|
||||
|
||||
/** 모든 조건을 비우고(초기화) 되돌리기 가능한 어시스턴트 턴 생성 */
|
||||
const buildClearTurn = useCallback((ctx: AiStrategyContext, reply?: string): Turn => {
|
||||
const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition };
|
||||
onApplyStrategy(null, null);
|
||||
return {
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: reply || CLEAR_REPLY,
|
||||
action: 'build',
|
||||
snapshot,
|
||||
produced: { buy: null, sell: null },
|
||||
};
|
||||
}, [onApplyStrategy]);
|
||||
|
||||
const send = useCallback(async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || busy) return;
|
||||
|
||||
setError(null);
|
||||
const ctx = getContext();
|
||||
|
||||
// 초기화/리셋 요청 — LLM 호출 없이 즉시 처리
|
||||
if (isResetRequest(trimmed)) {
|
||||
const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed };
|
||||
const clearTurn = buildClearTurn(ctx);
|
||||
setInput('');
|
||||
setTurns(prev => [...prev, userTurn, clearTurn]);
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed };
|
||||
const outgoing: StrategyAgentChatMessage[] = [...history, { role: 'user', content: trimmed }];
|
||||
setTurns(prev => [...prev, userTurn]);
|
||||
setInput('');
|
||||
setBusy(true);
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const res = await requestStrategyAgentChat(outgoing, ctx);
|
||||
|
||||
if (res.action === 'clear') {
|
||||
setTurns(prev => [...prev, buildClearTurn(ctx, res.reply)]);
|
||||
} else if (res.action === 'build' && res.hasStrategy) {
|
||||
const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition };
|
||||
// AI가 생략한 측은 기존 조건 유지
|
||||
const produced = {
|
||||
buy: res.buyCondition ?? ctx.buyCondition,
|
||||
sell: res.sellCondition ?? ctx.sellCondition,
|
||||
};
|
||||
onApplyStrategy(produced.buy, produced.sell);
|
||||
setTurns(prev => [...prev, {
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: res.reply || '요청하신 조건으로 전략을 구성했습니다.',
|
||||
action: 'build',
|
||||
snapshot,
|
||||
produced,
|
||||
}]);
|
||||
} else {
|
||||
setTurns(prev => [...prev, {
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: res.reply || '요청을 더 구체적으로 알려주세요.',
|
||||
action: 'ask',
|
||||
}]);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'AI 전략 생성 중 오류가 발생했습니다.';
|
||||
setError(msg);
|
||||
setTurns(prev => [...prev, {
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: `오류: ${msg}`,
|
||||
action: 'ask',
|
||||
}]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [busy, history, getContext, onApplyStrategy, scrollToBottom, buildClearTurn]);
|
||||
|
||||
const handleRevert = useCallback((turnId: number) => {
|
||||
setTurns(prev => prev.map(t => {
|
||||
if (t.id !== turnId || !t.snapshot) return t;
|
||||
onApplyStrategy(t.snapshot.buy, t.snapshot.sell);
|
||||
return { ...t, reverted: true };
|
||||
}));
|
||||
}, [onApplyStrategy]);
|
||||
|
||||
const handleReapply = useCallback((turnId: number) => {
|
||||
setTurns(prev => prev.map(t => {
|
||||
if (t.id !== turnId || !t.produced) return t;
|
||||
onApplyStrategy(t.produced.buy, t.produced.sell);
|
||||
return { ...t, reverted: false };
|
||||
}));
|
||||
}, [onApplyStrategy]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (busy) return;
|
||||
setTurns([]);
|
||||
setError(null);
|
||||
}, [busy]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send(input);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="se-ai-panel">
|
||||
<div className="se-ai-head">
|
||||
<span className="se-ai-title">AI 전략 에이전트</span>
|
||||
<button
|
||||
type="button"
|
||||
className="se-ai-reset"
|
||||
onClick={handleReset}
|
||||
disabled={busy || turns.length === 0}
|
||||
>
|
||||
대화 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="se-ai-messages" ref={scrollRef}>
|
||||
{turns.length === 0 && (
|
||||
<div className="se-ai-empty">
|
||||
<p>자연어로 매매 전략을 설명하면 조건을 자동 구성해 편집기에 적용합니다.</p>
|
||||
<p className="se-ai-empty-sub">정보가 부족하면 먼저 질문하고, 이어지는 대화로 전략을 다듬습니다.</p>
|
||||
<div className="se-ai-suggestions">
|
||||
{SUGGESTIONS.map(s => (
|
||||
<button key={s} type="button" className="se-ai-suggestion" onClick={() => void send(s)} disabled={busy}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{turns.map(t => (
|
||||
<div key={t.id} className={`se-ai-msg se-ai-msg--${t.role}`}>
|
||||
<div className="se-ai-bubble">
|
||||
{t.content.split('\n').map((line, i) => <p key={i}>{line || '\u00A0'}</p>)}
|
||||
</div>
|
||||
{t.action === 'build' && t.produced && (
|
||||
<div className="se-ai-apply-row">
|
||||
{t.reverted ? (
|
||||
<>
|
||||
<span className="se-ai-tag se-ai-tag--reverted">되돌림</span>
|
||||
<button type="button" className="se-ai-mini-btn" onClick={() => handleReapply(t.id)} disabled={busy}>
|
||||
다시 적용
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="se-ai-tag se-ai-tag--applied">편집기에 적용됨</span>
|
||||
{t.snapshot && (
|
||||
<button type="button" className="se-ai-mini-btn" onClick={() => handleRevert(t.id)} disabled={busy}>
|
||||
되돌리기
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{busy && (
|
||||
<div className="se-ai-msg se-ai-msg--assistant">
|
||||
<div className="se-ai-bubble se-ai-bubble--loading">생성 중…</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="se-ai-error">{error}</div>}
|
||||
|
||||
<div className="se-ai-input-row">
|
||||
<textarea
|
||||
className="se-ai-input"
|
||||
placeholder="예) 전환선이 기준선을 상향돌파하면 매수 (Enter 전송 · Shift+Enter 줄바꿈)"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={2}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="se-ai-send"
|
||||
onClick={() => void send(input)}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiStrategyPanel;
|
||||
@@ -205,6 +205,14 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
/>
|
||||
{/* START 결합 엣지(START끼리 자동 연결) 전용 target 핸들 — 비대화형, 엣지 부착용 */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="t-top"
|
||||
isConnectable={false}
|
||||
className="se-flow-handle se-flow-handle--start-combine-tgt"
|
||||
/>
|
||||
<div className="se-flow-start-layout">
|
||||
<div className="se-flow-start-title-row">
|
||||
<div className="se-flow-start-title-left">
|
||||
|
||||
@@ -31,6 +31,11 @@ interface Props {
|
||||
stableStrategy?: boolean;
|
||||
ichimokuSituation?: boolean;
|
||||
secondaryIndicator?: string;
|
||||
/** 추세선 돌파 — 드래그 payload type: trendLine */
|
||||
trendLine?: boolean;
|
||||
trendLineIndicatorType?: string;
|
||||
trendLineLeftField?: string;
|
||||
trendLineLookback?: number;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onAdd: () => void;
|
||||
@@ -38,9 +43,22 @@ interface Props {
|
||||
|
||||
export default function PaletteChip({
|
||||
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
|
||||
composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator, selected = false, onSelect, onAdd,
|
||||
composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator,
|
||||
trendLine = false, trendLineIndicatorType, trendLineLeftField, trendLineLookback,
|
||||
selected = false, onSelect, onAdd,
|
||||
}: Props) {
|
||||
const buildPayload = (): PaletteDragPayload => ({
|
||||
const buildPayload = (): PaletteDragPayload => {
|
||||
if (trendLine) {
|
||||
return {
|
||||
type: 'trendLine',
|
||||
value,
|
||||
label,
|
||||
indicatorType: trendLineIndicatorType,
|
||||
leftField: trendLineLeftField,
|
||||
lookback: trendLineLookback,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type, value, label,
|
||||
composite: composite || undefined,
|
||||
stochPair: stochPair || undefined,
|
||||
@@ -53,7 +71,8 @@ export default function PaletteChip({
|
||||
period: periodValue,
|
||||
shortPeriod,
|
||||
longPeriod,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const pointerMode = needsPointerPaletteDrag();
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
handlePaletteMouseDown,
|
||||
handlePalettePointerDown,
|
||||
handlePaletteTouchStart,
|
||||
endHtmlDragMouseTracking,
|
||||
needsPointerPaletteDrag,
|
||||
startHtmlDragMouseTracking,
|
||||
writePaletteHtmlDragData,
|
||||
type PaletteDragPayload,
|
||||
} from '../../utils/paletteDragSession';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
/** 드래그 시작 시점에 현재 탭(매수/매도)에 맞는 조건 payload 생성. 없으면 드래그 불가 */
|
||||
buildPayload: () => PaletteDragPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌측 전략 목록 아이템의 활성 배지 — 드래그하여 전략빌더(캔버스)에 조건 추가.
|
||||
* 팔레트 칩과 동일한 DnD 세션(HTML5 + pointer)을 사용한다.
|
||||
*/
|
||||
export default function SavedStrategyDragBadge({ enabled, buildPayload }: Props) {
|
||||
const pointerMode = needsPointerPaletteDrag();
|
||||
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
if (pointerMode) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
writePaletteHtmlDragData(e, payload);
|
||||
startHtmlDragMouseTracking(e.clientX, e.clientY, payload);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
endHtmlDragMouseTracking();
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (!pointerMode) return;
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
handlePalettePointerDown(e, payload, e.currentTarget as HTMLElement);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (!pointerMode) return;
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
handlePaletteMouseDown(e, payload, e.currentTarget as HTMLElement);
|
||||
};
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (!pointerMode) return;
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
handlePaletteTouchStart(e, payload, e.currentTarget as HTMLElement);
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`se-strat-status se-strat-status--drag${enabled ? ' se-strat-status--on' : ''}`}
|
||||
draggable={!pointerMode}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onPointerDownCapture={onPointerDown}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title="드래그하여 전략빌더에 이 전략의 조건을 추가/연결"
|
||||
>
|
||||
{enabled ? '활성' : '비활성'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getSmoothStepPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react';
|
||||
import type { StartCombineOp } from '../../utils/strategyStartNodes';
|
||||
|
||||
export type StartCombineEdgeData = {
|
||||
op?: StartCombineOp;
|
||||
onToggle?: (op: StartCombineOp) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* START 노드끼리 잇는 결합 엣지 — 가운데 AND/OR 토글 배지.
|
||||
* 클릭하면 startCombineOp 를 전환한다. (연결 자체는 START 2개 이상이면 자동 생성)
|
||||
*/
|
||||
export function StartCombineEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const op: StartCombineOp = (data as StartCombineEdgeData | undefined)?.op === 'OR' ? 'OR' : 'AND';
|
||||
const onToggle = (data as StartCombineEdgeData | undefined)?.onToggle;
|
||||
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
className={`se-flow-start-combine-edge se-flow-start-combine-edge--${op.toLowerCase()}`}
|
||||
style={{ strokeWidth: 2, strokeDasharray: '6 4', pointerEvents: 'none' }}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-edge-badge se-start-combine-edge-badge--${op.toLowerCase()}`}
|
||||
title={op === 'AND' ? 'START 간 AND (모두 충족) — 클릭하면 OR' : 'START 간 OR (하나만 충족) — 클릭하면 AND'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onToggle?.(op === 'AND' ? 'OR' : 'AND');
|
||||
}}
|
||||
>
|
||||
{op}
|
||||
</button>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type { Theme } from '../../types/index';
|
||||
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
|
||||
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
|
||||
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
|
||||
import {
|
||||
addChild,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
||||
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
|
||||
import {
|
||||
isPaletteHtmlDrag,
|
||||
isPaletteHtmlDragActive,
|
||||
@@ -97,12 +98,13 @@ import {
|
||||
DEFAULT_START_CANDLE,
|
||||
normalizeStartCandleType,
|
||||
type StartNodeMeta,
|
||||
type StartCombineOp,
|
||||
} from '../../utils/strategyStartNodes';
|
||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||
import { StartCombineEdge } from './StartCombineEdge';
|
||||
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||
import { getMinimapColors } from './minimapTheme';
|
||||
import {
|
||||
loadCanvasInteractionMode,
|
||||
saveCanvasInteractionMode,
|
||||
@@ -124,6 +126,7 @@ const nodeTypes = {
|
||||
|
||||
const edgeTypes = {
|
||||
strategy: StrategyFlowEdge,
|
||||
startCombine: StartCombineEdge,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -146,6 +149,9 @@ interface Props {
|
||||
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
|
||||
onExtraStartIdsChange?: (ids: string[]) => void;
|
||||
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
|
||||
/** START 2개 이상일 때 분기 결합 (AND | OR) */
|
||||
startCombineOp?: StartCombineOp;
|
||||
onStartCombineOpChange?: (op: StartCombineOp) => void;
|
||||
}
|
||||
|
||||
export interface StrategyEditorCanvasHandle {
|
||||
@@ -314,6 +320,8 @@ function StrategyEditorCanvasInner({
|
||||
onStartCandleTypesChange,
|
||||
onExtraStartIdsChange,
|
||||
onExtraRootsChange,
|
||||
startCombineOp = 'AND',
|
||||
onStartCombineOpChange,
|
||||
}: Props, ref: React.ForwardedRef<StrategyEditorCanvasHandle>) {
|
||||
const { fitView } = useReactFlow();
|
||||
const canvasWrapRef = useRef<HTMLDivElement>(null);
|
||||
@@ -357,19 +365,6 @@ function StrategyEditorCanvasInner({
|
||||
return ids;
|
||||
}, [root, extraStartIds, extraRoots]);
|
||||
|
||||
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
|
||||
|
||||
const minimapNodeColor = useCallback((node: Node) => {
|
||||
if (isStartNodeId(node.id)) return minimapColors.start;
|
||||
if (node.type === 'condition') return minimapColors.condition;
|
||||
return minimapColors.logic;
|
||||
}, [minimapColors]);
|
||||
|
||||
const minimapNodeStrokeColor = useCallback((node: Node) => {
|
||||
if (isStartNodeId(node.id)) return minimapColors.start;
|
||||
return minimapColors.stroke;
|
||||
}, [minimapColors]);
|
||||
|
||||
const signalTabRef = useRef(signalTab);
|
||||
signalTabRef.current = signalTab;
|
||||
|
||||
@@ -608,11 +603,17 @@ function StrategyEditorCanvasInner({
|
||||
}, [setNodes]);
|
||||
|
||||
const resolvePaletteDropNode = useCallback((
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
|
||||
): LogicNode | null => {
|
||||
if (data.type === 'sidewaysFilter') {
|
||||
return buildSidewaysFilterNode(data.value, def);
|
||||
}
|
||||
if (data.type === 'trendLine') {
|
||||
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
|
||||
}
|
||||
if (data.type === 'savedStrategy') {
|
||||
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
|
||||
}
|
||||
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
}, [signalTab, def]);
|
||||
|
||||
@@ -630,6 +631,20 @@ function StrategyEditorCanvasInner({
|
||||
scheduleLayoutEmit();
|
||||
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
|
||||
|
||||
/**
|
||||
* 저장 전략(서브트리)은 단일 고아 노드로 추가하면 자식이 캔버스에 표시되지 않으므로
|
||||
* 항상 루트에 AND 병합(빈 경우 루트로 설정)한다.
|
||||
*/
|
||||
const addSavedStrategyAtRoot = useCallback((
|
||||
data: { type: string; value: string; label: string; condition?: LogicNode },
|
||||
): boolean => {
|
||||
const newNode = resolvePaletteDropNode(data);
|
||||
if (!newNode) return false;
|
||||
onChange(root ? mergeAtRoot(root, newNode, false) : newNode);
|
||||
scheduleLayoutEmit();
|
||||
return true;
|
||||
}, [resolvePaletteDropNode, onChange, root, scheduleLayoutEmit]);
|
||||
|
||||
const applyDropWithAttach = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
@@ -713,17 +728,21 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
const handleDropTarget = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
data: { type: string; value: string; label: string; condition?: LogicNode },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
clearDropPreview();
|
||||
const fallback = () => {
|
||||
if (data.type === 'savedStrategy') addSavedStrategyAtRoot(data);
|
||||
else addOrphanAt(data, flowPos);
|
||||
};
|
||||
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
||||
const attached = applyDropWithAttach(anchorId, data, flowPos);
|
||||
if (!attached) addOrphanAt(data, flowPos);
|
||||
if (!attached) fallback();
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
fallback();
|
||||
}
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, addSavedStrategyAtRoot, root, orphans]);
|
||||
|
||||
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
|
||||
const next = normalizeConditionForPersistence(condition);
|
||||
@@ -938,7 +957,9 @@ function StrategyEditorCanvasInner({
|
||||
startMeta,
|
||||
extraStartIds,
|
||||
extraRoots,
|
||||
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]);
|
||||
startCombineOp,
|
||||
onStartCombineOpChange,
|
||||
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots, startCombineOp, onStartCombineOpChange]);
|
||||
|
||||
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
||||
useEffect(() => {
|
||||
@@ -1291,7 +1312,7 @@ function StrategyEditorCanvasInner({
|
||||
);
|
||||
|
||||
const applyPaletteDropAt = useCallback((
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
data: { type: string; value: string; label: string; composite?: boolean; condition?: LogicNode },
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
@@ -1308,12 +1329,16 @@ function StrategyEditorCanvasInner({
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'savedStrategy') {
|
||||
addSavedStrategyAtRoot(data);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
|
||||
});
|
||||
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]);
|
||||
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt, addSavedStrategyAtRoot]);
|
||||
|
||||
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1530,17 +1555,6 @@ function StrategyEditorCanvasInner({
|
||||
>
|
||||
<Background gap={24} size={1} className="se-flow-bg" />
|
||||
<Controls className="se-flow-controls" showInteractive={false} />
|
||||
<MiniMap
|
||||
className="se-flow-minimap"
|
||||
zoomable
|
||||
pannable
|
||||
nodeStrokeWidth={2}
|
||||
bgColor={minimapColors.bg}
|
||||
maskColor={minimapColors.mask}
|
||||
maskStrokeColor={minimapColors.maskStroke}
|
||||
nodeColor={minimapNodeColor}
|
||||
nodeStrokeColor={minimapNodeStrokeColor}
|
||||
/>
|
||||
<Panel position="top-right" className="se-canvas-auto-layout">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
import '../../styles/backtestDashboard.css';
|
||||
import '../../styles/strategyEvaluation.css';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { Theme, Timeframe } from '../../types';
|
||||
import type { Theme, Timeframe, OHLCVBar } from '../../types';
|
||||
import type { BacktestSignal } from '../../utils/backendApi';
|
||||
import type { StrategyEvaluationWindowMeta } from '../strategyEvaluation/StrategyEvaluationChart';
|
||||
import StrategyEvaluationChart from '../strategyEvaluation/StrategyEvaluationChart';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useMarketTicker } from '../../hooks/useMarketTicker';
|
||||
@@ -27,6 +29,11 @@ interface Props {
|
||||
stratDesc: string;
|
||||
buyEditorState: EditorConditionState;
|
||||
sellEditorState: EditorConditionState;
|
||||
onSignalsChange?: (signals: BacktestSignal[]) => void;
|
||||
onBarsLoaded?: (bars: OHLCVBar[]) => void;
|
||||
onEvalWindowMetaChange?: (meta: StrategyEvaluationWindowMeta) => void;
|
||||
onBacktestRunningChange?: (running: boolean) => void;
|
||||
onMarketChange?: (market: string) => void;
|
||||
}
|
||||
|
||||
const StrategyEditorEvaluationPanel: React.FC<Props> = ({
|
||||
@@ -35,6 +42,11 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
|
||||
stratDesc,
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
onSignalsChange,
|
||||
onBarsLoaded,
|
||||
onEvalWindowMetaChange,
|
||||
onBacktestRunningChange,
|
||||
onMarketChange,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
|
||||
const [market, setMarket] = useState('KRW-BTC');
|
||||
@@ -42,6 +54,11 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
|
||||
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
|
||||
const [paramsRevision, setParamsRevision] = useState(0);
|
||||
|
||||
const editorState = useMemo(
|
||||
() => ({ buy: buyEditorState, sell: sellEditorState }),
|
||||
[buyEditorState, sellEditorState],
|
||||
);
|
||||
|
||||
const draftStrategy = useMemo(
|
||||
() => buildDraftStrategyForEvaluation({
|
||||
name: stratName,
|
||||
@@ -53,12 +70,12 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const strategyPrimaryTimeframe = useMemo(
|
||||
() => (draftStrategy ? resolveStrategyPrimaryTimeframe(draftStrategy) : undefined),
|
||||
[draftStrategy],
|
||||
() => resolveStrategyPrimaryTimeframe(draftStrategy ?? undefined, editorState),
|
||||
[draftStrategy, editorState],
|
||||
);
|
||||
|
||||
const chartTimeframe = useMemo((): Timeframe => {
|
||||
return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe ?? '3m');
|
||||
return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe);
|
||||
}, [timeframeChoice, strategyPrimaryTimeframe]);
|
||||
|
||||
const tfOptions = useMemo(
|
||||
@@ -67,10 +84,14 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const draftRevisionKey = useMemo(
|
||||
() => `${draftStrategy?.buyCondition ?? ''}|${draftStrategy?.sellCondition ?? ''}`,
|
||||
[draftStrategy?.buyCondition, draftStrategy?.sellCondition],
|
||||
() => `${draftStrategy?.buyCondition ?? ''}|${draftStrategy?.sellCondition ?? ''}|${strategyPrimaryTimeframe}`,
|
||||
[draftStrategy?.buyCondition, draftStrategy?.sellCondition, strategyPrimaryTimeframe],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeframeChoice(BACKTEST_STRATEGY_TIMEFRAME);
|
||||
}, [strategyPrimaryTimeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
setParamsRevision(v => v + 1);
|
||||
}, [draftRevisionKey, settingsRevision]);
|
||||
@@ -82,6 +103,11 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
|
||||
refreshAllTickers: refreshMarketTickers,
|
||||
} = useMarketTicker();
|
||||
|
||||
const handleMarketChange = (next: string) => {
|
||||
setMarket(next);
|
||||
onMarketChange?.(next);
|
||||
};
|
||||
|
||||
if (!draftStrategy) {
|
||||
return (
|
||||
<div className="se-eval-panel se-eval-panel--empty">
|
||||
@@ -102,12 +128,16 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
|
||||
strategies={chartStrategies}
|
||||
selectedStrategyId={DRAFT_EVAL_STRATEGY_ID}
|
||||
onStrategySelect={() => {}}
|
||||
onMarketChange={setMarket}
|
||||
onMarketChange={handleMarketChange}
|
||||
onTimeframeChoiceChange={setTimeframeChoice}
|
||||
theme={theme}
|
||||
strategy={draftStrategy}
|
||||
selectedBarIndex={selectedBarIndex}
|
||||
onSelectedBarIndexChange={setSelectedBarIndex}
|
||||
onBarsLoaded={onBarsLoaded}
|
||||
onEvalWindowMetaChange={onEvalWindowMetaChange}
|
||||
onSignalsChange={onSignalsChange}
|
||||
onBacktestRunningChange={onBacktestRunningChange}
|
||||
paramsRevision={paramsRevision}
|
||||
getParamsOverride={getParams}
|
||||
getVisualConfigOverride={getVisualConfig}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
|
||||
import {
|
||||
CondEditor,
|
||||
addChild,
|
||||
@@ -36,6 +37,7 @@ import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
|
||||
import { START_NODE_ID } from '../../utils/strategyFlowLayout';
|
||||
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
||||
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
|
||||
import {
|
||||
clearPaletteDropHighlights,
|
||||
findPaletteDropKeyAtPoint,
|
||||
@@ -403,11 +405,17 @@ export default function StrategyListEditor({
|
||||
}, [onEditorStateChange, onOrphansChange, orphans]);
|
||||
|
||||
const resolvePaletteNode = useCallback((
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
|
||||
) => {
|
||||
if (data.type === 'sidewaysFilter') {
|
||||
return buildSidewaysFilterNode(data.value, def);
|
||||
}
|
||||
if (data.type === 'trendLine') {
|
||||
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
|
||||
}
|
||||
if (data.type === 'savedStrategy') {
|
||||
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
|
||||
}
|
||||
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
}, [signalTab, def]);
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import PaletteChip from './PaletteChip';
|
||||
import { loadBacktestSettings } from '../../utils/backendApi';
|
||||
import {
|
||||
filterTrendLinePaletteItems,
|
||||
type TrendLinePaletteItem,
|
||||
} from '../../utils/trendLinePalette';
|
||||
|
||||
interface Props {
|
||||
signalType: 'buy' | 'sell';
|
||||
searchQuery: string;
|
||||
selectedItemId: string | null;
|
||||
onSelectItem: (id: string | null) => void;
|
||||
onAddToCanvas: (item: TrendLinePaletteItem, lookback: number) => void;
|
||||
}
|
||||
|
||||
function trendLinePeriodLabel(lookback: number, signalType: 'buy' | 'sell'): string {
|
||||
const dir = signalType === 'buy' ? '상향' : '하향';
|
||||
return `${lookback}봉 · ${dir}`;
|
||||
}
|
||||
|
||||
export default function TrendLinePaletteTab({
|
||||
signalType,
|
||||
searchQuery,
|
||||
selectedItemId,
|
||||
onSelectItem,
|
||||
onAddToCanvas,
|
||||
}: Props) {
|
||||
const [defaultLookback, setDefaultLookback] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBacktestSettings().then(s => {
|
||||
if (s?.trendLineDefaultLookback != null && s.trendLineDefaultLookback > 1) {
|
||||
setDefaultLookback(s.trendLineDefaultLookback);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const items = useMemo(
|
||||
() => filterTrendLinePaletteItems(searchQuery),
|
||||
[searchQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="se-palette-section se-palette-section--trendline se-palette-section--scroll">
|
||||
<div className="se-palette-toolbar">
|
||||
<span className="se-palette-toolbar-title">추세선 돌파</span>
|
||||
<span className="se-palette-toolbar-hint">
|
||||
{signalType === 'buy' ? '매수: 상향 돌파' : '매도: 하향 돌파'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
{items.map(item => (
|
||||
<PaletteChip
|
||||
key={item.id}
|
||||
type="indicator"
|
||||
value={item.id}
|
||||
label={item.label}
|
||||
desc={item.desc}
|
||||
color="trendline"
|
||||
trendLine
|
||||
trendLineIndicatorType={item.indicatorType}
|
||||
trendLineLeftField={item.leftField}
|
||||
trendLineLookback={defaultLookback}
|
||||
period={trendLinePeriodLabel(defaultLookback, signalType)}
|
||||
selected={selectedItemId === item.id}
|
||||
onSelect={() => onSelectItem(item.id)}
|
||||
onAdd={() => onAddToCanvas(item, defaultLookback)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && (
|
||||
<p className="se-palette-empty">검색 결과가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
|
||||
import TradingChart from '../TradingChart';
|
||||
import ChartCustomOverlaySettingsModal from '../ChartCustomOverlaySettingsModal';
|
||||
import { MarketSearchPanel } from '../MarketSearchPanel';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe, Drawing } from '../../types';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
import { getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||
import type { BacktestSettingsDto, BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||
import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals';
|
||||
import { buildSignalTrendLineDrawings } from '../../utils/buildSignalTrendLineDrawings';
|
||||
import { setIndicatorChartContext } from '../../utils/indicatorRegistry';
|
||||
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
|
||||
import {
|
||||
BACKTEST_DISPLAY_BAR_COUNT,
|
||||
@@ -197,6 +199,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc');
|
||||
const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||
const [signals, setSignals] = useState<BacktestSignal[]>([]);
|
||||
const [evalSettings, setEvalSettings] = useState<BacktestSettingsDto | null>(null);
|
||||
const [signalDrawings, setSignalDrawings] = useState<Drawing[]>([]);
|
||||
const [backtestRunning, setBacktestRunning] = useState(false);
|
||||
const [backtestError, setBacktestError] = useState<string | null>(null);
|
||||
const [magnifierActive, setMagnifierActive] = useState(false);
|
||||
@@ -415,7 +419,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
setBacktestRunning(true);
|
||||
setBacktestError(null);
|
||||
try {
|
||||
const { signals: nextSignals } = await fetchStrategyEvaluationSignals({
|
||||
const result = await fetchStrategyEvaluationSignals({
|
||||
strategyId: strategy.id,
|
||||
strategy,
|
||||
market,
|
||||
@@ -426,7 +430,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
useDraftDsl,
|
||||
});
|
||||
if (gen !== backtestGenRef.current) return;
|
||||
setSignals(nextSignals);
|
||||
setEvalSettings(result.settings);
|
||||
setSignals(result.signals);
|
||||
applyMarkers();
|
||||
} catch (e) {
|
||||
if (gen !== backtestGenRef.current) return;
|
||||
@@ -461,6 +466,29 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
onSignalsChange?.(signals);
|
||||
}, [signals, onSignalsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setIndicatorChartContext(market, timeframe);
|
||||
if (!strategy || bars.length === 0) {
|
||||
setSignalDrawings([]);
|
||||
return;
|
||||
}
|
||||
if (selectedBarIndex < 0 || selectedBarIndex >= bars.length) {
|
||||
setSignalDrawings([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void buildSignalTrendLineDrawings({
|
||||
bars,
|
||||
barIndex: selectedBarIndex,
|
||||
strategy,
|
||||
settings: evalSettings,
|
||||
getParams,
|
||||
}).then(drawings => {
|
||||
if (!cancelled) setSignalDrawings(drawings);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [bars, strategy, evalSettings, getParams, market, timeframe, selectedBarIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
onBacktestRunningChange?.(backtestRunning);
|
||||
}, [backtestRunning, onBacktestRunningChange]);
|
||||
@@ -972,7 +1000,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
mode="chart"
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[]}
|
||||
drawings={signalDrawings}
|
||||
logScale={false}
|
||||
onCrosshair={() => {}}
|
||||
onManagerReady={onManagerReady}
|
||||
|
||||
+30
-4
@@ -5,6 +5,7 @@ import React, { useMemo, useState } from 'react';
|
||||
import PaletteChip from '../strategyEditor/PaletteChip';
|
||||
import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab';
|
||||
import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab';
|
||||
import TrendLinePaletteTab from '../strategyEditor/TrendLinePaletteTab';
|
||||
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
||||
import {
|
||||
BAND_PALETTE_CHIP_ITEMS,
|
||||
@@ -17,7 +18,7 @@ import type { useStrategyEvaluationEditor } from '../../hooks/useStrategyEvaluat
|
||||
|
||||
type EditorApi = Pick<
|
||||
ReturnType<typeof useStrategyEvaluationEditor>,
|
||||
'def' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter'
|
||||
'def' | 'signalTab' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' | 'applyTrendLine'
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
@@ -35,10 +36,10 @@ function paletteKey(type: string, id: string) {
|
||||
}
|
||||
|
||||
const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) => {
|
||||
const { def, applyPalette, applyPaletteItem, applySidewaysFilter } = editor;
|
||||
const { def, signalTab, applyPalette, applyPaletteItem, applySidewaysFilter, applyTrendLine } = editor;
|
||||
|
||||
const [paletteSearch, setPaletteSearch] = useState('');
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
|
||||
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
|
||||
@@ -141,6 +142,16 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
|
||||
>
|
||||
횡보필터
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
|
||||
onClick={() => {
|
||||
setIndicatorSubTab('trendline');
|
||||
setSelectedPaletteKey(null);
|
||||
}}
|
||||
>
|
||||
추세
|
||||
</button>
|
||||
</div>
|
||||
{indicatorSubTab === 'auxiliary' ? (
|
||||
<IndicatorPaletteTab
|
||||
@@ -178,7 +189,7 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
|
||||
applyPaletteItem(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : indicatorSubTab === 'range' ? (
|
||||
<SidewaysFilterPaletteTab
|
||||
def={def as DefType}
|
||||
items={sidewaysPalette}
|
||||
@@ -195,6 +206,21 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
|
||||
applySidewaysFilter(item.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TrendLinePaletteTab
|
||||
signalType={signalTab}
|
||||
searchQuery={paletteSearch}
|
||||
selectedItemId={
|
||||
selectedPaletteKey?.startsWith('trendline:')
|
||||
? selectedPaletteKey.slice('trendline:'.length)
|
||||
: null
|
||||
}
|
||||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
|
||||
onAddToCanvas={(item, lookback) => {
|
||||
selectPalette('trendline', item.id);
|
||||
applyTrendLine(item, lookback);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
buildSidewaysFilterNode,
|
||||
loadSidewaysPaletteItems,
|
||||
} from '../utils/sidewaysFilterPaletteStorage';
|
||||
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
|
||||
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||
import type { IndicatorVisualConfig } from './useIndicatorSettings';
|
||||
import type { PaletteItem } from '../utils/strategyPaletteStorage';
|
||||
@@ -490,6 +491,18 @@ export function useStrategyEvaluationEditor({
|
||||
});
|
||||
}, [def, handleEditorStateChange]);
|
||||
|
||||
const applyTrendLine = useCallback((
|
||||
item: { indicatorType: string; leftField?: string },
|
||||
lookback: number,
|
||||
) => {
|
||||
const newNode = buildTrendLineConditionNode(item, signalTab, def, lookback);
|
||||
handleEditorStateChange(prev => {
|
||||
const root = prev.root;
|
||||
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||||
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||||
});
|
||||
}, [signalTab, def, handleEditorStateChange]);
|
||||
|
||||
const applyTemplateForEvaluation = useCallback(async (row: TemplatePaletteRow): Promise<StrategyDto | null> => {
|
||||
const payload = buildTemplateEvaluationPayload(row, def);
|
||||
if (!payload) {
|
||||
@@ -541,6 +554,7 @@ export function useStrategyEvaluationEditor({
|
||||
applyPalette,
|
||||
applyPaletteItem,
|
||||
applySidewaysFilter,
|
||||
applyTrendLine,
|
||||
applyTemplateForEvaluation,
|
||||
saving,
|
||||
saveNow,
|
||||
|
||||
@@ -713,6 +713,19 @@
|
||||
background: color-mix(in srgb, var(--se-gold) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--se-gold) 35%, transparent);
|
||||
}
|
||||
.se-strat-status--drag {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.se-strat-status--drag:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.se-strat-status--drag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.se-empty {
|
||||
font-size: 0.78rem;
|
||||
@@ -902,6 +915,10 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.se-eval-return-summary {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-editing-name {
|
||||
font-size: 0.72rem;
|
||||
color: color-mix(in srgb, var(--se-gold) 85%, transparent);
|
||||
@@ -1111,6 +1128,56 @@
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
/* START 결합 엣지 — START끼리 자동 연결 (AND/OR) */
|
||||
.se-flow-start-combine-edge {
|
||||
stroke: var(--se-edge);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.se-flow-start-combine-edge--and {
|
||||
stroke: color-mix(in srgb, var(--se-accent) 80%, transparent);
|
||||
}
|
||||
.se-flow-start-combine-edge--or {
|
||||
stroke: #00d4ff;
|
||||
}
|
||||
.se-start-combine-edge-badge {
|
||||
z-index: 11;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 38px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-accent) 55%, transparent);
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
color: var(--se-accent);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
transition: background 0.15s, transform 0.12s, border-color 0.15s;
|
||||
}
|
||||
.se-start-combine-edge-badge:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.se-start-combine-edge-badge--or {
|
||||
border-color: rgba(0, 212, 255, 0.6);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
/* START 결합 전용 target 핸들 — 비대화형, 시각적으로 최소화 */
|
||||
.se-flow-handle--start-combine-tgt {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
min-width: 1px;
|
||||
min-height: 1px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.se-flow-edge-hit {
|
||||
pointer-events: stroke;
|
||||
}
|
||||
@@ -1910,6 +1977,131 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── AI 전략 에이전트 탭 ───────────────────────────────────── */
|
||||
.se-ai-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.se-ai-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
}
|
||||
.se-ai-title { font-size: 0.78rem; font-weight: 600; color: var(--se-text); }
|
||||
.se-ai-reset {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-reset:disabled { opacity: 0.5; cursor: default; }
|
||||
.se-ai-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.se-ai-empty { color: var(--se-text-muted); font-size: 0.76rem; }
|
||||
.se-ai-empty p { margin: 0 0 6px; line-height: 1.5; }
|
||||
.se-ai-empty-sub { opacity: 0.8; }
|
||||
.se-ai-suggestions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.se-ai-suggestion {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text);
|
||||
font-size: 0.73rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-suggestion:hover { border-color: var(--se-tab-active); }
|
||||
.se-ai-suggestion:disabled { opacity: 0.5; cursor: default; }
|
||||
.se-ai-msg { display: flex; flex-direction: column; max-width: 92%; }
|
||||
.se-ai-msg--user { align-self: flex-end; align-items: flex-end; }
|
||||
.se-ai-msg--assistant { align-self: flex-start; align-items: flex-start; }
|
||||
.se-ai-bubble {
|
||||
padding: 8px 11px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.se-ai-bubble p { margin: 0; }
|
||||
.se-ai-msg--user .se-ai-bubble {
|
||||
background: var(--se-tab-active);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.se-ai-msg--assistant .se-ai-bubble {
|
||||
background: var(--se-input-bg);
|
||||
border: 1px solid var(--se-input-border);
|
||||
color: var(--se-text);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.se-ai-bubble--loading { opacity: 0.7; font-style: italic; }
|
||||
.se-ai-apply-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
.se-ai-tag { font-size: 0.68rem; padding: 2px 7px; border-radius: 999px; }
|
||||
.se-ai-tag--applied { background: color-mix(in srgb, #16a34a 18%, transparent); color: #16a34a; }
|
||||
.se-ai-tag--reverted { background: color-mix(in srgb, #d97706 18%, transparent); color: #d97706; }
|
||||
.se-ai-mini-btn {
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: transparent;
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-mini-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.se-ai-error {
|
||||
margin: 0 10px 6px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
background: color-mix(in srgb, #dc2626 14%, transparent);
|
||||
color: #dc2626;
|
||||
}
|
||||
.se-ai-input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--se-border);
|
||||
}
|
||||
.se-ai-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text);
|
||||
font-size: 0.76rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.se-ai-send {
|
||||
align-self: stretch;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--se-tab-active);
|
||||
color: #fff;
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-send:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.se-palette-search {
|
||||
margin: 10px 10px 6px;
|
||||
padding: 8px 10px;
|
||||
@@ -1957,6 +2149,15 @@
|
||||
color: var(--se-palette-section-range, #f59e0b);
|
||||
box-shadow: inset 0 -2px 0 var(--se-palette-section-range, #f59e0b);
|
||||
}
|
||||
.se-palette-subtab--trendline.se-palette-subtab--on {
|
||||
color: var(--se-palette-section-trendline, #38bdf8);
|
||||
box-shadow: inset 0 -2px 0 var(--se-palette-section-trendline, #38bdf8);
|
||||
}
|
||||
|
||||
.se-palette-section--trendline h3,
|
||||
.se-palette-section--trendline .se-palette-toolbar-title {
|
||||
color: var(--se-palette-section-trendline, #38bdf8);
|
||||
}
|
||||
|
||||
.se-palette-section--range .se-palette-toolbar-title {
|
||||
color: var(--se-palette-section-range, #f59e0b);
|
||||
@@ -2467,6 +2668,31 @@ body.se-palette-drag-armed .se-palette-section--scroll {
|
||||
0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── 추세선 돌파 ── */
|
||||
.se-palette-card--trendline {
|
||||
--se-palette-trendline-accent: var(--se-palette-section-trendline, #38bdf8);
|
||||
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--se-palette-trendline-accent) 10%, var(--bg2));
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-trendline-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
--se-palette-chip-caption: color-mix(in srgb, var(--se-palette-trendline-accent) 34%, #fff 66%);
|
||||
--se-palette-chip-period-value: color-mix(in srgb, var(--se-palette-trendline-accent) 18%, #fff 82%);
|
||||
--se-palette-chip-period-bg: color-mix(in srgb, #000 34%, transparent);
|
||||
--se-palette-chip-period-border: color-mix(in srgb, var(--se-palette-trendline-accent) 40%, transparent);
|
||||
}
|
||||
.se-palette-card--trendline .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
|
||||
color: var(--se-palette-trendline-accent);
|
||||
}
|
||||
.se-palette-card--trendline:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 65%, transparent);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-trendline-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
|
||||
0 4px 14px color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── 복합지표 ── */
|
||||
.se-palette-card--composite {
|
||||
border-color: color-mix(in srgb, #c084fc 42%, transparent);
|
||||
@@ -2577,6 +2803,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
|
||||
/* 지표 탭 — 전략·보조 지표 컨트롤 이름 (오렌지) */
|
||||
.se-palette-panel .se-palette-card--band .se-palette-card-name,
|
||||
.se-palette-panel .se-palette-card--ind .se-palette-card-name,
|
||||
.se-palette-panel .se-palette-card--trendline .se-palette-card-name,
|
||||
.se-palette-panel .se-palette-card--composite .se-palette-card-name,
|
||||
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-name,
|
||||
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-name,
|
||||
@@ -2588,6 +2815,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
|
||||
/* 지표 탭 — 설명·기간 보조 텍스트 가독성 */
|
||||
.se-palette-panel .se-palette-card--band .se-palette-card-desc,
|
||||
.se-palette-panel .se-palette-card--ind .se-palette-card-desc,
|
||||
.se-palette-panel .se-palette-card--trendline .se-palette-card-desc,
|
||||
.se-palette-panel .se-palette-card--composite .se-palette-card-desc,
|
||||
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-desc,
|
||||
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-desc,
|
||||
@@ -2603,6 +2831,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
|
||||
|
||||
.se-palette-panel .se-palette-card--band .se-palette-card-period,
|
||||
.se-palette-panel .se-palette-card--ind .se-palette-card-period,
|
||||
.se-palette-panel .se-palette-card--trendline .se-palette-card-period,
|
||||
.se-palette-panel .se-palette-card--composite .se-palette-card-period,
|
||||
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-period,
|
||||
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-period,
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
--se-palette-section-band: #a78bfa;
|
||||
--se-palette-section-ind: #34d399;
|
||||
--se-palette-section-range: #f59e0b;
|
||||
--se-palette-section-trendline: #38bdf8;
|
||||
--se-palette-strategy-name: #f59e0b;
|
||||
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 82%, #fff 18%);
|
||||
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 90%, #fff 10%);
|
||||
@@ -221,6 +222,7 @@
|
||||
--se-palette-section-band: #7b1fa2;
|
||||
--se-palette-section-ind: #2e7d32;
|
||||
--se-palette-section-range: #e65100;
|
||||
--se-palette-section-trendline: #0288d1;
|
||||
--se-palette-strategy-name: #e65100;
|
||||
--se-palette-chip-caption: color-mix(in srgb, var(--se-text) 78%, #000 22%);
|
||||
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 92%, #000 8%);
|
||||
@@ -290,6 +292,7 @@
|
||||
--se-palette-section-band: #ce93d8;
|
||||
--se-palette-section-ind: #69f0ae;
|
||||
--se-palette-section-range: #ffb74d;
|
||||
--se-palette-section-trendline: #4fc3f7;
|
||||
--se-palette-strategy-name: #ffb74d;
|
||||
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 80%, #fff 20%);
|
||||
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 88%, #fff 12%);
|
||||
|
||||
@@ -1440,6 +1440,14 @@ export interface BacktestSettingsDto {
|
||||
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
|
||||
/** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */
|
||||
tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE';
|
||||
/** 추세선 조건 — TWO_POINT | REGRESSION | SWING */
|
||||
trendLineMode?: 'TWO_POINT' | 'REGRESSION' | 'SWING';
|
||||
/** 조건 lookbackPeriod 미지정 시 기본 N봉 */
|
||||
trendLineDefaultLookback?: number;
|
||||
/** SWING 모드 — 스윙 저점 판정 좌측 봉 수 */
|
||||
trendLineSwingPrecedingBars?: number;
|
||||
/** SWING 모드 — 스윙 저점 판정 우측 봉 수 */
|
||||
trendLineSwingFollowingBars?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
||||
@@ -1465,6 +1473,10 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
||||
positionMode: 'LONG_ONLY',
|
||||
analysisMethod: 'MARK_TO_MARKET',
|
||||
tradeExecutionMode: 'BACKTEST_ENGINE',
|
||||
trendLineMode: 'TWO_POINT',
|
||||
trendLineDefaultLookback: 10,
|
||||
trendLineSwingPrecedingBars: 1,
|
||||
trendLineSwingFollowingBars: 1,
|
||||
};
|
||||
|
||||
/** 백테스팅 설정 로드 */
|
||||
@@ -1823,6 +1835,63 @@ export async function fetchLlmConnectionTest(): Promise<LlmConnectionTestRespons
|
||||
);
|
||||
}
|
||||
|
||||
export interface LlmModelsQuery {
|
||||
host: string;
|
||||
port: number;
|
||||
useHttps: boolean;
|
||||
modelsPath?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LlmModelsResult {
|
||||
ok: boolean;
|
||||
models: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 설정 화면 — 입력한(미저장) 서버에서 사용 가능한 모델 목록 조회 */
|
||||
export async function fetchLlmModels(query: LlmModelsQuery): Promise<LlmModelsResult> {
|
||||
return requestOrThrowWithTimeout<LlmModelsResult>(
|
||||
'/strategy/evaluation/llm-models',
|
||||
{
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify(query),
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
}
|
||||
|
||||
export interface StrategyAgentChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StrategyAgentChatApiResponse {
|
||||
reply: string;
|
||||
action: 'ask' | 'build' | 'clear';
|
||||
buyCondition?: unknown | null;
|
||||
sellCondition?: unknown | null;
|
||||
model?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
/** AI 전략 에이전트 — 멀티턴 대화로 전략 DSL 생성/수정 */
|
||||
export async function postStrategyAgentChat(
|
||||
messages: StrategyAgentChatMessage[],
|
||||
context: unknown,
|
||||
): Promise<StrategyAgentChatApiResponse> {
|
||||
return requestOrThrowWithTimeout<StrategyAgentChatApiResponse>(
|
||||
'/strategy/agent/chat',
|
||||
{
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ messages, context }),
|
||||
},
|
||||
620_000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
|
||||
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
|
||||
if (!hasRegisteredUser()) return ['1m'];
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 전략평가 차트 — 선택 봉 기준 N봉 추세선 Drawing 생성
|
||||
*/
|
||||
import type { Drawing } from '../types';
|
||||
import type { OHLCVBar } from '../types';
|
||||
import type { BacktestSettingsDto, StrategyDto } from './backendApi';
|
||||
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
||||
import { trendLineConfigFromSettings, trendLineSegmentAt, trendLineKindForCross, isTrendLinePriceScaleField } from './trendLineGeometry';
|
||||
import { calculateIndicator } from './indicatorRegistry';
|
||||
import { DSL_TO_REGISTRY } from './strategyToChartIndicators';
|
||||
|
||||
function walkTrendLineConditions(
|
||||
node: LogicNode | null | undefined,
|
||||
side: 'buy' | 'sell',
|
||||
out: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }>,
|
||||
): void {
|
||||
if (!node) return;
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
|
||||
return;
|
||||
}
|
||||
if (node.type === 'NOT') {
|
||||
const child = node.children?.[0];
|
||||
if (child) walkTrendLineConditions(child, side, out);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'AND' || node.type === 'OR') {
|
||||
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
|
||||
return;
|
||||
}
|
||||
if ((!node.type || node.type === 'CONDITION') && node.condition) {
|
||||
const ct = node.condition.conditionType;
|
||||
if (ct === 'TREND_LINE_CROSS_UP' || ct === 'TREND_LINE_CROSS_DOWN') {
|
||||
out.push({ side, cond: node.condition });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registryType(dslType: string): string {
|
||||
return DSL_TO_REGISTRY[dslType] ?? dslType;
|
||||
}
|
||||
|
||||
function isPriceScaleField(field?: string): boolean {
|
||||
if (!field) return true;
|
||||
const f = field.toUpperCase();
|
||||
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|
||||
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|
||||
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|
||||
|| f.includes('BAND') || f.includes('DC_');
|
||||
}
|
||||
|
||||
async function resolveSourceValues(
|
||||
bars: OHLCVBar[],
|
||||
cond: ConditionDSL,
|
||||
getParams: (type: string) => Record<string, number | string | boolean>,
|
||||
): Promise<number[] | null> {
|
||||
const lf = (cond.leftField ?? 'CLOSE_PRICE').toUpperCase();
|
||||
if (lf === 'CLOSE_PRICE' || lf === 'CURRENT') {
|
||||
return bars.map(b => b.close);
|
||||
}
|
||||
if (lf === 'HIGH_PRICE') return bars.map(b => b.high);
|
||||
if (lf === 'LOW_PRICE') return bars.map(b => b.low);
|
||||
if (lf === 'OPEN_PRICE') return bars.map(b => b.open);
|
||||
|
||||
const regType = registryType(cond.indicatorType);
|
||||
try {
|
||||
const params = { ...getParams(regType), ...(cond.params ?? {}) };
|
||||
const { plots } = await calculateIndicator(regType, bars, params);
|
||||
const plotKeys = Object.keys(plots);
|
||||
if (plotKeys.length === 0) return null;
|
||||
|
||||
let plotKey = plotKeys[0]!;
|
||||
if (lf.includes('K') && plots.plot0) plotKey = 'plot0';
|
||||
else if (lf.includes('SIGNAL') && plots.plot1) plotKey = 'plot1';
|
||||
else if (lf.includes('MACD') && plots.plot0) plotKey = 'plot0';
|
||||
else if (lf.includes('UPPER') && plots.plot2) plotKey = 'plot2';
|
||||
else if (lf.includes('LOWER') && plots.plot0) plotKey = 'plot0';
|
||||
else if (lf.includes('MIDDLE') && plots.plot1) plotKey = 'plot1';
|
||||
|
||||
const plot = plots[plotKey as keyof typeof plots];
|
||||
if (!plot || !Array.isArray(plot)) return null;
|
||||
const byTime = new Map(plot.map((p: { time: number; value: number }) => [p.time, p.value]));
|
||||
return bars.map(b => {
|
||||
const v = byTime.get(b.time);
|
||||
return v != null && Number.isFinite(v) ? v : NaN;
|
||||
});
|
||||
} catch {
|
||||
return bars.map(b => b.close);
|
||||
}
|
||||
}
|
||||
|
||||
/** 선택 봉(barIndex) 기준 추세선만 반환 — 시그널 전체 순회하지 않음 */
|
||||
export async function buildSignalTrendLineDrawings(opts: {
|
||||
bars: OHLCVBar[];
|
||||
/** 캔들 선택봉 인덱스 */
|
||||
barIndex: number;
|
||||
strategy: StrategyDto | null;
|
||||
settings?: BacktestSettingsDto | null;
|
||||
getParams: (type: string) => Record<string, number | string | boolean>;
|
||||
}): Promise<Drawing[]> {
|
||||
const { bars, barIndex, strategy, settings, getParams } = opts;
|
||||
if (!strategy || bars.length === 0 || barIndex < 0 || barIndex >= bars.length) return [];
|
||||
|
||||
const conditions: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }> = [];
|
||||
walkTrendLineConditions(strategy.buyCondition as LogicNode | null, 'buy', conditions);
|
||||
walkTrendLineConditions(strategy.sellCondition as LogicNode | null, 'sell', conditions);
|
||||
if (conditions.length === 0) return [];
|
||||
|
||||
const priceConds = conditions.filter(c => isPriceScaleField(c.cond.leftField));
|
||||
if (priceConds.length === 0) return [];
|
||||
|
||||
const config = trendLineConfigFromSettings(settings);
|
||||
const barTimes = bars.map(b => b.time);
|
||||
const drawings: Drawing[] = [];
|
||||
|
||||
const sourceCache = new Map<string, number[]>();
|
||||
for (const { cond } of priceConds) {
|
||||
const key = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
|
||||
if (!sourceCache.has(key)) {
|
||||
const values = await resolveSourceValues(bars, cond, getParams);
|
||||
if (values) sourceCache.set(key, values);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { side, cond } of priceConds) {
|
||||
const cacheKey = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
|
||||
const sourceValues = sourceCache.get(cacheKey);
|
||||
if (!sourceValues) continue;
|
||||
|
||||
const lookback = Math.max(2, cond.lookbackPeriod ?? config.defaultLookback);
|
||||
const kind = trendLineKindForCross(cond.conditionType, side);
|
||||
const priceScale = isTrendLinePriceScaleField(cond.leftField);
|
||||
const seg = trendLineSegmentAt(
|
||||
barTimes, sourceValues, bars, barIndex, lookback, config, kind, priceScale,
|
||||
);
|
||||
if (!seg) continue;
|
||||
|
||||
const id = `tl-${barIndex}-${side}-${cond.conditionType}-${lookback}-${config.mode}`;
|
||||
drawings.push({
|
||||
id,
|
||||
type: 'trendline',
|
||||
points: [
|
||||
{ time: seg.startTimeSec, price: seg.startPrice },
|
||||
{ time: seg.endTimeSec, price: seg.endPrice },
|
||||
],
|
||||
color: side === 'buy' ? '#26a69a' : '#ef5350',
|
||||
lineWidth: 2,
|
||||
style: 'dashed',
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
return drawings;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export interface LlmAppSettings {
|
||||
useHttps: boolean;
|
||||
/** OpenAI 호환 chat completions 경로 */
|
||||
chatPath: string;
|
||||
/** OpenAI 호환 모델 목록 경로 (드롭다운 조회용) */
|
||||
modelsPath: string;
|
||||
/** 모델 ID */
|
||||
model: string;
|
||||
/** 최대 생성 토큰 */
|
||||
@@ -29,6 +31,7 @@ export const DEFAULT_LLM_APP_SETTINGS: LlmAppSettings = {
|
||||
port: 16000,
|
||||
useHttps: false,
|
||||
chatPath: '/v1/chat/completions',
|
||||
modelsPath: '/v1/models',
|
||||
model: 'mlx-community/Qwen2.5-7B-Instruct-4bit',
|
||||
maxTokens: 1024,
|
||||
temperature: 0.1,
|
||||
@@ -56,6 +59,9 @@ export function resolveLlmAppSettings(
|
||||
const chatPath = typeof raw.chatPath === 'string' && raw.chatPath.trim()
|
||||
? (raw.chatPath.startsWith('/') ? raw.chatPath.trim() : `/${raw.chatPath.trim()}`)
|
||||
: d.chatPath;
|
||||
const modelsPath = typeof raw.modelsPath === 'string' && raw.modelsPath.trim()
|
||||
? (raw.modelsPath.startsWith('/') ? raw.modelsPath.trim() : `/${raw.modelsPath.trim()}`)
|
||||
: d.modelsPath;
|
||||
const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : d.model;
|
||||
return {
|
||||
enabled: raw.enabled === undefined ? d.enabled : Boolean(raw.enabled),
|
||||
@@ -63,6 +69,7 @@ export function resolveLlmAppSettings(
|
||||
port: clampInt(raw.port, 1, 65535, d.port),
|
||||
useHttps: raw.useHttps === undefined ? d.useHttps : Boolean(raw.useHttps),
|
||||
chatPath,
|
||||
modelsPath,
|
||||
model,
|
||||
maxTokens: clampInt(raw.maxTokens, 64, 8192, d.maxTokens),
|
||||
temperature: clampFloat(raw.temperature, 0, 2, d.temperature),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* AI 전략 에이전트 — 멀티턴 대화 API 클라이언트 + 응답 DSL 검증·정규화.
|
||||
*/
|
||||
import {
|
||||
postStrategyAgentChat,
|
||||
type StrategyAgentChatMessage,
|
||||
type StrategyAgentChatApiResponse,
|
||||
} from './backendApi';
|
||||
import { INDICATOR_CONDITIONS, generateNodeId, type ConditionDSL, type LogicNode, type LogicNodeType } from './strategyTypes';
|
||||
|
||||
export type { StrategyAgentChatMessage } from './backendApi';
|
||||
|
||||
export interface StrategyAgentResult {
|
||||
reply: string;
|
||||
action: 'ask' | 'build' | 'clear';
|
||||
/** 검증·정규화된 매수 조건 (build이고 유효할 때만) */
|
||||
buyCondition: LogicNode | null;
|
||||
/** 검증·정규화된 매도 조건 */
|
||||
sellCondition: LogicNode | null;
|
||||
/** action=build인데 DSL이 모두 비어 검증 후 사라진 경우 */
|
||||
hasStrategy: boolean;
|
||||
model?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
const NODE_TYPES: ReadonlySet<LogicNodeType> = new Set<LogicNodeType>([
|
||||
'AND', 'OR', 'NOT', 'CONDITION', 'TIMEFRAME',
|
||||
]);
|
||||
|
||||
const WINDOW_MODES = new Set(['EXISTS_IN', 'NOT_EXISTS_IN']);
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/** LLM이 생성한 LogicNode 트리를 편집기/백엔드가 처리 가능한 형태로 검증·정규화. */
|
||||
export function sanitizeAiLogicNode(raw: unknown): LogicNode | null {
|
||||
const obj = asRecord(raw);
|
||||
if (!obj) return null;
|
||||
|
||||
const type = obj.type as LogicNodeType;
|
||||
if (!NODE_TYPES.has(type)) return null;
|
||||
|
||||
const id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : generateNodeId();
|
||||
|
||||
if (type === 'CONDITION') {
|
||||
const cond = asRecord(obj.condition);
|
||||
if (!cond) return null;
|
||||
const indicatorType = typeof cond.indicatorType === 'string' ? cond.indicatorType.trim() : '';
|
||||
const conditionType = typeof cond.conditionType === 'string' ? cond.conditionType.trim() : '';
|
||||
if (!indicatorType || !conditionType) return null;
|
||||
// 알려진 지표면 conditionType 매트릭스로 검증, 모르는 지표는 백엔드 폴백에 위임
|
||||
const allowed = INDICATOR_CONDITIONS[indicatorType];
|
||||
if (allowed && !allowed.includes(conditionType)) return null;
|
||||
|
||||
const condition = { ...cond, indicatorType, conditionType } as unknown as ConditionDSL;
|
||||
// 윈도우 모드일 때 candleRange 보정
|
||||
if (condition.candleRangeMode && WINDOW_MODES.has(condition.candleRangeMode)) {
|
||||
const n = Number(condition.candleRange);
|
||||
condition.candleRange = Number.isFinite(n) && n >= 2 ? Math.floor(n) : 4;
|
||||
}
|
||||
return { id, type, condition };
|
||||
}
|
||||
|
||||
// AND / OR / NOT / TIMEFRAME
|
||||
const children = Array.isArray(obj.children)
|
||||
? (obj.children.map(sanitizeAiLogicNode).filter(Boolean) as LogicNode[])
|
||||
: [];
|
||||
|
||||
if (type === 'NOT') {
|
||||
if (children.length === 0) return null;
|
||||
return { id, type, children: [children[0]] } as LogicNode;
|
||||
}
|
||||
|
||||
if (type === 'TIMEFRAME') {
|
||||
if (children.length === 0) return null;
|
||||
const node: LogicNode = { id, type, children };
|
||||
if (typeof obj.candleType === 'string') node.candleType = obj.candleType;
|
||||
if (Array.isArray(obj.candleTypes)) {
|
||||
node.candleTypes = obj.candleTypes.filter((c): c is string => typeof c === 'string');
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// AND / OR — 자식이 없으면 무의미하므로 제거, 하나뿐이면 그대로 승격하지 않고 유지
|
||||
if (children.length === 0) return null;
|
||||
return { id, type, children } as LogicNode;
|
||||
}
|
||||
|
||||
/** 멀티턴 대화 요청 → 응답 파싱·DSL 검증 */
|
||||
export async function requestStrategyAgentChat(
|
||||
messages: StrategyAgentChatMessage[],
|
||||
context: unknown,
|
||||
): Promise<StrategyAgentResult> {
|
||||
const res: StrategyAgentChatApiResponse = await postStrategyAgentChat(messages, context);
|
||||
const action: 'ask' | 'build' | 'clear' =
|
||||
res.action === 'build' ? 'build' : res.action === 'clear' ? 'clear' : 'ask';
|
||||
const buyCondition = action === 'build' ? sanitizeAiLogicNode(res.buyCondition) : null;
|
||||
const sellCondition = action === 'build' ? sanitizeAiLogicNode(res.sellCondition) : null;
|
||||
return {
|
||||
reply: res.reply ?? '',
|
||||
action,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
hasStrategy: Boolean(buyCondition || sellCondition),
|
||||
model: res.model,
|
||||
latencyMs: res.latencyMs,
|
||||
};
|
||||
}
|
||||
@@ -418,6 +418,8 @@ const COND_OPTIONS = [
|
||||
{ v: 'NEQ', l: '다름 (!=)' },
|
||||
{ v: 'CROSS_UP', l: '상향 돌파' },
|
||||
{ v: 'CROSS_DOWN', l: '하향 돌파' },
|
||||
{ v: 'TREND_LINE_CROSS_UP', l: '추세선 상향 돌파' },
|
||||
{ v: 'TREND_LINE_CROSS_DOWN', l: '추세선 하향 돌파' },
|
||||
{ v: 'SLOPE_UP', l: '상승 기울기' },
|
||||
{ v: 'SLOPE_DOWN', l: '하락 기울기' },
|
||||
{ v: 'DIFF_GT', l: '차이 > 값' },
|
||||
@@ -444,6 +446,7 @@ export const getCondOptionsForIndicator = (ind: string): CondTypeOpt[] => {
|
||||
|
||||
const COMMON_COND_KEYS = [
|
||||
'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN',
|
||||
'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN',
|
||||
'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS',
|
||||
];
|
||||
|
||||
@@ -899,6 +902,10 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D
|
||||
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
||||
updated.rightField = 'NONE';
|
||||
updated.slopePeriod = updated.slopePeriod ?? 3;
|
||||
} else if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(newCondType)) {
|
||||
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
||||
updated.rightField = 'NONE';
|
||||
updated.lookbackPeriod = updated.lookbackPeriod ?? 10;
|
||||
} else if (newCondType === 'HOLD_N_DAYS') {
|
||||
updated.leftField = 'NONE'; updated.rightField = 'NONE';
|
||||
updated.holdDays = updated.holdDays ?? 3;
|
||||
@@ -1004,6 +1011,11 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
||||
const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥';
|
||||
return `${indName} - 최근 ${n}봉 ${L} 최저 ${op} ${R || '침체선'}`;
|
||||
}
|
||||
if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(c.conditionType)) {
|
||||
const n = c.lookbackPeriod ?? 10;
|
||||
const dir = c.conditionType === 'TREND_LINE_CROSS_UP' ? '상향 돌파' : '하향 돌파';
|
||||
return `${indName} - 이전 ${n}봉 추세선 ${dir} · ${L}`;
|
||||
}
|
||||
const rangeClause = formatCandleRangeClause(c);
|
||||
if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`;
|
||||
return `${indName} - ${rangeClause}${L} ${C}`;
|
||||
@@ -1215,11 +1227,12 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
||||
};
|
||||
|
||||
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
|
||||
const isTrendLineType = ['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(normalized.conditionType);
|
||||
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
|
||||
const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType);
|
||||
const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType) || isTrendLineType;
|
||||
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
|
||||
|
||||
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
|
||||
const rightValue = isSlopeType || isTrendLineType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
|
||||
|
||||
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
|
||||
const handleRight = (v: string) => {
|
||||
|
||||
@@ -10,11 +10,15 @@ import {
|
||||
normalizeStartCandleType,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
type StartNodeMeta,
|
||||
type StartCombineOp,
|
||||
} from './strategyStartNodes';
|
||||
|
||||
export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES };
|
||||
export type { StartNodeMeta };
|
||||
|
||||
/** START 결합 엣지 식별 prefix */
|
||||
export const START_COMBINE_EDGE_PREFIX = 'start-combine:';
|
||||
|
||||
const H_GAP = 280;
|
||||
const V_GAP = 130;
|
||||
|
||||
@@ -436,6 +440,8 @@ export function applyEdgeHandles(
|
||||
saved: Map<string, EdgeHandleBinding>,
|
||||
): Edge[] {
|
||||
return edges.map(edge => {
|
||||
// START 결합 엣지는 고정 핸들(s-bottom→t-top) 유지 — 핸들 재계산/바인딩 제외
|
||||
if (edge.type === 'startCombine') return edge;
|
||||
const binding = saved.get(edge.id) ?? {};
|
||||
const picked = pickHandleSides(edge.source, edge.target, positions);
|
||||
let sourceHandle = picked.sourceHandle;
|
||||
@@ -716,6 +722,8 @@ export function logicNodeToFlow(
|
||||
startMeta: Record<string, StartNodeMeta> = defaultStartMeta(),
|
||||
extraStartIds: string[] = [],
|
||||
extraRoots: Record<string, LogicNode | null> = {},
|
||||
startCombineOp: StartCombineOp = 'AND',
|
||||
onStartCombineOpChange?: (op: StartCombineOp) => void,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
@@ -766,6 +774,28 @@ export function logicNodeToFlow(
|
||||
appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140);
|
||||
});
|
||||
|
||||
// START 2개 이상이면 START끼리 자동 결합 엣지(체인)로 연결 — AND/OR 토글 배지
|
||||
if (extraStartIds.length >= 1) {
|
||||
const chain = [START_NODE_ID, ...extraStartIds];
|
||||
for (let i = 1; i < chain.length; i += 1) {
|
||||
const sourceId = chain[i - 1];
|
||||
const targetId = chain[i];
|
||||
edges.push({
|
||||
id: `${START_COMBINE_EDGE_PREFIX}${sourceId}->${targetId}`,
|
||||
source: sourceId,
|
||||
sourceHandle: 's-bottom',
|
||||
target: targetId,
|
||||
targetHandle: 't-top',
|
||||
type: 'startCombine',
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
reconnectable: false,
|
||||
focusable: false,
|
||||
data: { op: startCombineOp, onToggle: onStartCombineOpChange },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
|
||||
|
||||
return { nodes, edges };
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type StrategyIndicatorRef,
|
||||
} from './strategyIndicatorSync';
|
||||
|
||||
const DSL_TO_REGISTRY: Record<string, string> = {
|
||||
export const DSL_TO_REGISTRY: Record<string, string> = {
|
||||
RSI: 'RSI',
|
||||
MACD: 'MACD',
|
||||
MA: 'SMA',
|
||||
|
||||
@@ -104,6 +104,7 @@ export const CONDITION_LABEL: Record<string, string> = {
|
||||
GT: '초과(>)', LT: '미만(<)', GTE: '이상(≥)', LTE: '이하(≤)',
|
||||
EQ: '같음(=)', NEQ: '다름(≠)',
|
||||
CROSS_UP: '상향 돌파', CROSS_DOWN: '하향 돌파',
|
||||
TREND_LINE_CROSS_UP: '추세선 상향 돌파', TREND_LINE_CROSS_DOWN: '추세선 하향 돌파',
|
||||
SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중',
|
||||
DIFF_GT: '차이>값', DIFF_LT: '차이<값',
|
||||
HOLD_N_DAYS: 'N일 유지',
|
||||
@@ -118,7 +119,7 @@ export const CONDITION_LABEL: Record<string, string> = {
|
||||
VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율',
|
||||
};
|
||||
|
||||
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
|
||||
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
|
||||
const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE'];
|
||||
const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS];
|
||||
|
||||
@@ -357,6 +358,21 @@ export function generateNodeId(): string {
|
||||
return `node_${++_nodeCounter}_${Date.now()}`;
|
||||
}
|
||||
|
||||
/** 서브트리 깊은 복제 + 모든 노드 id 재생성 (저장 전략 드롭 시 id 충돌 방지) */
|
||||
export function cloneLogicNodeWithNewIds(node: LogicNode): LogicNode {
|
||||
return {
|
||||
...node,
|
||||
id: generateNodeId(),
|
||||
condition: node.condition
|
||||
? {
|
||||
...node.condition,
|
||||
params: node.condition.params ? { ...node.condition.params } : node.condition.params,
|
||||
}
|
||||
: node.condition,
|
||||
children: node.children ? node.children.map(cloneLogicNodeWithNewIds) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 자연어 설명 변환 */
|
||||
export function nodeToNaturalLanguage(node: LogicNode): string {
|
||||
if (!node) return '';
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* N봉 추세선 — backend TrendLineMath 와 동기 (스윙 피벗).
|
||||
* fit [anchor−N, anchor−1], 상승(UP)=스윙 저점·하락(DOWN)=스윙 고점, anchor까지 연장.
|
||||
*/
|
||||
import type { BacktestSettingsDto } from './backendApi';
|
||||
|
||||
export type TrendLineMode = 'TWO_POINT' | 'REGRESSION' | 'SWING';
|
||||
export type TrendLineKind = 'UP' | 'DOWN';
|
||||
|
||||
export interface TrendLineConfig {
|
||||
mode: TrendLineMode;
|
||||
defaultLookback: number;
|
||||
swingPrecedingBars: number;
|
||||
swingFollowingBars: number;
|
||||
}
|
||||
|
||||
export interface TrendLineSegment {
|
||||
startTimeSec: number;
|
||||
startPrice: number;
|
||||
endTimeSec: number;
|
||||
endPrice: number;
|
||||
}
|
||||
|
||||
interface PivotPair {
|
||||
indexA: number;
|
||||
yA: number;
|
||||
indexB: number;
|
||||
yB: number;
|
||||
}
|
||||
|
||||
export function trendLineKindForCross(conditionType?: string, side?: 'buy' | 'sell'): TrendLineKind {
|
||||
if (conditionType === 'TREND_LINE_CROSS_UP') return 'DOWN';
|
||||
if (conditionType === 'TREND_LINE_CROSS_DOWN') return 'UP';
|
||||
return side === 'buy' ? 'DOWN' : 'UP';
|
||||
}
|
||||
|
||||
export function isTrendLinePriceScaleField(field?: string): boolean {
|
||||
if (!field) return true;
|
||||
const f = field.toUpperCase();
|
||||
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|
||||
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|
||||
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|
||||
|| f.includes('BAND') || f.includes('DC_') || f.includes('VWAP')
|
||||
|| f.includes('CONVERSION') || f.includes('BASE') || f.includes('SPAN')
|
||||
|| f.includes('LAGGING') || f.includes('CLOUD');
|
||||
}
|
||||
|
||||
export function trendLineConfigFromSettings(
|
||||
settings?: Pick<
|
||||
BacktestSettingsDto,
|
||||
| 'trendLineMode'
|
||||
| 'trendLineDefaultLookback'
|
||||
| 'trendLineSwingPrecedingBars'
|
||||
| 'trendLineSwingFollowingBars'
|
||||
> | null,
|
||||
): TrendLineConfig {
|
||||
let mode = settings?.trendLineMode ?? 'SWING';
|
||||
if (mode !== 'TWO_POINT' && mode !== 'REGRESSION' && mode !== 'SWING') {
|
||||
mode = 'SWING';
|
||||
}
|
||||
const defaultLookback =
|
||||
settings?.trendLineDefaultLookback != null && settings.trendLineDefaultLookback > 1
|
||||
? settings.trendLineDefaultLookback
|
||||
: 10;
|
||||
return {
|
||||
mode,
|
||||
defaultLookback,
|
||||
swingPrecedingBars: settings?.trendLineSwingPrecedingBars ?? 1,
|
||||
swingFollowingBars: settings?.trendLineSwingFollowingBars ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function numAt(values: number[], index: number): number | null {
|
||||
if (index < 0 || index >= values.length) return null;
|
||||
const v = values[index];
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function yAtPivot(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
index: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
if (priceScale) {
|
||||
return kind === 'UP' ? bars[index]!.low : bars[index]!.high;
|
||||
}
|
||||
return numAt(sourceValues, index) ?? NaN;
|
||||
}
|
||||
|
||||
function isSwingLow(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
i: number,
|
||||
prec: number,
|
||||
fol: number,
|
||||
priceScale: boolean,
|
||||
): boolean {
|
||||
const low = yAtPivot(bars, sourceValues, i, 'UP', priceScale);
|
||||
if (!Number.isFinite(low)) return false;
|
||||
for (let j = i - prec; j <= i + fol; j++) {
|
||||
if (j === i) continue;
|
||||
if (j < 0 || j >= bars.length) return false;
|
||||
const v = yAtPivot(bars, sourceValues, j, 'UP', priceScale);
|
||||
if (!Number.isFinite(v) || v < low) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSwingHigh(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
i: number,
|
||||
prec: number,
|
||||
fol: number,
|
||||
priceScale: boolean,
|
||||
): boolean {
|
||||
const high = yAtPivot(bars, sourceValues, i, 'DOWN', priceScale);
|
||||
if (!Number.isFinite(high)) return false;
|
||||
for (let j = i - prec; j <= i + fol; j++) {
|
||||
if (j === i) continue;
|
||||
if (j < 0 || j >= bars.length) return false;
|
||||
const v = yAtPivot(bars, sourceValues, j, 'DOWN', priceScale);
|
||||
if (!Number.isFinite(v) || v > high) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectSwingIndices(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
kLeft: number,
|
||||
kRight: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = start + kLeft; i <= end - kRight; i++) {
|
||||
const isSwing = kind === 'UP'
|
||||
? isSwingLow(bars, sourceValues, i, kLeft, kRight, priceScale)
|
||||
: isSwingHigh(bars, sourceValues, i, kLeft, kRight, priceScale);
|
||||
if (isSwing) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolvePivots(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): PivotPair | null {
|
||||
const kLeft = Math.max(0, config.swingPrecedingBars);
|
||||
const kRight = Math.max(0, config.swingFollowingBars);
|
||||
const swings = collectSwingIndices(bars, sourceValues, start, end, kLeft, kRight, kind, priceScale);
|
||||
if (swings.length < 2) return null;
|
||||
|
||||
const swingA = swings[0]!;
|
||||
for (let j = 1; j < swings.length; j++) {
|
||||
const swingB = swings[j]!;
|
||||
if (swingB <= swingA) continue;
|
||||
const yA = yAtPivot(bars, sourceValues, swingA, kind, priceScale);
|
||||
const yB = yAtPivot(bars, sourceValues, swingB, kind, priceScale);
|
||||
if (!Number.isFinite(yA) || !Number.isFinite(yB)) continue;
|
||||
if (kind === 'UP' && yB > yA) return { indexA: swingA, yA, indexB: swingB, yB };
|
||||
if (kind === 'DOWN' && yB < yA) return { indexA: swingA, yA, indexB: swingB, yB };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lineAt(p: PivotPair, evalIndex: number): number {
|
||||
if (p.indexB === p.indexA) return p.yA;
|
||||
const t = (evalIndex - p.indexA) / (p.indexB - p.indexA);
|
||||
return p.yA + (p.yB - p.yA) * t;
|
||||
}
|
||||
|
||||
function twoPointAt(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
evalIndex: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
|
||||
const y1 = yAtPivot(bars, sourceValues, end, kind, priceScale);
|
||||
if (!Number.isFinite(y0) || !Number.isFinite(y1) || end === start) return NaN;
|
||||
const t = (evalIndex - start) / (end - start);
|
||||
return y0 + (y1 - y0) * t;
|
||||
}
|
||||
|
||||
function regressionAt(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
evalIndex: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumXY = 0;
|
||||
let sumXX = 0;
|
||||
let count = 0;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const y = yAtPivot(bars, sourceValues, i, kind, priceScale);
|
||||
if (!Number.isFinite(y)) continue;
|
||||
const x = i - start;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumXX += x * x;
|
||||
count++;
|
||||
}
|
||||
if (count < 2) return NaN;
|
||||
const denom = count * sumXX - sumX * sumX;
|
||||
if (Math.abs(denom) < 1e-12) return NaN;
|
||||
const b = (count * sumXY - sumX * sumY) / denom;
|
||||
const a = (sumY - b * sumX) / count;
|
||||
return a + b * (evalIndex - start);
|
||||
}
|
||||
|
||||
function fallbackValueAt(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
evalIndex: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
if (config.mode === 'SWING') return NaN;
|
||||
if (config.mode === 'REGRESSION') {
|
||||
return regressionAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
return twoPointAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
|
||||
export function trendLineValueAtEval(
|
||||
sourceValues: number[],
|
||||
bars: { low: number; high: number }[],
|
||||
anchorIndex: number,
|
||||
evalIndex: number,
|
||||
lookback: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
if (anchorIndex < 1 || lookback < 2) return NaN;
|
||||
const start = anchorIndex - lookback;
|
||||
const end = anchorIndex - 1;
|
||||
if (start < 0 || end < start) return NaN;
|
||||
|
||||
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
|
||||
if (pivots) return lineAt(pivots, evalIndex);
|
||||
return fallbackValueAt(bars, sourceValues, start, end, evalIndex, config, kind, priceScale);
|
||||
}
|
||||
|
||||
export function trendLineSegmentAt(
|
||||
barTimes: number[],
|
||||
sourceValues: number[],
|
||||
bars: { low: number; high: number }[],
|
||||
index: number,
|
||||
lookback: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): TrendLineSegment | null {
|
||||
if (index < 1 || lookback < 2 || sourceValues.length !== barTimes.length) return null;
|
||||
const start = index - lookback;
|
||||
const end = index - 1;
|
||||
if (start < 0 || end < start) return null;
|
||||
|
||||
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
|
||||
if (pivots) {
|
||||
const yAt = lineAt(pivots, index);
|
||||
if (!Number.isFinite(yAt)) return null;
|
||||
return {
|
||||
startTimeSec: barTimes[pivots.indexA]!,
|
||||
startPrice: pivots.yA,
|
||||
endTimeSec: barTimes[index]!,
|
||||
endPrice: yAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (config.mode === 'SWING') return null;
|
||||
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
|
||||
const yAt = fallbackValueAt(bars, sourceValues, start, end, index, config, kind, priceScale);
|
||||
if (!Number.isFinite(y0) || !Number.isFinite(yAt)) return null;
|
||||
return {
|
||||
startTimeSec: barTimes[start]!,
|
||||
startPrice: y0,
|
||||
endTimeSec: barTimes[index]!,
|
||||
endPrice: yAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 전략편집기 — 추세선 돌파 조건 팔레트
|
||||
*/
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import {
|
||||
applyCondTypeDefaults,
|
||||
genId,
|
||||
getDefaultConditionFields,
|
||||
type DefType,
|
||||
} from './strategyEditorShared';
|
||||
import { initConditionPeriodsInherit } from './conditionPeriods';
|
||||
|
||||
export interface TrendLinePaletteItem {
|
||||
id: string;
|
||||
indicatorType: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
leftField?: string;
|
||||
}
|
||||
|
||||
export const TREND_LINE_PALETTE_ITEMS: TrendLinePaletteItem[] = [
|
||||
{ id: 'tl-close', indicatorType: 'MA', label: '종가', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
|
||||
{ id: 'tl-ma', indicatorType: 'MA', label: 'MA', desc: '이동평균 vs N봉 추세선', leftField: 'MA20' },
|
||||
{ id: 'tl-ema', indicatorType: 'EMA', label: 'EMA', desc: '지수이동평균 vs N봉 추세선', leftField: 'EMA20' },
|
||||
{ id: 'tl-boll', indicatorType: 'BOLLINGER', label: '볼린저', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
|
||||
{ id: 'tl-rsi', indicatorType: 'RSI', label: 'RSI', desc: 'RSI vs N봉 추세선' },
|
||||
{ id: 'tl-macd', indicatorType: 'MACD', label: 'MACD', desc: 'MACD선 vs N봉 추세선', leftField: 'MACD_LINE' },
|
||||
{ id: 'tl-stoch', indicatorType: 'STOCHASTIC', label: 'Stochastic', desc: '%K vs N봉 추세선', leftField: 'K' },
|
||||
{ id: 'tl-cci', indicatorType: 'CCI', label: 'CCI', desc: 'CCI vs N봉 추세선' },
|
||||
{ id: 'tl-adx', indicatorType: 'ADX', label: 'ADX', desc: 'ADX vs N봉 추세선' },
|
||||
{ id: 'tl-obv', indicatorType: 'OBV', label: 'OBV', desc: 'OBV vs N봉 추세선' },
|
||||
];
|
||||
|
||||
export function findTrendLinePaletteItem(id: string): TrendLinePaletteItem | undefined {
|
||||
return TREND_LINE_PALETTE_ITEMS.find(i => i.id === id);
|
||||
}
|
||||
|
||||
export function filterTrendLinePaletteItems(query: string): TrendLinePaletteItem[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return TREND_LINE_PALETTE_ITEMS;
|
||||
return TREND_LINE_PALETTE_ITEMS.filter(
|
||||
i => i.label.toLowerCase().includes(q)
|
||||
|| i.desc.toLowerCase().includes(q)
|
||||
|| i.indicatorType.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTrendLineConditionNode(
|
||||
item: Pick<TrendLinePaletteItem, 'indicatorType' | 'leftField'>,
|
||||
signalType: 'buy' | 'sell',
|
||||
def: DefType,
|
||||
lookback = 10,
|
||||
): LogicNode {
|
||||
const defaults = getDefaultConditionFields(item.indicatorType, signalType, def);
|
||||
const condType = signalType === 'buy' ? 'TREND_LINE_CROSS_UP' : 'TREND_LINE_CROSS_DOWN';
|
||||
let condition = applyCondTypeDefaults(
|
||||
{
|
||||
indicatorType: item.indicatorType,
|
||||
conditionType: condType,
|
||||
leftField: item.leftField ?? defaults.l,
|
||||
rightField: 'NONE',
|
||||
lookbackPeriod: Math.max(2, lookback),
|
||||
candleRange: 1,
|
||||
valuePeriodOverride: false,
|
||||
thresholdOverride: false,
|
||||
rightPeriodOverride: false,
|
||||
},
|
||||
condType,
|
||||
def,
|
||||
);
|
||||
condition = initConditionPeriodsInherit(item.indicatorType, condition, def);
|
||||
return { id: genId(), type: 'CONDITION', condition };
|
||||
}
|
||||
|
||||
export function buildTrendLineConditionNodeFromDrag(data: {
|
||||
value: string;
|
||||
indicatorType?: string;
|
||||
leftField?: string;
|
||||
lookback?: number;
|
||||
}, signalType: 'buy' | 'sell', def: DefType): LogicNode | null {
|
||||
const item = findTrendLinePaletteItem(data.value);
|
||||
const indicatorType = item?.indicatorType ?? data.indicatorType;
|
||||
if (!indicatorType) return null;
|
||||
return buildTrendLineConditionNode(
|
||||
{ indicatorType, leftField: item?.leftField ?? data.leftField },
|
||||
signalType,
|
||||
def,
|
||||
data.lookback ?? 10,
|
||||
);
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
# 드로잉선 → 전략 조건 검토
|
||||
|
||||
실시간 차트 **드로잉 툴바**(TradingView 스타일)에 제공되는 선·채널·피보나치 등 도구 중,
|
||||
현재 구현된 **추세선 돌파(`TREND_LINE_CROSS_*`)** 와 같이 **봉마다 자동 계산 → Ta4j Rule → 실시간·전략평가 시그널**로 확장 가능한 항목을 정리한 문서입니다.
|
||||
|
||||
> **관련:** [매매전략조건.md](./매매전략조건.md) · [전략편집기 로직 및 동작흐름.md](./전략편집기%20로직%20및%20동작흐름.md)
|
||||
> **코드 기준:** `DrawingToolbar.tsx` · `DrawingToolId` · `StrategyDslToTa4jAdapter` · `TrendLineMath`
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 추세선 전략 조건 (기준 구현)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| DSL | `TREND_LINE_CROSS_UP` / `TREND_LINE_CROSS_DOWN` |
|
||||
| 드로잉 대응 | **추세줄**(`trendline`) · **익스텐디드 라인**(`extended_line`)과 개념적으로 동일 (2점 직선 + 연장) |
|
||||
| 계산 | fit 구간 `[i−N, i−1]` · **스윙 피벗** (상승=저점↑ / 하락=고점↓) · 시그널 봉 `i`까지 연장 |
|
||||
| 판정 | 직전 봉 ≤ 추세선 → 현재 봉 > 추세선 (상향) / 반대 (하향) |
|
||||
| 설정 | 설정 → 전략 설정 → 추세선 (모드·N·스윙 k) |
|
||||
| 파이프라인 | `StrategyDslToTa4jAdapter` → `BacktestingService` · `LiveConditionStatusService` · 전략평가 차트 |
|
||||
|
||||
**핵심 패턴:** 차트에 사용자가 그리는 **고정 좌표**가 아니라, 매 봉 `i`마다 **Indicator가 Y값(또는 시간)을 반환**하고, 기존 `CROSS_UP`/`CROSS_DOWN` Rule과 결합합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 드로잉 툴바 선·채널 도구 목록
|
||||
|
||||
`DrawingToolbar` **라인 / 피보나치 / 채널 / 프로젝션** 그룹 기준 (측정·도형·텍스트·패턴 제외).
|
||||
|
||||
### 2.1 라인 그룹
|
||||
|
||||
| ID | UI 이름 | 점 수 | 기하 |
|
||||
|----|---------|-------|------|
|
||||
| `trendline` | 추세줄 | 2 | 2점 직선, **양방향** 연장 |
|
||||
| `ray` | 빛(레이) | 2 | 2점 직선, **우측** 연장 |
|
||||
| `extended_line` | 익스텐디드 라인 | 2 | 2점 직선, **양방향** 연장 (`trendline`과 동일 렌더) |
|
||||
| `arrow` | 화살표 추세선 | 2 | 레이 + 화살표 |
|
||||
| `trend_angle` | 추세각 | 2 | 추세선 + 각도 표시 |
|
||||
| `info_line` | 인포 라인 | 2 | 2점 + 거리·각도 라벨 |
|
||||
| `hline` | 가로줄 | 1 | **수평선** (가격 고정) |
|
||||
| `hline_ray` | 가로빛 | 1 | 앵커 시점부터 **우측** 수평 |
|
||||
| `vline` | 수직선 | 1 | **수직선** (시간 고정) |
|
||||
| `cross_line` | 크로스 라인 | 1 | 수평+수직 교차 |
|
||||
| `channel` | 추세 채널 | 3 | 기준 추세선 + 폭 |
|
||||
| `parallel_channel` | 패러렐 채널 | 2 | 평행 2선 |
|
||||
| `disjoint_channel` | 디스조인트 채널 | 4 | 비평행 2선 |
|
||||
| `flat_top_bottom` | 플랫 탑/바텀 | 2+ | 상단 수평 + 하단 추세 |
|
||||
|
||||
### 2.2 피보나치·갠·피치포크
|
||||
|
||||
| ID | UI 이름 | 산출물 |
|
||||
|----|---------|--------|
|
||||
| `fib` | 피보나치 되돌림 | 2점 구간 **수평 레벨** 다수 (0, 23.6, …, 100%) |
|
||||
| `fib_ext` | 추세기반 피보나치 확장 | 되돌림 + **확장** 수평 레벨 |
|
||||
| `fib_channel` | 피보나치 채널 | 기울어진 채널 + 피보나치 분할 |
|
||||
| `fibtz` | 피보나치 타임존 | 2점 시간 구간 **수직선** |
|
||||
| `fib_speed_fan` | 스피드 리지스턴스 팬 | 1점에서 **다발 레이** |
|
||||
| `fib_trend_time` | 추세기반 피보나치 시간 | 추세선 + 시간축 피보나치 |
|
||||
| `fib_circles` / `fib_spiral` / `fib_speed_arc` / `fib_wedge` | 원·스파이럴·아크·웻지 | 곡선/영역 |
|
||||
| `pitchfork` / `pitchfork_schiff` / `pitchfork_inside` | 피치포크 | 3점 → **중심선 + 평행 2선** |
|
||||
| `gann_box` / `gann_square` / `gann_fan` | 갠 | 격자·팬 **다발 직선** |
|
||||
|
||||
### 2.3 프로젝션·기타 (선 성격)
|
||||
|
||||
| ID | UI 이름 | 비고 |
|
||||
|----|---------|------|
|
||||
| `anchored_vwap` | 앵커드 VWAP | 앵커 시점부터 **누적 VWAP 곡선** |
|
||||
| `price_range` | 가격범위 | 구간 **상·하단 수평** |
|
||||
| `date_range` | 날짜 범위 | **수직** 구간 |
|
||||
| `guide_line` | 가이드선 | 보조 수평 (차트 UI) |
|
||||
| `forecast` / `projection_tool` | 예측·프로젝션 | 2점+ **연장** |
|
||||
|
||||
---
|
||||
|
||||
## 3. 전략 조건화 평가 기준
|
||||
|
||||
드로잉 도구를 **실시간 매매 시그널**로 쓰려면 아래가 필요합니다.
|
||||
|
||||
| # | 요건 | 설명 |
|
||||
|---|------|------|
|
||||
| 1 | **봉 인덱스 결정론** | 사용자 클릭 없이 OHLCV·지표만으로 매 봉 동일 Y(또는 시간) 계산 |
|
||||
| 2 | **교차·돌파 정의** | `CROSS_UP`/`DOWN` 또는 `GT`/`LT`로 표현 가능 |
|
||||
| 3 | **Ta4j Indicator** | `getValue(i)` 또는 Rule 한 개로 encapsulation |
|
||||
| 4 | **워밍업·NaN** | lookback·스윙 k만큼 불안정 구간 처리 |
|
||||
| 5 | **멀티 TF·좌필드** | 종가/RSI 등 left 지표와 스케일 일치 |
|
||||
|
||||
**등급**
|
||||
|
||||
- **A — 즉시 확장 권장:** 추세선과 동일 패턴(동적 1~2선 + CROSS), 구현 난이도 중, 실전 활용도 높음
|
||||
- **B — 부분 가능 / DSL已有:** 수평·채널·고저가 등 **이미 다른 필드로 구현**됐거나, 파라미터만 추가하면 됨
|
||||
- **C — 전략 자동화 부적합:** 주관적 패턴·다점 수동 입력·시간만 조건·시각 주석 위주
|
||||
|
||||
---
|
||||
|
||||
## 4. 요약 매트릭스
|
||||
|
||||
| 드로잉 도구 | 등급 | 전략 활용 | 비고 |
|
||||
|-------------|------|-----------|------|
|
||||
| **추세줄 / 익스텐디드 / 레이** | **A** | ✅ 구현됨 (`TREND_LINE_*`) | 레이=연장 방향만 다름 → `extendMode` 파라미터로 통합 가능 |
|
||||
| **가로줄** | **B** | ✅ 대체 가능 | `CROSS_*` + 고정값·`NH_PRIOR_N`·볼린저·MA |
|
||||
| **가로빛** | **A** | ⚠️ 미구현 | 앵커 이후 구간 수평 = **구간 고점/저점** 또는 **앵커 VWAP** |
|
||||
| **수직선 / 크로스 / 피보나치 TZ / 날짜범위** | **B~C** | △ 시간 조건 | 가격 돌파가 아닌 **봉 번호·세션·시간** 조건 (`TIMEFRAME`·`HOLD` 확장) |
|
||||
| **추세·패러렐·플랫탑 채널** | **A** | ⚠️ 미구현 | **채널 상·하단 돌파** — Donchian과 유사하나 **기울기** 있음 |
|
||||
| **디스조인트 채널** | **B** | △ | 4점 자유도 높음 → 자동화 시 규칙 고정 필요 |
|
||||
| **피보나치 되돌림/확장** | **A** | ⚠️ 미구현 | 스윙 고저 + **수평 레벨** 돌파 (0.382, 0.5, 0.618…) |
|
||||
| **피치포크 (중심선)** | **A** | ⚠️ 미구현 | 3 스윙점 → **중앙 추세선** 돌파 (추세선 일반화) |
|
||||
| **피보나치 팬 / 갠 팬** | **B** | △ | 다중 레이 — **특정 각도 레이** 돌파로 축소 가능 |
|
||||
| **앵커드 VWAP** | **B** | △ | VWAP 지표 + **앵커 lookback** 조건 (별도 지표 DSL) |
|
||||
| **가격범위** | **A** | ⚠️ 미구현 | N봉 **Range 상·하단** 돌파 (Donchian과 동일 family) |
|
||||
| **추세각 / 인포 / 화살표** | **C** | ✗ | 표시·측정용, 시그널 의미 없음 |
|
||||
| **패턴(XABCD·H&S·엘리엇)** | **C** | ✗ | 형태 인식·주관적 — 별도 패턴 엔진 필요 |
|
||||
| **원·호·브러시·텍스트** | **C** | ✗ | 시각 주석 |
|
||||
|
||||
---
|
||||
|
||||
## 5. A등급 — 구현 권장 (추세선과 동일 아키텍처)
|
||||
|
||||
### 5.1 레이 / 익스텐디드 (`ray`, `extended_line`)
|
||||
|
||||
**드로잉 의미:** 2점 직선, 연장 방향만 다름 (우측 only vs 양방향).
|
||||
|
||||
**전략 정의 (제안):**
|
||||
|
||||
- 기존 `TREND_LINE_*` + 파라미터 `extendMode: RIGHT | BOTH | LEFT`
|
||||
- fit·피벗 로직은 **현재 `TrendLineMath`와 동일**
|
||||
- `ray` = 피벗 선을 **평가 봉 i까지만** extrapolation (좌측 연장 없음)
|
||||
|
||||
**조건 예:**
|
||||
|
||||
| 조건 | 의미 |
|
||||
|------|------|
|
||||
| `TREND_LINE_CROSS_UP` + `extendMode=RIGHT` | 하락 저항 **레이** 상향 돌파 |
|
||||
| `TREND_LINE_CROSS_DOWN` | 상승 지지선 하향 이탈 |
|
||||
|
||||
**구현:** `TrendLineMath`에 연장 방향만 추가 — **난이도: 낮음**.
|
||||
|
||||
---
|
||||
|
||||
### 5.2 추세 채널 / 패러렐 채널 (`channel`, `parallel_channel`)
|
||||
|
||||
**드로잉 의미:** 평행한 **상·하 2선**; 가격이 채널 밖으로 나가면 추세 가속·전환.
|
||||
|
||||
**전략 정의 (제안):**
|
||||
|
||||
| DSL (제안) | 계산 | 매수·매도 |
|
||||
|------------|------|-----------|
|
||||
| `CHANNEL_BREAK_UP` | 상단선 = 기준 추세선 + offset (또는 2번째 피벗 평행) | 종가 **상단** 상향 돌파 |
|
||||
| `CHANNEL_BREAK_DOWN` | 하단선 = 기준 추세선 − offset | 종가 **하단** 하향 돌파 |
|
||||
| `CHANNEL_BOUNCE_UP` | 하단선 터치 후 반등 | `CROSS_UP` on lower band |
|
||||
|
||||
**알고리즘 (패러렐 채널 자동화):**
|
||||
|
||||
1. `[i−N, i−1]`에서 **상승 추세선(저점)** 또는 **하락 추세선(고점)** 1본 계산 (현행과 동일)
|
||||
2. offset = 구간 내 **최대 High − 추세선값** (상단 채널) 또는 **추세선값 − 최소 Low** (하단)
|
||||
3. 또는 **스윙 고점 2개 + 스윙 저점 2개**로 평행선 쌍 결정
|
||||
|
||||
**Ta4j:** `UpperChannelIndicator` / `LowerChannelIndicator` (커스텀) + `CROSS_*`.
|
||||
|
||||
**기존 DSL과 관계:** `DONCHIAN` `DC_UPPER_N` / `DC_LOWER_N`은 **수평 채널**(기울기 0). 본 조건은 **기울어진 채널** — 차별화됨.
|
||||
|
||||
**난이도: 중.**
|
||||
|
||||
---
|
||||
|
||||
### 5.3 플랫 탑/바텀 (`flat_top_bottom`)
|
||||
|
||||
**드로잉 의미:** 한쪽은 **수평**(저항/지지), 다른 쪽은 **기울어진** 변 연결 — 삼각 수렴·확장 패턴의 일부.
|
||||
|
||||
**전략 정의 (제안):**
|
||||
|
||||
| DSL (제안) | 구성 |
|
||||
|------------|------|
|
||||
| `FLAT_TOP_BREAK_UP` | `[i−N,i−1]` **최고가 수평선** (flat top) 상향 돌파 |
|
||||
| `FLAT_BOTTOM_BREAK_DOWN` | **최저가 수평선** (flat bottom) 하향 돌파 |
|
||||
| `WEDGE_BREAK_*` | flat + 대각 추세선 **수렴 후** 돌파 (확장) |
|
||||
|
||||
flat top = `NH_PRIOR` 구간 max(High)와 유사하나 **동일 구간** 명시.
|
||||
**난이도: 중 (채널 특수 케이스).**
|
||||
|
||||
---
|
||||
|
||||
### 5.4 피보나치 되돌림 / 확장 (`fib`, `fib_ext`)
|
||||
|
||||
**드로잉 의미:** 스윙 A→B 구간에 **비율별 수평선** (0%, 38.2%, 50%, 61.8%, 100% …).
|
||||
|
||||
**전략 정의 (제안):**
|
||||
|
||||
```
|
||||
FIB_LEVEL_CROSS_UP / DOWN
|
||||
- swingHigh, swingLow: [i−N, i−1] 스윙 (또는 구간 고/저)
|
||||
- level: 0.382 | 0.5 | 0.618 | 0.786 | 1.272 (확장)
|
||||
- left: CLOSE_PRICE (또는 지표)
|
||||
```
|
||||
|
||||
| 활용 | 조건 |
|
||||
|------|------|
|
||||
| 되돌림 매수 | 61.8% **지지** 후 `CROSS_UP` |
|
||||
| 되돌림 매도 | 38.2% **저항** 후 `CROSS_DOWN` |
|
||||
| 확장 목표 | 1.272 / 1.618 **돌파** 추적 |
|
||||
|
||||
**Indicator:** `FibLevelIndicator(swingA, swingB, ratio)` → bar i에서 해당 레벨 Y.
|
||||
|
||||
**차트 표시:** 전략평가 시 선택 봉 기준 **해당 레벨 1개** 또는 **주요 3선**만 overlay (추세선과 동일 UX).
|
||||
|
||||
**난이도: 중.**
|
||||
|
||||
---
|
||||
|
||||
### 5.5 피치포크 중심선 (`pitchfork` 계열)
|
||||
|
||||
**드로잉 의미:** 3점(스윙1, 스윙2, 스윙3) → **중앙 추세선** + 위·아래 평행선.
|
||||
|
||||
**전략 정의 (제안):**
|
||||
|
||||
| DSL (제안) | 의미 |
|
||||
|------------|------|
|
||||
| `PITCHFORK_MEDIAN_CROSS_UP/DOWN` | **중심선** 돌파 |
|
||||
| `PITCHFORK_UPPER_TOUCH` | 상단 평행선 도달 |
|
||||
| `PITCHFORK_LOWER_BOUNCE` | 하단 평행선 반등 |
|
||||
|
||||
**알고리즘:** 3 스윙 저점(상승) 또는 고점(하락) → 중점·기울기 → median line at i.
|
||||
Andrews / Schiff / Inside는 **중심선 계산식**만 다름 — 설정에서 `pitchforkVariant`.
|
||||
|
||||
**난이도: 중~상.**
|
||||
|
||||
---
|
||||
|
||||
### 5.6 N봉 가격 레인지 (`price_range` 드로잉 ↔ Range 돌파)
|
||||
|
||||
**드로잉 의미:** 구간 **상단·하단** 박스.
|
||||
|
||||
**전략 정의:** Donchian과 동일 family.
|
||||
|
||||
| 기존 DSL | 드로잉 대응 |
|
||||
|----------|-------------|
|
||||
| `CLOSE CROSS_UP NH_PRIOR_20` | 20봉 **신고가(상단)** 돌파 |
|
||||
| `CLOSE CROSS_DOWN NL_PRIOR_20` | 20봉 **신저가(하단)** 이탈 |
|
||||
| `DONCHIAN` + `DC_UPPER_N` | 채널 상단 |
|
||||
|
||||
**추가 제안:** `RANGE_BREAK_UP/DOWN` — lookback N 명시적 alias (UI는 **가격범위** 드로잉과 매핑).
|
||||
|
||||
**난이도: 낮음 (이미 80% 구현).**
|
||||
|
||||
---
|
||||
|
||||
## 6. B등급 — 부분 가능 / 기존 DSL로 커버
|
||||
|
||||
### 6.1 가로줄 (`hline`)
|
||||
|
||||
| 드로잉 | 전략 DSL 대체 |
|
||||
|--------|----------------|
|
||||
| 임의 가격 수평선 | `GT`/`LT`/`CROSS_*` + `compareValue` 또는 지표 임계 |
|
||||
| N봉 고가/저가 | `NH_PRIOR_N` / `NL_PRIOR_N` · `DC_UPPER_N` / `DC_LOWER_N` |
|
||||
| 볼린저 상·중·하 | `BOLLINGER` + `UPPER`/`MIDDLE`/`LOWER` |
|
||||
| 전고·전저 | `NEW_HIGH` / `NEW_LOW` 지표 |
|
||||
|
||||
**신규 DSL 필요성:** 낮음. 전략편집기 **「가로줄 = 수준 돌파」** 프리셋 UI만 추가해도 충분.
|
||||
|
||||
---
|
||||
|
||||
### 6.2 가로빛 (`hline_ray`)
|
||||
|
||||
**의미:** 특정 **시점 이후**에만 유효한 수평선 (예: 돌파봉 종가, 당일 시가).
|
||||
|
||||
**제안:**
|
||||
|
||||
| DSL (제안) | 정의 |
|
||||
|------------|------|
|
||||
| `SESSION_OPEN` | 당일/세션 시가 (수평) |
|
||||
| `BAR_N_CLOSE` | N봉 전 종가 수평 (앵커) |
|
||||
| `SWING_LEVEL` | 마지막 스윙 고/저 **수평 레이** |
|
||||
|
||||
**난이도: 중** (앵커 bar index 추적).
|
||||
|
||||
---
|
||||
|
||||
### 6.3 앵커드 VWAP (`anchored_vwap`)
|
||||
|
||||
**의미:** 특정 봉부터 누적 VWAP.
|
||||
|
||||
**전략:** `VWAP` 지표(또는 신규 `ANCHORED_VWAP`) + `CROSS_UP/DOWN`.
|
||||
앵커 = `i−anchorLookback` 또는 **세션 시작**.
|
||||
|
||||
**난이도: 중** (볼륨·누적 합).
|
||||
|
||||
---
|
||||
|
||||
### 6.4 피보나치 팬 / 갠 팬 (`fib_speed_fan`, `gann_fan`)
|
||||
|
||||
**의미:** 1점에서 **여러 각도**의 레이.
|
||||
|
||||
**전략 축소:** 팬 전체가 아닌 **특정 비율 1선**만 — 예: 1×1, 2×1 갠 각도 = 기울기 `k`인 **동적 추세선**.
|
||||
|
||||
| 제안 | 조건 |
|
||||
|------|------|
|
||||
| `GANN_FAN_CROSS` | 스윙 앵커 + `slopeRatio` (1/1, 1/2…) 레이 돌파 |
|
||||
|
||||
**난이도: 중** (추세선 + 고정 기울기).
|
||||
|
||||
---
|
||||
|
||||
### 6.5 수직선·시간 (`vline`, `fibtz`, `date_range`)
|
||||
|
||||
**의미:** **가격**이 아니라 **시간/봉** 조건.
|
||||
|
||||
| 활용 | DSL 방향 |
|
||||
|------|----------|
|
||||
| 특정 시각 이후만 매수 | `TIMEFRAME` + 세션 필터 |
|
||||
| N봉 경과 후 | `HOLD_N_DAYS` · `BAR_INDEX ≥ anchor` |
|
||||
| 피보나치 시간대 | `TIME_FIB_ZONE` (i가 [t0+φⁿ·Δt] 근처) |
|
||||
|
||||
실시간 **가격 돌파** 시그널과는 별도 — **필터/타이밍** 조건으로 AND 결합.
|
||||
|
||||
**난이도: 중** (가격 Indicator와 분리).
|
||||
|
||||
---
|
||||
|
||||
## 7. C등급 — 자동 전략 조건 부적합
|
||||
|
||||
| 유형 | 이유 |
|
||||
|------|------|
|
||||
| **추세각·인포라인·화살표** | 측정·주석; Y(t) 단일 함수 아님 |
|
||||
| **harmonic 패턴** (XABCD, ABCD, Cypher, H&S) | 4~5점 **형태 매칭** — ML/규칙 트리 별도 |
|
||||
| **엘리엇 파동** 드로잉 | 파동 카운트 주관적; ta4j Elliott는 분석용 |
|
||||
| **고스트피드·봉패턴** | 미래 봉 투영 — 백테스트 **룩어헤드** 위험 |
|
||||
| **long/short position** 박스 | RR 시각화; 진입·손절은 **별도 주문 DSL** |
|
||||
| **브러시·텍스트·핀** | 사용자 주석 |
|
||||
| **원·타원·삼각형** | 영역 포함 판정 — `IN_RECT`류는 가능하나 드로잉과 무관 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 공통 구현 패턴 (추세선과 동일)
|
||||
|
||||
신규 드로잉型 조건 추가 시 **권장 스택:**
|
||||
|
||||
```
|
||||
1. DSL conditionType (예: CHANNEL_BREAK_UP)
|
||||
2. Math/Indicator (bar i, lookback, config → Y 또는 time)
|
||||
3. StrategyDslToTa4jAdapter → buildXxxCrossRule (고정 창 [i−N,i−1])
|
||||
4. BacktestSettings (전역 default N, k, ratio…)
|
||||
5. 전략편집기 팔레트 (추세 탭과 동일 PaletteChip UX)
|
||||
6. 전략평가 차트 (선택 봉 1개 overlay only)
|
||||
7. LiveConditionStatusService 라벨
|
||||
```
|
||||
|
||||
**교차 Rule 템플릿 (현행 추세선):**
|
||||
|
||||
```text
|
||||
직전 봉: left[i−1] ≤ line[i−1]
|
||||
현재 봉: left[i] > line[i] → CROSS_UP
|
||||
line[i±k]는 동일 fit 창 [i−N, i−1]에서 extrapolation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 구현 우선순위 (로드맵 제안)
|
||||
|
||||
| 순위 | 기능 | 드로잉 대응 | 이유 |
|
||||
|------|------|-------------|------|
|
||||
| 1 | `extendMode` on `TREND_LINE` | ray / extended | 코드 변경 최소 |
|
||||
| 2 | `CHANNEL_BREAK_*` | channel, parallel | 돈치안보다 실전 TA에 가까움 |
|
||||
| 3 | `FIB_LEVEL_CROSS_*` | fib, fib_ext | 되돌림·확장 매매 빈도 높음 |
|
||||
| 4 | `FLAT_TOP/BOTTOM_*` | flat_top_bottom | 삼각·깃발 패턴 기초 |
|
||||
| 5 | `PITCHFORK_MEDIAN_*` | pitchfork | 중심선 트레이딩 |
|
||||
| 6 | 가로줄 프리셋 UI | hline | 신규 엔진 없이 DSL 노출 |
|
||||
| 7 | `ANCHORED_VWAP_CROSS` | anchored_vwap | VWAP 전략 수요 |
|
||||
| 8 | 시간 조건 (`TIME_*`) | vline, fibtz | 필터로 활용 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 실시간 매매 체크 연동
|
||||
|
||||
현재 파이프라인은 **드로잉 객체(JSON)를 읽지 않습니다.**
|
||||
차트에 사용자가 그린 선과 **전략 시그널은 독립**이며, 전략은 **동일 수학 규칙**으로 매 봉 재계산합니다.
|
||||
|
||||
| 단계 | 서비스 |
|
||||
|------|--------|
|
||||
| 설정 로드 | `BacktestSettingsService` → `TrendLineConfig` |
|
||||
| Rule 생성 | `StrategyDslToTa4jAdapter.toRule` |
|
||||
| 백테스트·스캔 | `BacktestingService` |
|
||||
| 실시간 | `LiveConditionStatusService` (조건별 충족/미충족) |
|
||||
| UI | `StrategyEvaluationChart` · 가상/실전 차트 시그널 마커 |
|
||||
|
||||
신규 선 조건도 **드로잉 저장소와 연동할 필요 없음** — 추세선과 같이 **Indicator 기반**이면 실시간 체크에 바로 투입 가능.
|
||||
|
||||
---
|
||||
|
||||
## 11. 한 줄 결론
|
||||
|
||||
> **추세선(`TREND_LINE`)과 같이 “매 봉 Y값을 계산하는 1~2차원 직선·수평 레벨”** 이면 대부분 `CROSS_*` Rule로 실시간 전략에 넣을 수 있다.
|
||||
> **우선순위는 채널(평행선)·피보나치 수준·레이 연장 방향**이며, **가로줄·Donchian·볼린저**는 이미 DSL로 커버된다.
|
||||
> **패턴·팬 전체·브러시**는 드로잉 툴로는 유용하나 **자동 시그널**에는 부적합하거나 별도 엔진이 필요하다.
|
||||
|
||||
---
|
||||
|
||||
*문서 버전: 2026-06 — goldenChart 드로잉 툴바·TREND_LINE 구현 기준*
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
# 전략편집기 — 우측 패널 지표 컨트롤 ↔ 매매 시그널 감지
|
||||
|
||||
전략편집기 우측 **지표** 패널에 있는 컨트롤(칩)을 클릭·드래그했을 때 생성되는 DSL 조건이,
|
||||
실제 차트·백테스트·실시간 전략 평가에서 **어떻게 매수/매도 시그널로 판정되는지**를 정리한 문서입니다.
|
||||
|
||||
> **평가 원천:** `StrategyDslToTa4jAdapter` → Ta4j `Rule` → 봉 `i`에서 `rule.isSatisfied(i)`
|
||||
> **관련:** [매매전략조건.md](./매매전략조건.md) · [드로잉선_전략조건.md](./드로잉선_전략조건.md) · [전략편집기 로직 및 동작흐름.md](./전략편집기%20로직%20및%20동작흐름.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 흐름
|
||||
|
||||
```text
|
||||
[우측 PaletteChip 클릭/드래그]
|
||||
↓
|
||||
makeNode / buildXxxConditionNode → LogicNode (CONDITION 또는 AND/OR/NOT 트리)
|
||||
↓
|
||||
전략 저장 (buyConditionJson / sellConditionJson)
|
||||
↓
|
||||
StrategyDslToTa4jAdapter.toRule() — leaf CONDITION → Ta4j Rule
|
||||
↓
|
||||
봉 i마다 isSatisfied(i) — true면 해당 봉에 BUY 또는 SELL 시그널
|
||||
↓
|
||||
전략평가 차트 마커 · LiveConditionStatusService · BacktestingService
|
||||
```
|
||||
|
||||
### 1.1 매수/매도 탭과 시그널 방향
|
||||
|
||||
| 편집기 탭 | DSL 저장 위치 | 시그널 타입 |
|
||||
|-----------|---------------|-------------|
|
||||
| **매수** (`buySellTab = buy`) | `buyConditionJson` | `BUY` |
|
||||
| **매도** (`buySellTab = sell`) | `sellConditionJson` | `SELL` |
|
||||
|
||||
같은 칩이라도 **어느 탭에서 추가했는지**에 따라 `conditionType` 기본값이 달라집니다.
|
||||
예: 보조 RSI 칩 → 매수 탭 `CROSS_UP`, 매도 탭 `CROSS_DOWN`.
|
||||
|
||||
### 1.2 positionMode (설정 → 전략 설정)
|
||||
|
||||
| 모드 | 동작 |
|
||||
|------|------|
|
||||
| **SIGNAL_ONLY** | 매수·매도 Rule **독립** — 보유 여부와 무관하게 봉마다 시그널 |
|
||||
| **LONG_ONLY** | **미보유**일 때만 entry(BUY), **보유**일 때만 exit(SELL) — `TradingRecord` 추적 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 우측 패널 구조
|
||||
|
||||
**파일:** `StrategyEditorPage.tsx`, `StrategyEvaluationIndicatorPalettePanel.tsx`
|
||||
|
||||
| 영역 | 내용 |
|
||||
|------|------|
|
||||
| 검색 | `paletteSearch` — 라벨·설명 필터 |
|
||||
| **시작·논리** | START, AND, OR, NOT |
|
||||
| **밴드·추세** | MA, EMA, BOLLINGER, DONCHIAN, ICHIMOKU (고정 5칩) |
|
||||
| **지표 서브탭** | 보조지표 · 복합지표 · 횡보필터 · 추세 |
|
||||
| **템플릿 탭** | 내장·사용자 템플릿 (복합/횡보 항목 재노출) |
|
||||
|
||||
칩 추가 경로:
|
||||
|
||||
- **클릭** → `applyPalette` / `applySidewaysFilter` / `applyTrendLine`
|
||||
- **드래그** → `PaletteChip` → `StrategyEditorCanvas.resolvePaletteDropNode`
|
||||
|
||||
---
|
||||
|
||||
## 3. 논리 노드 (START / AND / OR / NOT / TIMEFRAME)
|
||||
|
||||
### 3.1 START
|
||||
|
||||
- 캔버스 **시작 섹션** 추가용. 조건 노드가 아님.
|
||||
- 실제 평가는 START 아래 **자식 AND/OR 트리**가 담당.
|
||||
|
||||
### 3.2 AND / OR
|
||||
|
||||
| 노드 | Ta4j | 시그널 조건 |
|
||||
|------|------|-------------|
|
||||
| **AND** | `AndRule(자식…)` | **모든** 자식 Rule이 같은 봉 `i`에서 true |
|
||||
| **OR** | `OrRule(자식…)` | **하나라도** true면 true |
|
||||
|
||||
빈 AND/OR → 항상 false (시그널 없음).
|
||||
|
||||
### 3.3 NOT
|
||||
|
||||
- **첫 번째 자식만** `NotRule`로 감쌈.
|
||||
- 예: 횡보필터 **exclude** 모드 → `NOT(횡보 조건)` = 추세 구간만 통과.
|
||||
|
||||
### 3.4 TIMEFRAME (분봉 분기)
|
||||
|
||||
```json
|
||||
{ "type": "TIMEFRAME", "candleTypes": ["1h"], "children": [ … ] }
|
||||
```
|
||||
|
||||
| 단계 | 동작 |
|
||||
|------|------|
|
||||
| 1 | `candleTypes[0]` 분봉으로 OHLCV **별도 BarSeries** 구성 |
|
||||
| 2 | 자식 Rule을 그 분봉에서 평가 |
|
||||
| 3 | 차트( primary ) 분봉 index `i`와 **시간 정렬** (`CrossSeriesRule`) |
|
||||
|
||||
**백테스트/전략평가:** primary 봉 시각에 맞는 **확정 분봉** index로 매핑.
|
||||
**실시간:** `LiveStrategyEvaluator` + `StrategyTriggerBranchEvaluator` — 트리거 분봉 마감 시 분기 평가, AND 다중 TF는 **캐시** 결합.
|
||||
|
||||
---
|
||||
|
||||
## 4. 공통 CONDITION 구조
|
||||
|
||||
```typescript
|
||||
interface ConditionDSL {
|
||||
indicatorType: string; // RSI, MA, TREND_LINE, …
|
||||
conditionType: string; // CROSS_UP, GT, TREND_LINE_CROSS_UP, …
|
||||
leftField?: string; // RSI_VALUE, CLOSE_PRICE, MA20, …
|
||||
rightField?: string; // HL_OVER, DC_UPPER_20, NONE, …
|
||||
period?: number;
|
||||
lookbackPeriod?: number; // 추세선 N, LOWEST N봉
|
||||
compareValue?: number; // DIFF_GT, VOLUME_GT_MA_RATIO
|
||||
slopePeriod?: number; // SLOPE_UP/DOWN
|
||||
holdDays?: number; // HOLD_N_DAYS
|
||||
candleRange?: number;
|
||||
candleRangeMode?: 'CURRENT' | 'EXISTS_IN' | 'NOT_EXISTS_IN';
|
||||
composite?: boolean;
|
||||
leftPeriod / rightPeriod / leftCandleType / rightCandleType;
|
||||
params?: Record<string, …>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 conditionType → 차트에서의 의미
|
||||
|
||||
| conditionType | 판정 방식 | 적용 봉 |
|
||||
|---------------|-----------|---------|
|
||||
| **GT / LT / GTE / LTE / EQ / NEQ** | `left[i]` vs `right[i]` **수준 비교** | 현재 봉 `i` |
|
||||
| **CROSS_UP** | `left[i−1] ≤ right[i−1]` **그리고** `left[i] > right[i]` | 교차 **1봉** |
|
||||
| **CROSS_DOWN** | `left[i−1] ≥ right[i−1]` **그리고** `left[i] < right[i]` | 교차 **1봉** |
|
||||
| **TREND_LINE_CROSS_UP/DOWN** | CROSS와 동일 형식, **right = N봉 피벗 추세선** (fit `[i−N,i−1]`) | 교차 1봉 |
|
||||
| **SLOPE_UP / SLOPE_DOWN** | `left`가 `slopePeriod`봉 **연속 상승/하락** | 현재 봉 |
|
||||
| **DIFF_GT / DIFF_LT** | `|left − right| >/< compareValue` | 현재 봉 |
|
||||
| **HOLD_N_DAYS** | `left > right`가 **N봉 연속** | 현재 봉 |
|
||||
| **LOWEST_LTE / LOWEST_GTE** | `min(left, i−N..i)` vs `right` | 현재 봉 |
|
||||
|
||||
**NaN·미확정:** left/right NaN → 해당 봉 false. `CROSS_*`는 `index < 1`이면 false.
|
||||
|
||||
### 4.2 leftField / rightField 해석
|
||||
|
||||
백엔드 `resolveField()`가 심볼 → Ta4j `Indicator`로 변환합니다.
|
||||
|
||||
| 심볼 예 | 의미 |
|
||||
|---------|------|
|
||||
| `CLOSE_PRICE` | 종가 |
|
||||
| `RSI_VALUE` | RSI 본선 (period는 지표 설정·조건 상속) |
|
||||
| `HL_OVER` / `HL_MID` / `HL_UNDER` | 과열/중립/침체 **임계값** (설정·지표별) |
|
||||
| `K_80`, `K_20` | Stoch 직접 입력 임계 |
|
||||
| `DC_UPPER_20` | 20봉 Donchian **상단** (직전 N봉 high max, 당일 제외 방식은 NH_PRIOR 참고) |
|
||||
| `NH_PRIOR_9` | **직전 9봉** 최고가 (당일 봉 **미포함**) |
|
||||
| `NL_PRIOR_9` | **직전 9봉** 최저가 |
|
||||
| `NONE` | 일목 구름·추세선 등 **단항** 조건 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 밴드·추세 (고정 5칩)
|
||||
|
||||
**출처:** `BAND_PALETTE_CHIP_ITEMS` (`strategyPaletteStorage.ts`)
|
||||
|
||||
### 5.1 이동평균 (MA)
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 기본 (매수) | `MA` · `CROSS_UP` · `MA1` vs `MA2` |
|
||||
| 기본 (매도) | `MA` · `CROSS_DOWN` · `MA1` vs `MA2` |
|
||||
| **시그널** | 단기 MA가 장기 MA **상향/하향 교차**한 **그 봉** |
|
||||
|
||||
MA1/MA2 기간 → 보조지표 설정 `period1`, `period2` (SMA 설정).
|
||||
|
||||
### 5.2 EMA
|
||||
|
||||
MA와 동일 구조. `EMA1` vs `EMA2`, `EMAIndicator` 사용.
|
||||
|
||||
### 5.3 볼린저밴드 (BOLLINGER)
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 기본 (매수) | `CLOSE_PRICE` `CROSS_UP` `UPPER_BAND` |
|
||||
| 기본 (매도) | `CLOSE_PRICE` `CROSS_DOWN` `LOWER_BAND` (편집기에서 변경 가능) |
|
||||
| **시그널** | 종가가 **밴드 경계**를 돌파한 봉 |
|
||||
|
||||
다른 조건: `GT`/`LT`로 밴드 **내부/외부** 체류 판정.
|
||||
|
||||
### 5.4 돈치안 (DONCHIAN)
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 기본 (매수) | `CLOSE_PRICE` `CROSS_UP` `DC_UPPER_20` |
|
||||
| 기본 (매도) | `CLOSE_PRICE` `CROSS_DOWN` `DC_LOWER_20` |
|
||||
| **시그널** | 종가가 N봉 채널 **상단/하단** 교차 |
|
||||
|
||||
`DC_UPPER_n` = Donchian 상단 Indicator. (신고가 `NH_PRIOR_n`과 계산 방식·당일 포함 여부 주의 — §7.4)
|
||||
|
||||
### 5.5 일목균형표 (ICHIMOKU)
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 기본 (매수) | `CONVERSION_LINE` `CROSS_UP` `BASE_LINE` |
|
||||
| 기본 (매도) | `CONVERSION_LINE` `CROSS_DOWN` `BASE_LINE` |
|
||||
| **시그널** | **전환선·기준선 골든/데드크로스** |
|
||||
|
||||
**추가 전용 conditionType** (left/right 자동 설정):
|
||||
|
||||
| conditionType | 시그널 의미 |
|
||||
|---------------|-------------|
|
||||
| `ABOVE_CLOUD` | 종가 > 구름 상단 |
|
||||
| `BELOW_CLOUD` | 종가 < 구름 하단 |
|
||||
| `IN_CLOUD` | 종가가 구름 **내부** |
|
||||
| `CLOUD_BREAK_UP` | 종가 **구름 상단** 상향 돌파 (Ta4j CrossedUp) |
|
||||
| `CLOUD_BREAK_DOWN` | 종가 **구름 하단** 하향 이탈 |
|
||||
| `SPAN1_CROSS_UP_SPAN2` | 선행1 **선행2** 상향 교차 |
|
||||
| `LAGGING_GT_PRICE` | 후행스팬 > **26봉 전** 종가 |
|
||||
| `LAGGING_ABOVE_PRIOR_CLOUD` | 후행 > **26봉 전** 구름 상단 |
|
||||
| `CHIKOU_CROSS_ABOVE_PRIOR_CLOUD` | 후행 **구름 상단** 1회 돌파 |
|
||||
|
||||
파라미터: 전환 9 · 기준 26 · 선행/후행 26 · 선행2 52 (설정 상속).
|
||||
|
||||
---
|
||||
|
||||
## 6. 보조지표 탭 (`auxiliary`)
|
||||
|
||||
**내장 17종** + 사용자 추가 (`DEFAULT_AUXILIARY_ITEMS`).
|
||||
단일 칩 → **CONDITION 1개** (`makeNode` → `getDefaultConditionFields`).
|
||||
|
||||
### 6.1 오실레이터 (RSI, Stochastic, CCI, Williams %R, BWI)
|
||||
|
||||
| 지표 | 기본 left | 기본 right | 기본 condition (매수) |
|
||||
|------|-----------|------------|------------------------|
|
||||
| RSI | `RSI_VALUE` | `HL_OVER` (과열) | `CROSS_UP` → 침체→과열 **교차** 아님, **과열선 돌파** |
|
||||
| Stochastic | `STOCH_K` | `STOCH_D` | `%K` vs `%D` 교차 |
|
||||
| CCI | `CCI_VALUE` | `CCI_SIGNAL` | CCI vs 시그널선 교차 |
|
||||
| Williams %R | `WILLIAMS_R_VALUE` | `HL_OVER` | 과매도 구간 **탈출** 등 (필드 변경 가능) |
|
||||
|
||||
**추가 (오실레이터 전용):**
|
||||
|
||||
| conditionType | 시그널 |
|
||||
|---------------|--------|
|
||||
| `LOWEST_LTE` | 최근 **N봉**(lookback, 기본 20) `left` **최저** ≤ 침체선 — “최근 N봉 안 침체 터치” |
|
||||
| `LOWEST_GTE` | 최근 N봉 최저 ≥ 임계 — 침체 이탈 유지 |
|
||||
|
||||
**실전 예 (Stoch 재매수 억제):** `AND( CROSS_UP 80, LOWEST_LTE 20 )` — 80 돌파 **且** 최근 20봉 내 20 이하 경험.
|
||||
|
||||
### 6.2 추세·거래량 (ADX, DMI, MACD, OBV, TRIX, Volume OSC, VR)
|
||||
|
||||
| 지표 | 기본 left → right | 전형적 시그널 |
|
||||
|------|-------------------|---------------|
|
||||
| ADX | `ADX_VALUE` → `HL_MID` | ADX > 25 추세 강도 |
|
||||
| DMI | `PDI` → `MDI` | +DI **−DI** 교차 |
|
||||
| MACD | `MACD_LINE` → `SIGNAL_LINE` | MACD **시그널** 골든크로스 |
|
||||
| OBV | `OBV_LINE` → `OBV_SIGNAL` | OBV vs OBV 시그널 |
|
||||
| TRIX | `TRIX_VALUE` → `TRIX_SIGNAL` | TRIX 교차 |
|
||||
| Volume OSC | `VOLUME_OSC_VALUE` → `HL_MID` | 거래량 OSC 중립 돌파 |
|
||||
| VR | `VR_VALUE` → `HL_OVER` | VR 과열 |
|
||||
|
||||
### 6.3 심리·이격 (DISPARITY, PSY*, INVEST_PSY)
|
||||
|
||||
- `DISPARITY{n}` vs `HL_MID` — 이격도 과열/침체.
|
||||
- 심리도 계열 — `PSY_VALUE` vs `HL_OVER` 등.
|
||||
|
||||
### 6.4 거래량 (VOLUME)
|
||||
|
||||
| conditionType | 시그널 |
|
||||
|---------------|--------|
|
||||
| `GT` / `CROSS_UP` | 거래량 vs `VOLUME_MA` |
|
||||
| **`VOLUME_GT_MA_RATIO`** | `volume[i] ≥ MA(length) × compareValue` (기본 MA 5, 비율 1.5) |
|
||||
|
||||
### 6.5 신고가 / 신저가 (NEW_HIGH / NEW_LOW)
|
||||
|
||||
| 항목 | 매수 (NEW_HIGH) | 매도 (NEW_LOW) |
|
||||
|------|-----------------|----------------|
|
||||
| 기본 | `CLOSE` `CROSS_UP` `NH_PRIOR_9` | `CLOSE` `CROSS_DOWN` `NL_PRIOR_9` |
|
||||
| **right 의미** | **직전 9봉** 최고가 수평선 | **직전 9봉** 최저가 |
|
||||
| **시그널** | 종가가 **신고가선** 상향 **교차** | 종가가 **신저가선** 하향 **교차** |
|
||||
|
||||
백엔드: `NH_PRIOR_n` = `max(high[t−n] … high[t−1])` (**현재 봉 high 미포함**).
|
||||
`CROSS_UP` + right≠NH_PRIOR 시 **GTE**로 정규화 (Donchian 당일 포함 CROSS 방지).
|
||||
|
||||
---
|
||||
|
||||
## 7. 복합지표 탭 (`composite`)
|
||||
|
||||
### 7.1 동일 지표 2기간 교차 (`COMPOSITE_INDICATOR_ITEMS`)
|
||||
|
||||
**플래그:** `composite: true`, `leftPeriod`, `rightPeriod`, `leftField`, `rightField`, (선택) `leftCandleType` / `rightCandleType`.
|
||||
|
||||
| 칩 | left → right (예) | 시그널 |
|
||||
|----|-------------------|--------|
|
||||
| 돈치안 | `DC_UPPER_9` `CROSS_UP` `DC_UPPER_20` | **단기** 채널상단이 **장기** 상단 돌파 |
|
||||
| RSI | `RSI_VALUE_9` `CROSS_UP` `RSI_VALUE_18` | 단기 RSI **장기** RSI 상향 교차 |
|
||||
| CCI, ADX, Williams, TRIX, VR, PSY, 이격도 | 동일 패턴 | 기간 교차 |
|
||||
|
||||
**멀티 TF:** `leftCandleType ≠ rightCandleType` → `CrossTimeframeCompositeRule` (각 분봉에서 left/right 취한 뒤 교차 공식).
|
||||
|
||||
### 7.2 Stoch 과열×보조 (`STOCH_OVERBOUGHT_PAIR`)
|
||||
|
||||
**LogicNode OR 트리** (단일 CONDITION 아님):
|
||||
|
||||
```text
|
||||
OR(
|
||||
AND( Stoch %K ≥ 80, 보조지표 CROSS_UP 중앙 ),
|
||||
AND( 보조지표 ≥ 0, Stoch %K CROSS_UP 80 )
|
||||
)
|
||||
```
|
||||
|
||||
- **시그널:** 과열 구간 Stoch×보조 **동시 충족** 또는 **역순** 동시 충족.
|
||||
- 메타: `stochPair.secondaryIndicatorType` (기본 CCI).
|
||||
|
||||
### 7.3 9·20일 신고/신저 (`NEW_HIGH_9_20_BUY` / `NEW_LOW_9_20_SELL`)
|
||||
|
||||
**LogicNode AND:**
|
||||
|
||||
- `NEW_HIGH` `CROSS_UP` `NH_PRIOR_9` (또는 20)
|
||||
- **필터** (filterLevel: balanced / strict) — ADX·거래량 등 추가 leaf
|
||||
|
||||
**시그널:** 단기 신고가 돌파 **且** 필터 통과.
|
||||
|
||||
### 7.4 33변곡 (`INFLECTION_33_BUY/SELL`)
|
||||
|
||||
- EMA33 **변곡** + Donchian/필터 AND.
|
||||
- **시그널:** 33봉 EMA 기울기 전환 + 채널 확인.
|
||||
|
||||
### 7.5 일목×볼린저 (`ICHIMOKU_BB_BUY/SELL`)
|
||||
|
||||
단일 CONDITION:
|
||||
|
||||
```text
|
||||
indicatorType: ICHIMOKU_BB
|
||||
leftField: LAGGING_SPAN
|
||||
rightField: UPPER_BAND | LOWER_BAND
|
||||
conditionType: CROSS_UP | CROSS_DOWN
|
||||
params.chikouDisplacement: 26 (기본)
|
||||
```
|
||||
|
||||
**시그널:** **26봉 전** 후행스팬 vs **26봉 전** BB 밴드 교차 (후행·밴드 모두 lag 적용).
|
||||
|
||||
### 7.6 일목 상황별 (23종, `ICHIMOKU_SIT_*`)
|
||||
|
||||
| 카테고리 | 예 | 내부 구조 |
|
||||
|----------|-----|-----------|
|
||||
| 기본 매수 5 | 골든크로스, 기준선 돌파, 구름 돌파… | AND/OR **복수 CONDITION** |
|
||||
| 호전/역전 | 호전(정배열), 역전(역배열) | 4~5 조건 AND |
|
||||
| 엘리어트 10 | 1·2·3·4·5파, A·B·C | 파동 단계별 AND |
|
||||
|
||||
**시그널:** 해당 **상황 ID**에 정의된 leaf 조건이 **같은 봉**에서 모두(AND) 또는 일부(OR) 충족.
|
||||
|
||||
### 7.7 추천 안정형 (18종, `TURTLE_S1_BUY` 등)
|
||||
|
||||
| 전략 ID | 시그널 요지 |
|
||||
|---------|-------------|
|
||||
| TURTLE_S1 | 20일 **최고가** 돌파 매수 / 10일 **최저** 이탈 청산 |
|
||||
| TURTLE_S2 | 55/20일 터틀 장기 |
|
||||
| DONCHIAN_20 | 20일 채널 상·하단 |
|
||||
| MA_TREND | MA 교차 + ADX |
|
||||
| ICHIMOKU_TREND / SANYAKU / GOELEMENT | 일목 조합 |
|
||||
| MACD_MOMENTUM / BOLLINGER_BOX | 모멘텀·박스 |
|
||||
|
||||
각 항목은 `stableStrategyPairs.ts`에 **고정 LogicNode**로 정의 — 편집기에서 leaf 펼치면 MA·DONCHIAN·ICHIMOKU CONDITION들.
|
||||
|
||||
---
|
||||
|
||||
## 8. 횡보필터 탭 (`range`)
|
||||
|
||||
**목적:** 매수/매도 **본 조건**과 AND로 묶어 **횡보 구간만** 또는 **횡보만 제외**하고 시그널.
|
||||
|
||||
| mode | LogicNode | 시그널 해석 |
|
||||
|------|-----------|-------------|
|
||||
| **include** | `buildInner()` 그대로 | **횡보일 때만** true인 leaf |
|
||||
| **exclude** | `NOT(buildInner())` | **추세일 때만** true |
|
||||
|
||||
**카테고리 10종 · 내장 52필터** (`sidewaysFilterPalette.ts`):
|
||||
|
||||
| 카테고리 | 대표 필터 | leaf 조건 예 | true인 시장 |
|
||||
|----------|-----------|--------------|-------------|
|
||||
| **ADX** | ADX < 25 | `ADX LT HL_MID` | **약한 추세** (횡보) |
|
||||
| | ADX ≥ 25 (exclude용) | `ADX GTE` | 강한 추세 |
|
||||
| **볼린저** | 밴드 수축 | `DIFF_LT` UPPER−LOWER `< 2%` | 변동성 **축소** |
|
||||
| | 종가≈중심 | `CLOSE` ≈ `MIDDLE` | 밴드 중앙 횡보 |
|
||||
| **MA** | MA5≈MA20 | `DIFF_LT` MA5−MA20 `< N` | 이평 **수렴** |
|
||||
| **일목** | 구름 안 | `IN_CLOUD` | 구름 **내부** |
|
||||
| | 구름 얇음 | `DIFF_LT` SPAN1−SPAN2 | 얇은 구름 |
|
||||
| **Donchian** | 9/20 채널 좁음 | `DIFF_LT` UPPER−LOWER | **좁은 레인지** |
|
||||
| **오실레이터** | RSI 40~60 | `GTE`+`LTE` 밴드 | **중립** |
|
||||
| **cycle** | Stoch N봉 침체 | `LOWEST_LTE` `%K` | lookback 내 **20 이하** 경험 |
|
||||
| **DMI** | +DI≈−DI | `DIFF_LT` PDI−MDI | 방향성 **없음** |
|
||||
| **volume** | 거래량 < MA | `VOLUME LT VOLUME_MA` | **저거래** |
|
||||
| **combo** | ADX+구름+RSI | AND | **복합 횡보** |
|
||||
|
||||
**사용 예:**
|
||||
|
||||
```text
|
||||
매수 시그널 = AND( 본래 매수 조건, 횡보필터 exclude「ADX≥25」 )
|
||||
→ 추세가 있을 때만 매수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 추세 탭 (`trendline`)
|
||||
|
||||
**10칩** (`TREND_LINE_PALETTE_ITEMS`). 추세선 **정의**는 설정 → 전략 설정 (피벗·N·스윙 k).
|
||||
|
||||
| 칩 | indicatorType | leftField | conditionType |
|
||||
|----|---------------|-----------|---------------|
|
||||
| 종가 | MA | `CLOSE_PRICE` | buy: `TREND_LINE_CROSS_UP` |
|
||||
| MA / EMA | MA / EMA | `MA20` / `EMA20` | sell: `TREND_LINE_CROSS_DOWN` |
|
||||
| RSI, MACD, Stoch… | 해당 타입 | 지표선 | 동일 |
|
||||
|
||||
**공통 DSL:** `rightField: NONE`, `lookbackPeriod` (기본 10, 설정 `trendLineDefaultLookback`).
|
||||
|
||||
### 9.1 백엔드 시그널 알고리즘 (`TrendLineMath` + `buildTrendLineCrossUpRule`)
|
||||
|
||||
1. **fit 구간:** `[i − N, i − 1]` (현재 봉 **제외**)
|
||||
2. **피벗 선정:**
|
||||
- **상향 돌파 (매수):** **하락 저항선** — 스윙 **고점** 2개, `High₂ < High₁`, **High** 가격 연결
|
||||
- **하향 돌파 (매도):** **상승 지지선** — 스윙 **저점** 2개, `Low₂ > Low₁`, **Low** 가격 연결
|
||||
3. **동일 선**을 `i−1`, `i`에서 extrapolation → `left`와 **CROSS** 판정
|
||||
4. **오실레이터 left:** 스윙을 **지표값** 기준, 선도 지표 축
|
||||
|
||||
**시그널:** 해당 봉에서 종가(또는 left 지표)가 **N봉 피벗 추세선**을 **상향/하향 돌파**한 **그 봉 1개**.
|
||||
|
||||
전략평가 차트: **선택 봉** 위치의 추세선만 overlay (`buildSignalTrendLineDrawings`).
|
||||
|
||||
---
|
||||
|
||||
## 10. candleRange (N봉 윈도우) — 조건 편집기
|
||||
|
||||
팔레트 기본은 `candleRange: 1`, `candleRangeMode` 미설정 (= **CURRENT**).
|
||||
|
||||
| candleRangeMode | 의미 |
|
||||
|-----------------|------|
|
||||
| **CURRENT** | 해당 봉에서만 core Rule |
|
||||
| **EXISTS_IN** | 최근 N봉 **중 하나라도** core true |
|
||||
| **NOT_EXISTS_IN** | 최근 N봉 **동안 core true 없음** |
|
||||
|
||||
`SLOPE_*` + EXISTS_IN: 최근 N봉 **전 구간** 상승/하락 추세.
|
||||
|
||||
---
|
||||
|
||||
## 11. 시그널이 차트에 표시되는 경로
|
||||
|
||||
| 기능 | 서비스 | Rule 빌드 | 스캔 |
|
||||
|------|--------|-----------|------|
|
||||
| **전략평가** | `BacktestingService` / chart scan | `toRule(buy/sell)` | evalStart..end 각 `i` |
|
||||
| **전략편집기 평가 탭** | 동일 (`StrategyEvaluationChart`) | 동일 | 동일 |
|
||||
| **실시간 조건 패널** | `LiveConditionStatusService.evaluate` | leaf별 + overall | 현재/선택 봉 |
|
||||
| **실시간 자동매매** | `LiveStrategyEvaluator` | TriggerBranch | 분봉 **마감** 시 |
|
||||
|
||||
**마커:** `isSatisfied(i) === true`인 봉 `i`의 **시각·종가**에 BUY/SELL 마커.
|
||||
**LONG_ONLY:** BUY 후 exit Rule true 봉까지 **재진입 BUY 억제**.
|
||||
|
||||
---
|
||||
|
||||
## 12. 컨트롤 → 시그널 빠른 참조표
|
||||
|
||||
| 패널 영역 | 컨트롤 | 생성물 | 한 줄 시그널 요약 |
|
||||
|-----------|--------|--------|-------------------|
|
||||
| 논리 | AND | AND 노드 | 모든 하위 조건 **동시** 충족 봉 |
|
||||
| 논리 | OR | OR 노드 | 하나라도 충족 봉 |
|
||||
| 논리 | NOT | NOT 노드 | 자식 조건 **반대** |
|
||||
| 밴드 | MA/EMA | 1 CONDITION | 이평 **골든/데드크로스** 봉 |
|
||||
| 밴드 | BOLLINGER | 1 CONDITION | 종가 **밴드** 돌파 |
|
||||
| 밴드 | DONCHIAN | 1 CONDITION | 종가 **채널** 돌파 |
|
||||
| 밴드 | ICHIMOKU | 1 CONDITION | 전환/기준 **교차** 또는 구름/후행 **전용** 조건 |
|
||||
| 보조 | RSI 등 | 1 CONDITION | 지표 vs 임계/시그널 **교차·수준** |
|
||||
| 보조 | NEW_HIGH | 1 CONDITION | **직전 N봉 신고가** 교차 |
|
||||
| 복합 | 2기간 RSI… | 1 composite CONDITION | **단기 vs 장기** 교차 |
|
||||
| 복합 | Stoch×CCI | OR 트리 | 과열+보조 **복합** |
|
||||
| 복합 | 9·20 신고가 | AND 트리 | 신고가+**필터** |
|
||||
| 복합 | 일목 23종 | AND/OR 트리 | **상황별** 다중 조건 |
|
||||
| 복합 | 안정형 18종 | AND/OR 트리 | **템플릿 전략** |
|
||||
| 횡보 | ADX<25 등 | AND/NOT 트리 | **횡보/추세 필터** (타 조건과 AND) |
|
||||
| 추세 | 종가/RSI… | 1 CONDITION | **N봉 피벗 추세선** 돌파 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 편집기에서 시그널 확인 방법
|
||||
|
||||
1. **전략평가** 또는 편집기 **평가** 탭 실행 → 차트 **BUY/SELL 마커**
|
||||
2. 하단 **봉 선택기**로 봉 이동 → `LiveConditionStatusService` 스타일로 **조건별 true/false** (평가 패널)
|
||||
3. **추세** 조건: 선택 봉에서 **점선 추세선** overlay
|
||||
4. DSL **CondEditor**에서 `conditionType`·`leftField`·`lookbackPeriod` 변경 → 위 표의 Rule **즉시 반영**
|
||||
|
||||
---
|
||||
|
||||
## 14. 코드 인덱스
|
||||
|
||||
| 영역 | 경로 |
|
||||
|------|------|
|
||||
| 팔레트 UI | `frontend/src/components/StrategyEditorPage.tsx` |
|
||||
| 칩 | `frontend/src/components/strategyEditor/PaletteChip.tsx` |
|
||||
| DSL 타입 | `frontend/src/utils/strategyTypes.ts` |
|
||||
| 노드 생성 | `frontend/src/utils/strategyEditorShared.tsx` |
|
||||
| 보조/복합 목록 | `frontend/src/utils/strategyPaletteStorage.ts` |
|
||||
| 복합 2기간 | `frontend/src/utils/compositeIndicators.ts` |
|
||||
| 횡보 47필터 | `frontend/src/utils/sidewaysFilterPalette.ts` |
|
||||
| 추세 9칩 | `frontend/src/utils/trendLinePalette.ts` |
|
||||
| 일목 23 | `frontend/src/utils/ichimokuSituationStrategy.ts` |
|
||||
| 안정형 18 | `frontend/src/utils/stableStrategyPairs.ts` |
|
||||
| Rule 변환 | `backend/.../StrategyDslToTa4jAdapter.java` |
|
||||
| 추세선 수학 | `backend/.../TrendLineMath.java` |
|
||||
| 백테스트 스캔 | `backend/.../BacktestingService.java` |
|
||||
| 실시간 | `backend/.../LiveConditionStatusService.java` |
|
||||
|
||||
---
|
||||
|
||||
*문서 버전: 2026-06 — goldenChart 전략편집기·StrategyDslToTa4jAdapter 기준*
|
||||
@@ -0,0 +1,382 @@
|
||||
# 전략편집기 — 템플릿 탭 ↔ 매매 시그널 감지
|
||||
|
||||
전략편집기 우측 **템플릿** 탭에 나열된 각 전략을 적용했을 때 생성·저장되는 DSL이,
|
||||
실제 차트·백테스트·실시간 평가에서 **어떻게 매수/매도 시그널로 판정되는지**를 정리한 문서입니다.
|
||||
|
||||
> **평가 원천:** `StrategyDslToTa4jAdapter` → Ta4j `Rule` → 봉 `i`에서 `rule.isSatisfied(i)`
|
||||
> **관련:** [전략지표컨트롤.md](./전략지표컨트롤.md) · [매매전략조건.md](./매매전략조건.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 템플릿 탭 구조
|
||||
|
||||
**파일:** `StrategyEditorPage.tsx`, `strategyTemplateList.ts`, `TemplatePaletteTab.tsx`
|
||||
|
||||
템플릿 목록은 **4종 출처**를 하나의 리스트로 합칩니다 (`buildStrategyTemplatePaletteRows`).
|
||||
|
||||
| 출처 (`source`) | 배지 | 개수(기본) | 생성·적용 |
|
||||
|-----------------|------|------------|-----------|
|
||||
| **builtin** | (없음) | 28 | `getStrategyTemplates()` |
|
||||
| **composite-palette** | 복합지표 | ~58 | 복합지표 팔레트와 동일 항목 |
|
||||
| **sideways-palette** | 횡보지표 | 52 | 횡보필터 팔레트와 동일 항목 |
|
||||
| **user** | 내 템플릿 | 사용자 저장 | `ui_preferences.strategyEditor.userTemplates` |
|
||||
|
||||
검색(`templateSearch`)은 **라벨·설명·매수/매도·배지** 문자열로 필터합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 템플릿 적용 방식 (중요)
|
||||
|
||||
템플릿을 **더블클릭/적용**할 때 동작이 출처마다 다릅니다.
|
||||
|
||||
| 출처 | 적용 함수 | 캔버스 동작 |
|
||||
|------|-----------|-------------|
|
||||
| **내장 (builtin)** | `handleTemplate` | 해당 **매수 또는 매도 탭 전체를 교체** (반대 탭·고아 노드 초기화) |
|
||||
| **내 템플릿 (user)** | `applyUserTemplate` | 저장된 **LogicNode 트리로 해당 탭 전체 교체** |
|
||||
| **복합지표 (composite-palette)** | `applyPaletteItem` | **현재 탭 루트에 AND 병합** (기존 조건 유지 + 추가) |
|
||||
| **횡보지표 (sideways-palette)** | `applySidewaysFilter` | **현재 탭 루트에 AND 병합** (필터 노드 추가) |
|
||||
|
||||
```text
|
||||
[내장/내 템플릿 적용]
|
||||
→ buyCondition 또는 sellCondition = 템플릿 루트 1개 (교체)
|
||||
|
||||
[복합·횡보 템플릿 적용]
|
||||
→ mergeAtRoot(기존 루트, 새 노드, AND) — 다른 조건과 결합 가능
|
||||
```
|
||||
|
||||
**전략평가 탭**에서는 `buildTemplateEvaluationPayload`가 내장/내 템플릿을 **새 평가 전략**으로 만들고, 복합·횡보는 동일 빌더로 DSL을 생성합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 시그널 판정 공통 규칙
|
||||
|
||||
템플릿도 최종적으로 **LogicNode 트리**이므로, leaf `CONDITION`마다 아래 규칙이 적용됩니다.
|
||||
|
||||
| conditionType | 봉 `i`에서 true 조건 |
|
||||
|---------------|----------------------|
|
||||
| **CROSS_UP** | `left[i−1] ≤ right[i−1]` **且** `left[i] > right[i]` |
|
||||
| **CROSS_DOWN** | `left[i−1] ≥ right[i−1]` **且** `left[i] < right[i]` |
|
||||
| **GT / LT / GTE / LTE** | `left[i]` vs `right[i]` 수준 비교 |
|
||||
| **AND** | 모든 자식 true |
|
||||
| **OR** | 하나라도 true |
|
||||
| **NOT** | 자식 false |
|
||||
| **EXISTS_IN** (N봉) | 최근 N봉 중 core Rule **1회 이상** true |
|
||||
|
||||
**필터 강도:** 복합 전략(9·20 신고가, 33변곡, 안정형 등)은 기본 **`relaxed`(완화)** — ADX·거래량 등 보조 필터 **없음**이 기본값입니다. 편집기에서 **강함/보통**으로 변경 가능.
|
||||
|
||||
**positionMode:** 설정 → 전략 설정. `LONG_ONLY`면 BUY 후 exit(SELL) true 전까지 재진입 BUY 억제.
|
||||
|
||||
---
|
||||
|
||||
## 4. 내장 템플릿 (28종)
|
||||
|
||||
**출처:** `strategyPresets.ts` → `getStrategyTemplates(def)`
|
||||
|
||||
MA 기간은 설정의 `maLines[0]`, `maLines[1]` (기본 5·10일)을 사용합니다.
|
||||
|
||||
### 4.1 단순 템플릿 (`kind: 'simple'`) — 16종
|
||||
|
||||
CONDITION **1개**. `simpleTemplateToNode()`로 변환.
|
||||
|
||||
| 라벨 | signal | indicator | condition | left → right | 시그널 의미 |
|
||||
|------|--------|-----------|-----------|--------------|-------------|
|
||||
| MA 골든크로스 매수 | buy | MA | CROSS_UP | MA5 → MA10 | 단기 MA **장기 MA 상향 교차** 봉 |
|
||||
| MA 데드크로스 매도 | sell | MA | CROSS_DOWN | MA5 → MA10 | 단기 MA **장기 MA 하향 교차** 봉 |
|
||||
| RSI 과매도 매수 | buy | RSI | **LTE** | RSI_VALUE → OVERSOLD_30 | RSI **≤ 30** (과매도 구간 **체류**, 교차 아님) |
|
||||
| RSI 과매수 매도 | sell | RSI | **GTE** | RSI_VALUE → OVERBOUGHT_70 | RSI **≥ 70** |
|
||||
| MACD 상향 돌파 | buy | MACD | CROSS_UP | MACD_LINE → SIGNAL_LINE | MACD **시그널선 골든크로스** |
|
||||
| MACD 하향 돌파 | sell | MACD | CROSS_DOWN | MACD_LINE → SIGNAL_LINE | MACD **데드크로스** |
|
||||
| 볼린저 하단 돌파 매수 | buy | BOLLINGER | CROSS_UP | CLOSE → LOWER_BAND | 종가 **하단밴드 상향 돌파** (반등) |
|
||||
| 볼린저 상단 돌파 매도 | sell | BOLLINGER | CROSS_UP | CLOSE → UPPER_BAND | 종가 **상단밴드 상향 돌파** (과열 이탈) |
|
||||
| CCI 과매도 반등 | buy | CCI | CROSS_UP | CCI_VALUE → OVERSOLD_NEG100 | CCI **−100선 상향 돌파** |
|
||||
| 심리도 과매도 반등 매수 | buy | PSY | CROSS_UP | PSY_VALUE → OVERSOLD_25 | 심리도 **25선 상향 돌파** |
|
||||
| 스토캐스틱 골든크로스 | buy | STOCH | CROSS_UP | STOCH_K → STOCH_D | %K **%D 상향 교차** |
|
||||
| ADX 중앙선 상향 돌파 | buy | ADX | CROSS_UP | ADX_VALUE → ADX_25 | ADX **25선 상향 돌파** |
|
||||
| 9일 신고가 돌파 | buy | NEW_HIGH | CROSS_UP | CLOSE → NH_PRIOR_9 | 종가 **직전 9봉 최고가** 교차 |
|
||||
| 20일 신고가 돌파 | buy | NEW_HIGH | CROSS_UP | CLOSE → NH_PRIOR_20 | 종가 **직전 20봉 최고가** 교차 |
|
||||
| 9일 신저가 이탈 | sell | NEW_LOW | CROSS_DOWN | CLOSE → NL_PRIOR_9 | 종가 **직전 9봉 최저가** 하향 교차 |
|
||||
| 20일 신저가 이탈 | sell | NEW_LOW | CROSS_DOWN | CLOSE → NL_PRIOR_20 | 종가 **직전 20봉 최저가** 하향 교차 |
|
||||
|
||||
**NH_PRIOR_n / NL_PRIOR_n:** `max(high[t−n..t−1])` / `min(low[t−n..t−1])` — **현재 봉 high/low 미포함**.
|
||||
|
||||
### 4.2 복합 내장 템플릿 (`kind: 'composite'`) — 12종
|
||||
|
||||
`build()` 함수가 **AND/OR LogicNode**를 반환. 적용 시 **해당 탭 전체**가 이 트리로 교체됩니다.
|
||||
|
||||
#### 4.2.1 9·20일 신고/신저가
|
||||
|
||||
| 라벨 | signal | 트리 | 기본(relaxed) 시그널 |
|
||||
|------|--------|------|----------------------|
|
||||
| 9·20일 신고가 매수 | buy | `AND` | `CLOSE CROSS_UP NH_PRIOR_20` **만** |
|
||||
| 9·20일 신저가 매도 | sell | `AND` | `CLOSE CROSS_DOWN NL_PRIOR_20` **만** |
|
||||
|
||||
**필터 강도별 추가 leaf (편집기에서 변경 가능):**
|
||||
|
||||
| 강도 | 매수 추가 | 매도 추가 |
|
||||
|------|-----------|-----------|
|
||||
| **strict** | 9일 CROSS + ADX≥25 + 거래량>MA + 고가≥20일선 | ADX≥20 + 거래량>MA |
|
||||
| **balanced** | ADX≥18 | ADX≥15 |
|
||||
| **relaxed** | (없음) | (없음) |
|
||||
|
||||
#### 4.2.2 33변곡
|
||||
|
||||
| 라벨 | signal | 트리 | 기본(relaxed) 시그널 |
|
||||
|------|--------|------|----------------------|
|
||||
| 33변곡 매수 | buy | `AND` | EMA33 **1봉 SLOPE_UP** + CLOSE **CROSS_UP** CLOSE_MAX_33 |
|
||||
| 33변곡 매도 | sell | `OR` | CLOSE **CROSS_DOWN** CLOSE_MIN_33 **만** (EMA 분기 생략) |
|
||||
|
||||
**balanced/strict** 시 매수: EMA 2봉 변곡, 종가>EMA33, ADX·거래량 필터 추가.
|
||||
**매도 strict/balanced:** EMA 하락+종가<EMA33 **OR** 채널 이탈.
|
||||
|
||||
#### 4.2.3 돈천·터틀·일목 (Donchian / Ichimoku)
|
||||
|
||||
| 라벨 | signal | DSL 핵심 | 시그널 |
|
||||
|------|--------|----------|--------|
|
||||
| 돈천 채널(20일) 매수 | buy | DONCHIAN CROSS_UP CLOSE → DC_UPPER_20 | 20일 채널 **상단** 돌파 |
|
||||
| 돈천 채널(20일) 매도 | sell | DONCHIAN CROSS_DOWN CLOSE → DC_LOWER_20 | 20일 채널 **하단** 이탈 |
|
||||
| 터틀 S1 매수 | buy | DC_UPPER_**20** CROSS_UP | 20일 **최고가** 돌파 진입 |
|
||||
| 터틀 S1 청산 | sell | DC_LOWER_**10** CROSS_DOWN | 10일 **최저가** 이탈 청산 |
|
||||
| 터틀 S2 매수 | buy | DC_UPPER_**55** CROSS_UP | 55일 최고가 돌파 |
|
||||
| 터틀 S2 청산 | sell | DC_LOWER_**20** CROSS_DOWN | 20일 최저가 이탈 |
|
||||
| 일목 전환/기준선 매수 | buy | ICHIMOKU CROSS_UP CONVERSION → BASE | **전환·기준 골든크로스** |
|
||||
| 일목 전환/기준선 매도 | sell | ICHIMOKU CROSS_DOWN CONVERSION → BASE | **데드크로스** |
|
||||
|
||||
> **참고:** 터틀 S1 매수 = 돈천 20일 매수와 **동일 DSL**. S1 청산만 10일 하단으로 다름.
|
||||
|
||||
---
|
||||
|
||||
## 5. 복합지표 템플릿 (~58종, 배지「복합지표」)
|
||||
|
||||
**출처:** `buildCompositePaletteTemplateRows` — `DEFAULT_COMPOSITE_ITEMS` (`strategyPaletteStorage.ts`)
|
||||
|
||||
지표 탭 **복합** 서브탭과 **동일 항목**이 템플릿 탭에도 노출됩니다.
|
||||
적용 시 **현재 탭 조건에 AND 병합** (`applyPaletteItem`).
|
||||
|
||||
### 5.1 Stoch 과열×보조 (1종)
|
||||
|
||||
| 라벨 | signal | 트리 | 시그널 |
|
||||
|------|--------|------|--------|
|
||||
| Stoch 과열×보조 | buy | `OR` | `(Stoch≥80 ∧ 보조 CROSS_UP 중앙) ∨ (보조≥중앙 ∧ Stoch CROSS_UP 80)` |
|
||||
|
||||
기본 보조: **CCI**. 편집기에서 RSI·ADX 등 변경 가능.
|
||||
|
||||
### 5.2 9·20 신고/신저 · 33변곡 · 일목×BB (6종)
|
||||
|
||||
내장 §4.2와 **동일 빌더**. 템플릿 탭에서는 **추가(병합)** 방식만 다름.
|
||||
|
||||
| 항목 | 빌더 | 기본 시그널 요약 |
|
||||
|------|------|------------------|
|
||||
| 9·20일 신고가 매수 | `buildNewHigh920BuyTree` | 20일 신고가 CROSS + (필터) |
|
||||
| 9·20일 신저가 매도 | `buildNewLow920SellTree` | 20일 신저가 CROSS + (필터) |
|
||||
| 33변곡 매수/매도 | `buildInflection33Tree` | EMA33 변곡 + 33봉 종가 채널 |
|
||||
| 일목×BB 매수 | `buildIchimokuBbBuyTree` | **26봉 전** 후행 vs **26봉 전** BB **상한** CROSS_UP |
|
||||
| 일목×BB 매도 | `buildIchimokuBbSellTree` | **26봉 전** 후행 vs **26봉 전** BB **하한** CROSS_DOWN |
|
||||
|
||||
### 5.3 일목 상황별 (23종)
|
||||
|
||||
**빌더:** `ichimokuSituationStrategy.ts` — `BUILDERS[situationId][mode]`
|
||||
|
||||
| ID | 라벨(요) | signal | 논리 | leaf 조건 (동일 봉 AND/OR) |
|
||||
|----|----------|--------|------|---------------------------|
|
||||
| GOLDEN_CROSS | 골든크로스 매수 | buy | AND | 전환 **CROSS_UP** 기준 |
|
||||
| BASE_BREAKUP | 기준선 돌파 매수 | buy | AND | 종가 **CROSS_UP** 기준선 |
|
||||
| CLOUD_BREAKUP | 구름 돌파 매수 | buy | AND | **CLOUD_BREAK_UP** |
|
||||
| LAGGING_BULL | 후행 호전 매수 | buy | AND | 후행 **>** 26봉 전 종가 |
|
||||
| CLOUD_SUPPORT | 구름 지지 매수 | buy | AND | 종가>선행1 **且** 종가>기준선 |
|
||||
| HOJEON | 호전(정배열) 매수 | buy | AND | 전환>기준 + 구름 위 + 양운 + 후행>종가 |
|
||||
| DEAD_CROSS | 데드크로스 매도 | sell | OR | 전환 **CROSS_DOWN** 기준 |
|
||||
| BASE_BREAKDOWN | 기준선 이탈 매도 | sell | OR | 종가 **CROSS_DOWN** 기준선 |
|
||||
| CLOUD_BREAKDOWN | 구름 이탈 매도 | sell | OR | **CLOUD_BREAK_DOWN** |
|
||||
| LAGGING_BEAR | 후행 역전 매도 | sell | OR | 후행 **<** 26봉 전 종가 |
|
||||
| CLOUD_RESISTANCE | 구름 저항 매도 | sell | AND | 종가<선행1 **且** 종가<기준선 |
|
||||
| SANYAKU_REVERSE | 삼역역전 매도 | sell | AND | 전환<기준 + 구름 아래 + 후행 역전 |
|
||||
| GYEOKJEON | 역전(역배열) 매도 | sell | AND | 전환<기준 + 구름 아래 + 음운 + 후행 역전 |
|
||||
| EW1 | 1파 매수 | buy | AND | 종가>기준 CROSS + 전환>기준 |
|
||||
| EW2 | 2파 매수/손절 | buy/sell | AND/OR | 매수: 기준·선행2 지지+전환>기준 / 매도: 기준 이탈 OR 구름 이탈 |
|
||||
| EW3 | 3파 매수 | buy | AND | 전환>기준 + 구름 위 + 후행 + 양운 |
|
||||
| EW4 | 4파 재매수/익절 | buy/sell | AND/OR | 매수: 선행1·전환 지지 / 매도: 기준·구름 이탈 |
|
||||
| EW5 | 5파 분할매도 | sell | OR | 종가<전환 OR 데드 OR 구름 이탈 |
|
||||
| EW_A | A파 매도 | sell | OR | 기준·데드 이탈 |
|
||||
| EW_B | B파 매도 | sell | AND | 기준 아래 + 구름 아래 + 전환<기준 |
|
||||
| EW_C | C파 매도 | sell | AND | 구름 이탈 + 구름 아래 + 전환<기준 + 후행 역전 |
|
||||
|
||||
### 5.4 추천 안정형 (18종)
|
||||
|
||||
**빌더:** `stableStrategyPairs.ts` — 기본 필터 **`relaxed`**
|
||||
|
||||
| strategyId | 매수 핵심 | 매도 핵심 | relaxed 시그널 |
|
||||
|------------|-----------|-----------|----------------|
|
||||
| **TURTLE_S1** | DC_UPPER_20 CROSS_UP | DC_LOWER_10 CROSS_DOWN | §4.2.3과 동일 |
|
||||
| **TURTLE_S2** | DC_UPPER_55 CROSS_UP | DC_LOWER_20 CROSS_DOWN | 장기 터틀 |
|
||||
| **DONCHIAN_20** | DC_UPPER_20 CROSS_UP | DC_LOWER_20 CROSS_DOWN | 20일 채널 |
|
||||
| **MA_TREND** | MA20 CROSS_UP MA60 | MA20 CROSS_DOWN MA60 **OR** 종가<MA20 | 이평 추세 |
|
||||
| **ICHIMOKU_TREND** | 전환/기준 CROSS_UP | CROSS_DOWN **OR** 구름 아래(balanced+) | 일목 추세 |
|
||||
| **ICHIMOKU_SANYAKU** | 구름 돌파 + (종가>전환 등) | 전환/기준/구름 **하향 CROSS** OR | 삼역호전 |
|
||||
| **ICHIMOKU_PERFECT** | 전환>기준 + 구름 위 | 전환<기준 + 구름 아래 | 5원소(양운·후행은 strict) |
|
||||
| **MACD_MOMENTUM** | MACD CROSS_UP | MACD CROSS_DOWN **OR** 종가<EMA60 | 모멘텀 |
|
||||
| **BOLLINGER_RANGE** | RSI≤30 + ADX약 + 하단밴드 CROSS_UP | RSI≥70 **OR** 상단밴드 CROSS_UP | 박스권 |
|
||||
|
||||
**필터 강도 (strict/balanced) 추가 예:**
|
||||
|
||||
| 전략 | strict 추가 |
|
||||
|------|-------------|
|
||||
| TURTLE / DONCHIAN 매수 | ADX≥25 + 거래량>MA |
|
||||
| MA_TREND 매수 | ADX≥25 + 거래량>MA |
|
||||
| ICHIMOKU_SANYAKU 매수 | EXISTS_IN 윈도우(12~26봉) + MA60·거래량 |
|
||||
| MACD_MOMENTUM 매수 | EMA60 위 + ADX·거래량 |
|
||||
|
||||
### 5.5 동일 지표 2기간 교차 (10종)
|
||||
|
||||
**적용:** `makeNode(..., { composite: true })` — 현재 **매수/매도 탭**에 따라 `CROSS_UP` / `CROSS_DOWN`.
|
||||
|
||||
| 칩 | left → right (기본 기간) | 시그널 |
|
||||
|----|--------------------------|--------|
|
||||
| 돈치안 | DC_UPPER_9 CROSS DC_UPPER_20 | **단기 채널상단**이 **장기 상단** 돌파 |
|
||||
| RSI | RSI_VALUE_9 CROSS RSI_VALUE_18 | 단기 RSI **장기** RSI 교차 |
|
||||
| CCI | CCI_VALUE_9 CROSS CCI_VALUE_26 | 동일 패턴 |
|
||||
| ADX | ADX_VALUE_14 CROSS ADX_VALUE_28 | 동일 |
|
||||
| Williams %R | WILLIAMS_R 14 vs 28 | 동일 |
|
||||
| TRIX | TRIX 12 vs 24 | 동일 |
|
||||
| VR | VR 26 vs 52 | 동일 |
|
||||
| 심리도 / 투자심리도 | 설정 기간 ×2 | 동일 |
|
||||
| 이격도 | dispShort vs dispMid | 동일 |
|
||||
|
||||
기간은 **보조지표 설정**(`DefType`)에서 상속.
|
||||
|
||||
---
|
||||
|
||||
## 6. 횡보지표 템플릿 (52종, 배지「횡보지표」)
|
||||
|
||||
**출처:** `buildSidewaysPaletteTemplateRows` — `DEFAULT_SIDEWAYS_FILTER_ITEMS`
|
||||
|
||||
적용: `buildSidewaysFilterLogic(templateId, mode, def)` → **현재 탭에 AND 병합**.
|
||||
|
||||
| mode | LogicNode | 시그널 해석 |
|
||||
|------|-----------|-------------|
|
||||
| **include** | 필터 leaf 그대로 | **횡보일 때** true — 횡보 구간 **포함** 필터 |
|
||||
| **exclude** | `NOT(필터 leaf)` | **추세일 때** true — 횡보 **제외** |
|
||||
|
||||
**템플릿 탭 signal 표시:** include → **buy**, exclude → **sell** (UI 라벨용; 실제로는 **필터 노드**이며 단독 매매 시그널이 아님).
|
||||
|
||||
### 6.1 카테고리별 시그널 조건
|
||||
|
||||
| 카테고리 | 대표 템플릿 | true = 횡보(include) |
|
||||
|----------|-------------|----------------------|
|
||||
| **ADX** | ADX < 25 | ADX **< 25** (비추세) |
|
||||
| | ADX ≥ 25 (exclude) | 추세 — NOT으로 **횡보 제외** |
|
||||
| **볼린저** | BB 수축 | 상·하단 간격 **DIFF_LT** (Squeeze) |
|
||||
| | 종가 ≈ 중심 | 종가 **≈** MIDDLE_BAND |
|
||||
| **MA** | MA5≈MA20 | MA 스프레드 **DIFF_LT** |
|
||||
| **일목** | 구름 안 | **IN_CLOUD** |
|
||||
| **Donchian** | 9/20일 채널 좁음 | DC_UPPER − DC_LOWER **DIFF_LT** |
|
||||
| **오실레이터** | RSI 40~60 | RSI **GTE 40 ∧ LTE 60** |
|
||||
| **cycle** | Stoch 20봉 침체 | **LOWEST_LTE** %K ≤ 20 (lookback 20) |
|
||||
| **DMI** | +DI ≈ −DI | PDI−MDI **DIFF_LT** |
|
||||
| **volume** | 거래량 < MA | VOLUME **LT** VOLUME_MA |
|
||||
| **combo** | ADX+구름+RSI | 위 조건 **AND** (3중 횡보) |
|
||||
|
||||
**실전 조합 예:**
|
||||
|
||||
```text
|
||||
매수 = AND( MA 골든크로스, NOT「ADX<25」 ) ← exclude ADX≥25 = 추세에서만 매수
|
||||
매수 = AND( RSI 과매도, 「RSI 40~60」 ) ← include = 횡보 확인 (드묾)
|
||||
```
|
||||
|
||||
전체 52항목 ID·설명은 `sidewaysFilterPalette.ts` `DEFAULT_SIDEWAYS_FILTER_ITEMS` 참고.
|
||||
|
||||
---
|
||||
|
||||
## 7. 내 템플릿 (사용자 저장)
|
||||
|
||||
**저장:** 현재 **매수 또는 매도** 탭의 `EditorConditionState` + `orphans` 스냅샷.
|
||||
|
||||
| 필드 | 의미 |
|
||||
|------|------|
|
||||
| `signal` | buy / sell — 적용 시 **해당 탭만** 채움 |
|
||||
| `editorState.root` | LogicNode 트리 |
|
||||
| `orphans` | 캔버스 고아 노드 |
|
||||
|
||||
**시그널:** 저장 시점 DSL 그대로 `StrategyDslToTa4jAdapter` 평가.
|
||||
내용은 사용자가 만든 **AND/OR/CONDITION** 조합이므로, leaf별로 §3 공통 규칙 적용.
|
||||
|
||||
**적용:** 반대 탭·고아 초기화 후 **전체 교체** (`applyUserTemplate`).
|
||||
|
||||
---
|
||||
|
||||
## 8. 내장 vs 복합지표 중복
|
||||
|
||||
다음 항목은 **내장 템플릿**과 **복합지표 템플릿**에 **둘 다** 나타날 수 있습니다.
|
||||
|
||||
| 항목 | 차이 |
|
||||
|------|------|
|
||||
| 9·20 신고/신저, 33변곡, 터틀, 돈천, 일목 전환/기준 | **DSL 동일** |
|
||||
| 적용 방식 | 내장 = **탭 교체** / 복합 = **AND 추가** |
|
||||
|
||||
숨김: 템플릿 삭제 → `hiddenBuiltinTemplateIds`에 key 저장 (복원 가능).
|
||||
|
||||
---
|
||||
|
||||
## 9. 차트까지의 평가 경로
|
||||
|
||||
```text
|
||||
[템플릿 적용]
|
||||
↓
|
||||
buyConditionJson / sellConditionJson (전략 저장)
|
||||
↓
|
||||
StrategyDslToTa4jAdapter.toRule(buy) / toRule(sell)
|
||||
↓
|
||||
BacktestingService · StrategyEvaluationChart · LiveConditionStatusService
|
||||
↓
|
||||
isSatisfied(i) === true → 봉 i에 BUY/SELL 마커
|
||||
```
|
||||
|
||||
| 기능 | 템플릿 사용처 |
|
||||
|------|---------------|
|
||||
| **전략편집기** | 템플릿 탭 적용 → 편집 후 전략 저장 |
|
||||
| **전략평가** | 템플릿 선택 → `[템플릿] {라벨}` 임시 전략 생성 |
|
||||
| **실시간** | 저장된 전략 DSL → 분봉 마감 시 TriggerBranch 평가 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 템플릿별 시그널 빠른 참조
|
||||
|
||||
| 템플릿 유형 | 대표 예 | 한 줄 시그널 |
|
||||
|-------------|---------|--------------|
|
||||
| 단순 내장 | MA 골든크로스 | 이평 **교차** 1봉 |
|
||||
| 단순 내장 | RSI 과매도 | RSI **≤30** 수준 |
|
||||
| 단순 내장 | 20일 신고가 | **직전 20봉 고가** CROSS_UP |
|
||||
| 복합 내장 | 9·20 신고가 | 20일 CROSS + (선택 필터) AND |
|
||||
| 복합 내장 | 터틀 S1 | 20일 돌파 매수 / 10일 이탈 매도 |
|
||||
| 복합지표 | Stoch×CCI | 과열+보조 **OR** 조합 |
|
||||
| 복합지표 | 일목 EW3 | 4~5 일목 leaf **AND** |
|
||||
| 복합지표 | MA 추세 | MA20/60 + (ADX) |
|
||||
| 복합지표 | RSI 2기간 | RSI 단기 **장기** CROSS |
|
||||
| 횡보 | ADX<25 include | **횡보 필터** (타 조건과 AND) |
|
||||
| 횡보 | ADX≥25 exclude | **추세만** 통과 NOT |
|
||||
| 내 템플릿 | (사용자) | 저장 DSL 그대로 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 코드 인덱스
|
||||
|
||||
| 영역 | 경로 |
|
||||
|------|------|
|
||||
| 템플릿 목록 병합 | `frontend/src/utils/strategyTemplateList.ts` |
|
||||
| 내장 28종 정의 | `frontend/src/utils/strategyPresets.ts` |
|
||||
| 복합·횡보 행 변환 | `frontend/src/utils/strategyEditorTemplatePaletteRows.ts` |
|
||||
| 템플릿 UI | `frontend/src/components/strategyEditor/TemplatePaletteTab.tsx` |
|
||||
| 적용 (편집기) | `frontend/src/components/StrategyEditorPage.tsx` — `handleTemplateApply` |
|
||||
| 적용 (전략평가) | `frontend/src/utils/strategyEvaluationTemplateApply.ts` |
|
||||
| 9·20 / 33변곡 | `priceExtremeBreakoutPair.ts`, `inflection33Strategy.ts` |
|
||||
| Stoch×보조 | `stochOverboughtPair.ts` |
|
||||
| 일목×BB | `ichimokuBbStrategy.ts` |
|
||||
| 일목 23상황 | `ichimokuSituationStrategy.ts` |
|
||||
| 안정형 18 | `stableStrategyPairs.ts` |
|
||||
| 2기간 교차 | `compositeIndicators.ts` |
|
||||
| 횡보 52 | `sidewaysFilterPalette.ts` |
|
||||
| 사용자 템플릿 | `strategyTemplateStorage.ts` |
|
||||
| Rule 변환 | `backend/.../StrategyDslToTa4jAdapter.java` |
|
||||
|
||||
---
|
||||
|
||||
*문서 버전: 2026-06 — goldenChart 전략편집기 템플릿 탭·strategyPresets 기준*
|
||||
Reference in New Issue
Block a user