일목균형표 수정
This commit is contained in:
@@ -107,6 +107,11 @@ public class GcAppSettings {
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private String chartLegendOptionsJson;
|
||||
|
||||
/** 차트 pane 구분선 옵션 JSON (visible, color, width, lineStyle) */
|
||||
@Column(name = "chart_pane_separator_json", columnDefinition = "JSON")
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private String chartPaneSeparatorJson;
|
||||
|
||||
/** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */
|
||||
@Column(name = "trade_alert_popup", nullable = false)
|
||||
@Builder.Default
|
||||
|
||||
@@ -95,6 +95,7 @@ public class AppSettingsService {
|
||||
if (d.containsKey("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight(
|
||||
Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString()));
|
||||
if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions")));
|
||||
if (d.containsKey("chartPaneSeparator")) s.setChartPaneSeparatorJson(toJson(d.get("chartPaneSeparator")));
|
||||
if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup(
|
||||
Boolean.parseBoolean(d.get("tradeAlertPopup").toString()));
|
||||
if (d.containsKey("tradeAlertSoundEnabled")) s.setTradeAlertSoundEnabled(
|
||||
@@ -192,6 +193,7 @@ public class AppSettingsService {
|
||||
m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true);
|
||||
m.put("chartLiveReceiveHighlight", s.getChartLiveReceiveHighlight() != null ? s.getChartLiveReceiveHighlight() : true);
|
||||
m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson()));
|
||||
m.put("chartPaneSeparator", parseJson(s.getChartPaneSeparatorJson()));
|
||||
m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true);
|
||||
m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true);
|
||||
m.put("tradeAlertSound", s.getTradeAlertSound() != null ? s.getTradeAlertSound() : "bell");
|
||||
|
||||
@@ -588,13 +588,13 @@ public class IndicatorService {
|
||||
int conv = intP(p, "conversionPeriods", 9);
|
||||
int base = intP(p, "basePeriods", 26);
|
||||
int span2 = intP(p, "laggingSpan2Periods", 52);
|
||||
int disp = intP(p, "displacement", 26);
|
||||
int senkouDisp = intP(p, "senkouDisplacement", intP(p, "displacement", 26));
|
||||
int chikouDisp = intP(p, "chikouDisplacement", intP(p, "displacement", 26));
|
||||
IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(s, conv);
|
||||
IchimokuKijunSenIndicator kijun = new IchimokuKijunSenIndicator(s, base);
|
||||
IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, disp);
|
||||
// 선행스팬B displacement 도 spanA·chikou 와 동일한 disp 를 적용해야 한다
|
||||
IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, disp);
|
||||
IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, disp);
|
||||
IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, senkouDisp);
|
||||
IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, senkouDisp);
|
||||
IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, chikouDisp);
|
||||
return Map.of(
|
||||
"plot0", toPlot(s, tenkan),
|
||||
"plot1", toPlot(s, kijun),
|
||||
|
||||
@@ -58,6 +58,7 @@ public class LiveStrategyTimeframeService {
|
||||
if (candleType == null || candleType.isBlank()) return "1m";
|
||||
String c = candleType.trim().toLowerCase();
|
||||
if ("1d".equals(c)) return "1d";
|
||||
if ("1w".equals(c)) return "1w";
|
||||
if ("1h".equals(c)) return "1h";
|
||||
if ("4h".equals(c)) return "4h";
|
||||
if (ALLOWED.contains(c)) return c;
|
||||
@@ -69,7 +70,8 @@ public class LiveStrategyTimeframeService {
|
||||
case "1m", "3m", "5m", "10m", "15m", "30m" -> chartTf;
|
||||
case "1h", "4h" -> chartTf;
|
||||
case "1D" -> "1d";
|
||||
case "1W", "1M" -> "1d";
|
||||
case "1W" -> "1w";
|
||||
case "1M" -> "1d";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
+15
-2
@@ -148,7 +148,7 @@ public class StrategyConditionTimeframeService {
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")));
|
||||
addTimeframeCandleTypes(node, out);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ public class StrategyConditionTimeframeService {
|
||||
if (children.isArray() && !children.isEmpty()) {
|
||||
if (allTimeframeChildren(children)) {
|
||||
for (JsonNode tf : children) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m")));
|
||||
addTimeframeCandleTypes(tf, out);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -183,4 +183,17 @@ public class StrategyConditionTimeframeService {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** TIMEFRAME 노드 — candleTypes 배열 또는 단일 candleType */
|
||||
static void addTimeframeCandleTypes(JsonNode timeframeNode, Set<String> out) {
|
||||
JsonNode arr = timeframeNode.path("candleTypes");
|
||||
if (arr.isArray() && !arr.isEmpty()) {
|
||||
for (JsonNode t : arr) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
||||
if (!ct.isBlank()) out.add(ct);
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.add(LiveStrategyTimeframeService.normalize(timeframeNode.path("candleType").asText("1m")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ public class StrategyDslTimeframeNormalizer {
|
||||
if (root == null || root.isNull()) return root;
|
||||
|
||||
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
|
||||
// START 다중 분봉(candleTypes) — 편집기 저장값 유지
|
||||
if (hasMultipleCandleTypes(root)) {
|
||||
return root;
|
||||
}
|
||||
String expected = inferPrimaryCandleType(root, strategyName);
|
||||
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
|
||||
if (!expected.equals(current)) {
|
||||
@@ -145,7 +149,7 @@ public class StrategyDslTimeframeNormalizer {
|
||||
|
||||
String type = node.path("type").asText("");
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")));
|
||||
StrategyConditionTimeframeService.addTimeframeCandleTypes(node, out);
|
||||
}
|
||||
|
||||
if ("CONDITION".equals(type)) {
|
||||
@@ -193,6 +197,11 @@ public class StrategyDslTimeframeNormalizer {
|
||||
return "1m";
|
||||
}
|
||||
|
||||
private static boolean hasMultipleCandleTypes(JsonNode timeframeNode) {
|
||||
JsonNode arr = timeframeNode.path("candleTypes");
|
||||
return arr.isArray() && arr.size() > 1;
|
||||
}
|
||||
|
||||
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
||||
ObjectNode copy = tf.deepCopy();
|
||||
copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType));
|
||||
|
||||
@@ -749,17 +749,24 @@ public class StrategyDslToTa4jAdapter {
|
||||
private IchimokuKijunSenIndicator ichimokuKijun(BarSeries s, Map<String, Object> p) {
|
||||
return new IchimokuKijunSenIndicator(s, intP(p, "basePeriods", 26));
|
||||
}
|
||||
private int ichimokuSenkouDisplacement(Map<String, Object> p) {
|
||||
return intP(p, "senkouDisplacement", intP(p, "displacement", 26));
|
||||
}
|
||||
|
||||
private int ichimokuChikouDisplacement(Map<String, Object> p) {
|
||||
return intP(p, "chikouDisplacement", intP(p, "displacement", 26));
|
||||
}
|
||||
|
||||
private IchimokuSenkouSpanAIndicator ichimokuSpanA(BarSeries s, Map<String, Object> p) {
|
||||
return new IchimokuSenkouSpanAIndicator(s, ichimokuTenkan(s, p), ichimokuKijun(s, p),
|
||||
intP(p, "displacement", 26));
|
||||
ichimokuSenkouDisplacement(p));
|
||||
}
|
||||
private IchimokuSenkouSpanBIndicator ichimokuSpanB(BarSeries s, Map<String, Object> p) {
|
||||
// IndicatorService 와 동일하게 displacement 파라미터 반영
|
||||
return new IchimokuSenkouSpanBIndicator(s, intP(p, "laggingSpan2Periods", 52),
|
||||
intP(p, "displacement", 26));
|
||||
ichimokuSenkouDisplacement(p));
|
||||
}
|
||||
private IchimokuChikouSpanIndicator ichimokuLagging(BarSeries s, Map<String, Object> p) {
|
||||
return new IchimokuChikouSpanIndicator(s, intP(p, "displacement", 26));
|
||||
return new IchimokuChikouSpanIndicator(s, ichimokuChikouDisplacement(p));
|
||||
}
|
||||
|
||||
/** 종가가 구름 위 */
|
||||
|
||||
@@ -82,9 +82,17 @@ public class StrategyTriggerBranchEvaluator {
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"));
|
||||
List<String> types = readTimeframeCandleTypes(node);
|
||||
JsonNode sub = firstChild(node);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub)));
|
||||
if (types.size() <= 1) {
|
||||
String ct = types.isEmpty() ? "1m" : types.get(0);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub)));
|
||||
}
|
||||
List<BranchDef> branches = new ArrayList<>();
|
||||
for (String ct : types) {
|
||||
branches.add(new BranchDef(ct, sub));
|
||||
}
|
||||
return new BranchScope("OR", branches);
|
||||
}
|
||||
|
||||
if ("AND".equals(type) || "OR".equals(type)) {
|
||||
@@ -92,9 +100,10 @@ public class StrategyTriggerBranchEvaluator {
|
||||
if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) {
|
||||
List<BranchDef> branches = new ArrayList<>();
|
||||
for (JsonNode child : children) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(
|
||||
child.path("candleType").asText("1m"));
|
||||
branches.add(new BranchDef(ct, firstChild(child)));
|
||||
JsonNode sub = firstChild(child);
|
||||
for (String ct : readTimeframeCandleTypes(child)) {
|
||||
branches.add(new BranchDef(ct, sub));
|
||||
}
|
||||
}
|
||||
return new BranchScope(type, branches);
|
||||
}
|
||||
@@ -102,9 +111,18 @@ public class StrategyTriggerBranchEvaluator {
|
||||
|
||||
JsonNode wrapped = findTimeframeWrapper(node);
|
||||
if (wrapped != null) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(wrapped.path("candleType").asText("1m"));
|
||||
List<String> types = readTimeframeCandleTypes(wrapped);
|
||||
JsonNode sub = firstChild(wrapped);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub != null && !sub.isNull() ? sub : node)));
|
||||
JsonNode subtree = sub != null && !sub.isNull() ? sub : node;
|
||||
if (types.size() <= 1) {
|
||||
String ct = types.isEmpty() ? "1m" : types.get(0);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, subtree)));
|
||||
}
|
||||
List<BranchDef> branches = new ArrayList<>();
|
||||
for (String ct : types) {
|
||||
branches.add(new BranchDef(ct, subtree));
|
||||
}
|
||||
return new BranchScope("OR", branches);
|
||||
}
|
||||
|
||||
String inferred = StrategyDslTimeframeNormalizer.inferPrimaryCandleType(node);
|
||||
@@ -143,6 +161,21 @@ public class StrategyTriggerBranchEvaluator {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** START 다중 분봉 — 각 마감봉마다 동일 조건 트리를 독립 평가 */
|
||||
private static List<String> readTimeframeCandleTypes(JsonNode timeframeNode) {
|
||||
JsonNode arr = timeframeNode.path("candleTypes");
|
||||
if (arr.isArray() && !arr.isEmpty()) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (JsonNode t : arr) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
||||
if (!ct.isBlank() && !list.contains(ct)) list.add(ct);
|
||||
}
|
||||
if (!list.isEmpty()) return list;
|
||||
}
|
||||
return List.of(LiveStrategyTimeframeService.normalize(
|
||||
timeframeNode.path("candleType").asText("1m")));
|
||||
}
|
||||
|
||||
private final class TriggerBranchRule implements Rule {
|
||||
private final BranchScope scope;
|
||||
private final String market;
|
||||
|
||||
@@ -25,7 +25,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
*
|
||||
* 구조: ConcurrentHashMap<market, Map<candleType, BarSeries>>
|
||||
* - market : "KRW-BTC", "KRW-ETH" 등 업비트 종목 코드
|
||||
* - candleType: "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"
|
||||
* - candleType: "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"
|
||||
*
|
||||
* 슬라이딩 윈도우: 모든 BarSeries 에 setMaximumBarCount(300) 적용.
|
||||
* 301번째 캔들이 들어오면 가장 오래된 캔들이 자동 소멸.
|
||||
@@ -56,6 +56,7 @@ public class Ta4jStorage {
|
||||
"30m", "minutes/30",
|
||||
"1h", "minutes/60",
|
||||
"4h", "minutes/240",
|
||||
"1w", "weeks",
|
||||
"1d", "days"
|
||||
);
|
||||
|
||||
@@ -237,6 +238,7 @@ public class Ta4jStorage {
|
||||
case "30m" -> Duration.ofMinutes(30);
|
||||
case "1h" -> Duration.ofHours(1);
|
||||
case "4h" -> Duration.ofHours(4);
|
||||
case "1w" -> Duration.ofDays(7);
|
||||
case "1d" -> Duration.ofDays(1);
|
||||
default -> Duration.ofMinutes(1);
|
||||
};
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ public class CandleGapBackfillService {
|
||||
@Value("${upbit.api.request-delay-ms:110}")
|
||||
private long requestDelayMs;
|
||||
|
||||
private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"};
|
||||
private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"};
|
||||
|
||||
private volatile long lastRunAtMs;
|
||||
private final AtomicInteger lastRunMarkets = new AtomicInteger();
|
||||
|
||||
@@ -32,9 +32,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* 명세서 3.2 준수:
|
||||
* - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신
|
||||
* - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기
|
||||
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파
|
||||
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d)으로 집계 전파
|
||||
*
|
||||
* 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d
|
||||
* 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -50,8 +50,8 @@ public class BarBuilder {
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
|
||||
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 1440};
|
||||
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"};
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
|
||||
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"};
|
||||
|
||||
/** 진행 중인 1분봉 상태 (market → PartialBar) */
|
||||
private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- ============================================================
|
||||
-- V42: gc_app_settings — 차트 pane 구분선 옵션 JSON
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE gc_app_settings
|
||||
ADD COLUMN chart_pane_separator_json JSON NULL
|
||||
COMMENT '차트 영역(캔들·거래량·보조지표 pane) 구분선: visible,color,width,lineStyle';
|
||||
+22
@@ -55,6 +55,28 @@ class StrategyConditionTimeframeServiceTest {
|
||||
assertFalse(service.usesTimeframe(1L, "1m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_readsMultipleCandleTypesFromTimeframeNode() {
|
||||
GcStrategy multi = new GcStrategy();
|
||||
multi.setId(3L);
|
||||
multi.setBuyConditionJson("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||
}]
|
||||
}
|
||||
""");
|
||||
when(strategyRepo.findById(3L)).thenReturn(Optional.of(multi));
|
||||
|
||||
Set<String> tfs = service.collectForStrategy(3L);
|
||||
assertEquals(Set.of("1m", "3m", "5m"), tfs);
|
||||
assertTrue(service.usesTimeframe(3L, "3m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectForStrategy_legacyTreeDefaultsTo1m() {
|
||||
GcStrategy legacy = new GcStrategy();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StrategyDslTimeframeNormalizerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final StrategyDslTimeframeNormalizer normalizer = new StrategyDslTimeframeNormalizer(objectMapper);
|
||||
|
||||
@Test
|
||||
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||
}]
|
||||
}
|
||||
""";
|
||||
String normalized = normalizer.normalizeJson(json, "5분봉 테스트 전략");
|
||||
JsonNode root = objectMapper.readTree(normalized);
|
||||
assertTrue(root.path("candleTypes").isArray());
|
||||
assertEquals(3, root.path("candleTypes").size());
|
||||
assertEquals("1m", root.path("candleTypes").get(0).asText());
|
||||
assertEquals("3m", root.path("candleTypes").get(1).asText());
|
||||
assertEquals("5m", root.path("candleTypes").get(2).asText());
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* START candleTypes → BarBuilder 게이트(usesTimeframe) → TriggerBranch 평가 연결 검증.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StrategyEvaluationTimeframeIntegrationTest {
|
||||
|
||||
@Mock
|
||||
private GcStrategyRepository strategyRepo;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock
|
||||
private StrategyDslTimeframeNormalizer dslNormalizer;
|
||||
|
||||
@Mock
|
||||
private LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
|
||||
private StrategyConditionTimeframeService timeframeService;
|
||||
|
||||
private StrategyTriggerBranchEvaluator triggerEvaluator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
timeframeService = new StrategyConditionTimeframeService(
|
||||
strategyRepo, objectMapper, dslNormalizer, liveStrategyEvaluator);
|
||||
triggerEvaluator = new StrategyTriggerBranchEvaluator(
|
||||
new StrategyDslToTa4jAdapter(), objectMapper, new StrategyBranchStateCache());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkedTimeframes_gateBarCloseAndTriggerScope() throws Exception {
|
||||
String buyJson = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI", "conditionType": "GT",
|
||||
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
GcStrategy strategy = new GcStrategy();
|
||||
strategy.setId(100L);
|
||||
strategy.setName("multi tf test");
|
||||
strategy.setBuyConditionJson(buyJson);
|
||||
when(strategyRepo.findById(100L)).thenReturn(Optional.of(strategy));
|
||||
when(dslNormalizer.normalizeJson(buyJson, strategy.getName())).thenReturn(buyJson);
|
||||
|
||||
Set<String> collected = timeframeService.collectForStrategy(100L);
|
||||
assertEquals(Set.of("1m", "3m", "5m"), collected);
|
||||
|
||||
assertTrue(timeframeService.usesTimeframe(100L, "1m"));
|
||||
assertTrue(timeframeService.usesTimeframe(100L, "3m"));
|
||||
assertTrue(timeframeService.usesTimeframe(100L, "5m"));
|
||||
assertFalse(timeframeService.usesTimeframe(100L, "15m"));
|
||||
|
||||
var scope = triggerEvaluator.parseScope(buyJson);
|
||||
assertEquals("OR", scope.combineOp());
|
||||
assertEquals(3, scope.branches().size());
|
||||
assertTrue(scope.branches().stream().anyMatch(b -> "3m".equals(b.candleType())));
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,52 @@ class StrategyTriggerBranchEvaluatorTest {
|
||||
assertTrue(rule1m.isSatisfied(idx1m, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiCandleTypes_sameSubtree_onlyEvaluatesTriggerBranch() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleType": "1m",
|
||||
"candleTypes": ["1m", "3m", "5m"],
|
||||
"children": [{
|
||||
"type": "CONDITION", "condition": {
|
||||
"indicatorType": "RSI", "conditionType": "GT",
|
||||
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
|
||||
var scope = evaluator.parseScope(json);
|
||||
assertEquals("OR", scope.combineOp());
|
||||
assertEquals(3, scope.branches().size());
|
||||
assertEquals("1m", scope.branches().get(0).candleType());
|
||||
assertEquals("3m", scope.branches().get(1).candleType());
|
||||
assertEquals("5m", scope.branches().get(2).candleType());
|
||||
|
||||
Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||
"1m", Map.of(), storage);
|
||||
BarSeries s1m = storage.getOrCreate(MARKET, "1m");
|
||||
on1m.isSatisfied(s1m.getEndIndex(), null);
|
||||
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
|
||||
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m"),
|
||||
"1m 마감 트리거 시 3m 분기는 평가·캐시되지 않아야 함");
|
||||
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "5m"));
|
||||
|
||||
branchCache.invalidateStrategy(STRATEGY_ID);
|
||||
Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||
"3m", Map.of(), storage);
|
||||
BarSeries s3m = storage.getOrCreate(MARKET, "3m");
|
||||
on3m.isSatisfied(s3m.getEndIndex(), null);
|
||||
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m"));
|
||||
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
|
||||
|
||||
Rule on5mWrongTrigger = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||
"1m", Map.of(), storage);
|
||||
assertFalse(on5mWrongTrigger.isSatisfied(s3m.getEndIndex(), null),
|
||||
"3m 인덱스로 1m 트리거 Rule 평가 시 false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void single3mScope_onlyTriggersOn3mBranch() throws Exception {
|
||||
String json = """
|
||||
|
||||
+92
-7
@@ -2797,6 +2797,34 @@ html.theme-blue {
|
||||
color: var(--text2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ism-hist-colors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ism-hist-color-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ism-macd-hist-style-block {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
}
|
||||
.ism-macd-hist-style-title {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.ism-plot-title--sub {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
.ism-style-field--wide {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 색상 입력 */
|
||||
.ism-color-wrap { display: flex; align-items: center; gap: 4px; position: relative; }
|
||||
.ism-color-swatch-btn {
|
||||
@@ -3012,9 +3040,24 @@ html.theme-blue {
|
||||
border-radius: 8px;
|
||||
background: var(--bg2);
|
||||
}
|
||||
.ism-ichimoku-cloud-card-label {
|
||||
display: block;
|
||||
.ism-ichimoku-cloud-card--off {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.ism-ichimoku-cloud-card--off .ism-ichimoku-cloud-card-row,
|
||||
.ism-ichimoku-cloud-card--off .ism-ichimoku-cloud-meta {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ism-ichimoku-cloud-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ism-ichimoku-cloud-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ism-ichimoku-cloud-card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
@@ -9652,7 +9695,7 @@ html.theme-blue {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(4px, 1.2cqi, 10px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -9709,13 +9752,28 @@ html.theme-blue {
|
||||
}
|
||||
|
||||
.rsp-trade-card-body .top-price-qty-block {
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(4px, 1cqi, 8px);
|
||||
}
|
||||
|
||||
.rsp-trade-card-body .top-pct-row--block {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.rsp-trade-card-body .top-pct-row--block .top-pct-btn {
|
||||
flex: 1 1 calc(20% - 4px);
|
||||
min-width: 2.4rem;
|
||||
}
|
||||
|
||||
.rsp-trade-card-body .top-pct-row--block .top-pct-btn--direct {
|
||||
flex: 1 1 calc(33% - 4px);
|
||||
min-width: 3.2rem;
|
||||
}
|
||||
/* 가격·수량: 기본 세로 배치 */
|
||||
.top-price-qty-block {
|
||||
display: flex;
|
||||
@@ -9972,11 +10030,14 @@ html.theme-blue {
|
||||
}
|
||||
.top-pct-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
row-gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.top-pct-btn {
|
||||
flex: 1;
|
||||
flex: 1 1 calc(20% - 4px);
|
||||
min-width: 2.4rem;
|
||||
padding: 5px 2px;
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -9986,7 +10047,8 @@ html.theme-blue {
|
||||
cursor: pointer;
|
||||
}
|
||||
.top-pct-btn--direct {
|
||||
flex: 1.3;
|
||||
flex: 1 1 calc(33% - 4px);
|
||||
min-width: 3.2rem;
|
||||
}
|
||||
.top-pct-btn--active {
|
||||
border-color: var(--accent);
|
||||
@@ -11289,6 +11351,29 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
.tnl-page--with-right .ptd-ob-stack--fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 매매 시그널 알림 우측: 좁은 카드에서도 주문 비율·총액이 겹치지 않도록 */
|
||||
.tnl-page--with-right .ptd-split-panel--right .ptd-order-card > .top-panel {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-panel-fields {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-actions {
|
||||
margin-top: auto;
|
||||
}
|
||||
@container trade-card (max-height: 400px) {
|
||||
.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-meta {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
.tnl-row--selected {
|
||||
border-color: var(--accent, #7aa2f7);
|
||||
box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.35);
|
||||
|
||||
@@ -596,6 +596,7 @@ function App() {
|
||||
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
|
||||
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
|
||||
const chartLegendOptions = appDefaults.chartLegendOptions;
|
||||
const chartPaneSeparator = appDefaults.chartPaneSeparator;
|
||||
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
||||
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
||||
const [paperCashBalance, setPaperCashBalance] = useState(0);
|
||||
@@ -970,6 +971,12 @@ function App() {
|
||||
slotRefs.current.forEach(slot => slot?.setVolumeVisible(chartVolumeVisible));
|
||||
}, [appSettingsLoaded, chartVolumeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appSettingsLoaded) return;
|
||||
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator));
|
||||
}, [appSettingsLoaded, chartPaneSeparator]);
|
||||
|
||||
// mainChartStyle 변경 → ChartManager 즉시 적용
|
||||
useEffect(() => {
|
||||
managerRef.current?.updateMainSeriesStyle(mainChartStyle);
|
||||
@@ -1791,6 +1798,12 @@ function App() {
|
||||
chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record<string, boolean>,
|
||||
});
|
||||
}}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
onChartPaneSeparatorChange={opts => {
|
||||
saveAppDef({ chartPaneSeparator: opts });
|
||||
managerRef.current?.setPaneSeparatorOptions(opts);
|
||||
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts));
|
||||
}}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
onPaperTradingEnabled={v => saveAppDef({ paperTradingEnabled: v })}
|
||||
paperInitialCapital={appDefaults.paperInitialCapital}
|
||||
@@ -2096,6 +2109,7 @@ function App() {
|
||||
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartVolumeVisible={chartVolumeVisible}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||
displayTimezone={displayTimezone}
|
||||
@@ -2134,6 +2148,7 @@ function App() {
|
||||
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartVolumeVisible={chartVolumeVisible}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||
displayTimezone={displayTimezone}
|
||||
@@ -2204,6 +2219,7 @@ function App() {
|
||||
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
||||
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
||||
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
syncBacktestMarkersRef.current();
|
||||
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
||||
@@ -2243,6 +2259,8 @@ function App() {
|
||||
onDuplicateIndicator={handleDuplicateIndicator}
|
||||
focusedIndicatorId={focusedIndicatorId}
|
||||
onRestoreIndicators={handleRestoreIndicators}
|
||||
volumeVisible={chartVolumeVisible}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
|
||||
{/* 백테스팅 결과 통계 배지 */}
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle,
|
||||
} from '../types';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
|
||||
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
||||
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
@@ -112,6 +113,7 @@ export interface ChartSlotHandle {
|
||||
setSeriesPriceLabelsEnabled: (enabled: boolean) => void;
|
||||
/** 거래량 pane on/off */
|
||||
setVolumeVisible: (visible: boolean) => void;
|
||||
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => void;
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
@@ -172,6 +174,8 @@ export interface ChartSlotProps {
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
/** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */
|
||||
chartVisible?: boolean;
|
||||
/** pane 구분선 */
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||
@@ -195,6 +199,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
compactMode = false,
|
||||
chartLiveReceiveHighlight = true,
|
||||
chartVisible = true,
|
||||
chartPaneSeparator,
|
||||
}, ref) {
|
||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||
@@ -309,6 +314,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
setVolumeVisible: (visible: boolean) => {
|
||||
managerRef.current?.setVolumeVisible(visible);
|
||||
},
|
||||
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => {
|
||||
managerRef.current?.setPaneSeparatorOptions(opts);
|
||||
},
|
||||
}), []);
|
||||
|
||||
// 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영
|
||||
@@ -324,6 +332,11 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
managerRef.current?.setVolumeVisible(chartVolumeVisible);
|
||||
}, [chartVolumeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartPaneSeparator) return;
|
||||
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
}, [chartPaneSeparator]);
|
||||
|
||||
// 외부 심볼 동기화
|
||||
useEffect(() => {
|
||||
if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol);
|
||||
@@ -806,6 +819,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
drawingsVisible={!compactMode}
|
||||
showHoverToolbar={!compactMode}
|
||||
volumeVisible={chartVolumeVisible}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
onCrosshair={data => {
|
||||
setLegend(data);
|
||||
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
|
||||
@@ -824,6 +838,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||
mgr.setVolumeVisible(chartVolumeVisible);
|
||||
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
// Time sync: 가시 시간 범위 변화 구독
|
||||
mgr.subscribeVisibleTimeRange(r => {
|
||||
if (!r || isSyncingTimeRef.current) return;
|
||||
|
||||
@@ -11,9 +11,10 @@ import ColorPickerPanel from './ColorPickerPanel';
|
||||
export interface ColorInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
|
||||
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = false }) => {
|
||||
const { hex6, alpha } = parsePlotColor(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -88,13 +89,15 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="ism-color-wrap" ref={anchorRef}>
|
||||
<div className={`ism-color-wrap${disabled ? ' ism-color-wrap--disabled' : ''}`} ref={anchorRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="ism-color-swatch-btn"
|
||||
title="색상 선택"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setOpen(v => !v);
|
||||
if (!open) requestAnimationFrame(updatePosition);
|
||||
}}
|
||||
@@ -106,12 +109,14 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
|
||||
className="ism-color-picker ism-color-picker--compact"
|
||||
value={hex6}
|
||||
title="브라우저 색상 선택"
|
||||
disabled={disabled}
|
||||
onChange={e => patchHex(e.target.value)}
|
||||
/>
|
||||
<NumericParamInput
|
||||
className="ism-input ism-alpha-input"
|
||||
value={alpha}
|
||||
spec={getOpacityPercentSpec(alpha)}
|
||||
disabled={disabled}
|
||||
onChange={patchAlpha}
|
||||
/>
|
||||
<span className="ism-alpha-label">%</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ import NumericParamInput from './NumericParamInput';
|
||||
import PlotLineStylePicker from './PlotLineStylePicker';
|
||||
import ColorInput from './ColorInput';
|
||||
import { colorToRgbaString, cloudColorOpacityPercent } from '../utils/ichimokuConfig';
|
||||
import { resolveHistogramUpColor, resolveHistogramDownColor } from '../utils/plotColorUtils';
|
||||
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
|
||||
const SRC_OPTIONS = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4'];
|
||||
@@ -187,9 +188,29 @@ export const PlotSettingsRow: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
{!inputsOnly && (
|
||||
isHistogram ? (
|
||||
isHistogram && indicatorType === 'MACD' ? (
|
||||
<div className="ism-hist-colors">
|
||||
<div className="ism-hist-color-field">
|
||||
<span className="ism-style-label">상승</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramUpColor(plot)}
|
||||
disabled={!enabled}
|
||||
onChange={v => onPlotStyle(plotIndex, { histogramUpColor: v, color: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-hist-color-field">
|
||||
<span className="ism-style-label">하락</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramDownColor(plot)}
|
||||
disabled={!enabled}
|
||||
onChange={v => onPlotStyle(plotIndex, { histogramDownColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : isHistogram ? (
|
||||
<ColorInput
|
||||
value={plot.color}
|
||||
disabled={!enabled}
|
||||
onChange={v => onPlotStyle(plotIndex, { color: v })}
|
||||
/>
|
||||
) : (
|
||||
@@ -339,45 +360,72 @@ export const SettingsOutputFooter: React.FC<{
|
||||
export const IchimokuCloudSection: React.FC<{
|
||||
cloudColors: IchimokuCloudColors;
|
||||
onChange: (patch: Partial<IchimokuCloudColors>) => void;
|
||||
}> = ({ cloudColors, onChange }) => (
|
||||
<>
|
||||
<div className="ism-ichimoku-cloud-divider" />
|
||||
<div className="ism-ichimoku-cloud-section">
|
||||
<h4 className="ism-ichimoku-cloud-title">구름 영역 색상</h4>
|
||||
<p className="ism-ichimoku-cloud-hint">
|
||||
선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름이 표시됩니다.
|
||||
</p>
|
||||
<div className="ism-ichimoku-cloud-grid">
|
||||
<div className="ism-ichimoku-cloud-card">
|
||||
<span className="ism-ichimoku-cloud-card-label">상승 구름 (선행1 > 선행2)</span>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bullishColor}
|
||||
onChange={v => onChange({ bullishColor: v })}
|
||||
/>
|
||||
}> = ({ cloudColors, onChange }) => {
|
||||
const bullishOn = cloudColors.bullishVisible !== false;
|
||||
const bearishOn = cloudColors.bearishVisible !== false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ism-ichimoku-cloud-divider" />
|
||||
<div className="ism-ichimoku-cloud-section">
|
||||
<h4 className="ism-ichimoku-cloud-title">구름 영역</h4>
|
||||
<p className="ism-ichimoku-cloud-hint">
|
||||
선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름을 그릴 수 있습니다. 상승·하락 구름은 각각 on/off 할 수 있습니다.
|
||||
</p>
|
||||
<div className="ism-ichimoku-cloud-grid">
|
||||
<div className={`ism-ichimoku-cloud-card${bullishOn ? '' : ' ism-ichimoku-cloud-card--off'}`}>
|
||||
<div className="ism-ichimoku-cloud-card-head">
|
||||
<label className="ism-toggle ism-ichimoku-cloud-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bullishOn}
|
||||
onChange={e => onChange({ bullishVisible: e.target.checked })}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<span className="ism-ichimoku-cloud-card-label">상승 구름 (선행1 > 선행2)</span>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bullishColor}
|
||||
disabled={!bullishOn}
|
||||
onChange={v => onChange({ bullishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bullishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bullishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-card">
|
||||
<span className="ism-ichimoku-cloud-card-label">하락 구름 (선행1 < 선행2)</span>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bearishColor}
|
||||
onChange={v => onChange({ bearishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bearishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}%
|
||||
<div className={`ism-ichimoku-cloud-card${bearishOn ? '' : ' ism-ichimoku-cloud-card--off'}`}>
|
||||
<div className="ism-ichimoku-cloud-card-head">
|
||||
<label className="ism-toggle ism-ichimoku-cloud-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bearishOn}
|
||||
onChange={e => onChange({ bearishVisible: e.target.checked })}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<span className="ism-ichimoku-cloud-card-label">하락 구름 (선행1 < 선행2)</span>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bearishColor}
|
||||
disabled={!bearishOn}
|
||||
onChange={v => onChange({ bearishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bearishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 볼린저밴드 백그라운드 (업비트 모습 탭) ───────────────────── */
|
||||
export const BollingerBackgroundRow: React.FC<{
|
||||
|
||||
@@ -9,7 +9,12 @@ import { getPlotLabel } from '../utils/indicatorLabels';
|
||||
import { getHlinePriceSpec } from '../utils/indicatorParamSpec';
|
||||
import NumericParamInput from './NumericParamInput';
|
||||
import { ColorInput } from './IndicatorSettingsSections';
|
||||
import { LINE_WIDTH_OPTIONS, LINE_STYLE_OPTIONS } from '../utils/plotColorUtils';
|
||||
import {
|
||||
LINE_WIDTH_OPTIONS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
resolveHistogramUpColor,
|
||||
resolveHistogramDownColor,
|
||||
} from '../utils/plotColorUtils';
|
||||
import { IchimokuCloudSection } from './IndicatorSettingsSections';
|
||||
import { getIchimokuPlotTitle, type IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
|
||||
@@ -199,12 +204,46 @@ const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps
|
||||
|
||||
<div ref={sectionRef} className="ism-style-section">
|
||||
{plots.map((plot, idx) => (
|
||||
<PlotStyleRow
|
||||
key={plot.id}
|
||||
label={plotLabel(plot)}
|
||||
plot={plot}
|
||||
onStyle={patch => onPlotStyle(idx, patch)}
|
||||
/>
|
||||
indicatorType === 'MACD' && plot.type === 'histogram' ? (
|
||||
<div key={plot.id} className="ism-macd-hist-style-block">
|
||||
<div className="ism-style-row ism-macd-hist-style-title">
|
||||
<div className="ism-plot-title-cell">
|
||||
<span className="ism-plot-title">{plotLabel(plot)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-style-row">
|
||||
<div className="ism-plot-title-cell">
|
||||
<span className="ism-plot-title ism-plot-title--sub">상승 막대</span>
|
||||
</div>
|
||||
<div className="ism-style-field ism-style-field--wide">
|
||||
<span className="ism-style-label">색상</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramUpColor(plot)}
|
||||
onChange={v => onPlotStyle(idx, { histogramUpColor: v, color: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-style-row">
|
||||
<div className="ism-plot-title-cell">
|
||||
<span className="ism-plot-title ism-plot-title--sub">하락 막대</span>
|
||||
</div>
|
||||
<div className="ism-style-field ism-style-field--wide">
|
||||
<span className="ism-style-label">색상</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramDownColor(plot)}
|
||||
onChange={v => onPlotStyle(idx, { histogramDownColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PlotStyleRow
|
||||
key={plot.id}
|
||||
label={plotLabel(plot)}
|
||||
plot={plot}
|
||||
onStyle={patch => onPlotStyle(idx, patch)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
|
||||
{hlines.length > 0 && (
|
||||
|
||||
@@ -252,12 +252,51 @@ function assignLayoutsByPaneIndex(
|
||||
const lay = byPane.get(host.paneIndex);
|
||||
if (!lay || lay.height < MIN_SUB_PANE_HEIGHT) continue;
|
||||
for (const item of slot.items) {
|
||||
if (item.layoutTopY != null) continue;
|
||||
item.layoutTopY = lay.topY;
|
||||
item.layoutHeight = lay.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 화면 순서(위→아래)의 활성 sub-pane ↔ 슬롯 순서 — orphan pane index 와 분리 */
|
||||
function assignLayoutsBySubPaneOrder(
|
||||
slots: VisualSlot[],
|
||||
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
|
||||
): void {
|
||||
for (let i = 0; i < slots.length && i < subLayouts.length; i++) {
|
||||
const lay = subLayouts[i];
|
||||
for (const item of slots[i].items) {
|
||||
if (item.layoutTopY != null) continue;
|
||||
item.layoutTopY = lay.topY;
|
||||
item.layoutHeight = lay.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** paneIndex·orphan pane 으로 어긋난 좌표를 슬롯 순서 기준 sub-pane 위치로 보정 */
|
||||
function reconcileLayoutsWithSubPanes(
|
||||
slots: VisualSlot[],
|
||||
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
|
||||
): void {
|
||||
const TOL = 24;
|
||||
for (let i = 0; i < slots.length && i < subLayouts.length; i++) {
|
||||
const expected = subLayouts[i];
|
||||
for (const item of slots[i].items) {
|
||||
const cur = item.layoutTopY;
|
||||
const curH = item.layoutHeight;
|
||||
if (
|
||||
cur == null
|
||||
|| Math.abs(cur - expected.topY) > TOL
|
||||
|| (curH != null && Math.abs(curH - expected.height) > TOL)
|
||||
) {
|
||||
item.layoutTopY = expected.topY;
|
||||
item.layoutHeight = expected.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPaneItems(
|
||||
manager: ChartManager,
|
||||
containerEl: HTMLElement | null,
|
||||
@@ -301,7 +340,12 @@ function buildPaneItems(
|
||||
}
|
||||
|
||||
const slots = buildVisualSlots(items, indicators);
|
||||
const subLayouts = manager.getIndicatorSubPaneLayouts();
|
||||
assignLayoutsBySubPaneOrder(slots, subLayouts);
|
||||
assignLayoutsByPaneIndex(slots, manager.getPaneLayouts());
|
||||
if (subLayouts.length > 0) {
|
||||
reconcileLayoutsWithSubPanes(slots, subLayouts);
|
||||
}
|
||||
|
||||
if (containerEl?.isConnected) {
|
||||
const missing = items.filter(i => i.layoutTopY == null);
|
||||
@@ -319,7 +363,7 @@ function buildPaneItems(
|
||||
}
|
||||
}
|
||||
|
||||
return items.filter(i => i.layoutTopY != null && i.layoutHeight != null);
|
||||
return items;
|
||||
}
|
||||
|
||||
function paneItemLayout(
|
||||
@@ -457,10 +501,18 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
setTimeout(syncPaneItems, 900),
|
||||
setTimeout(syncPaneItems, 1200),
|
||||
setTimeout(syncPaneItems, 1800),
|
||||
setTimeout(syncPaneItems, 2500),
|
||||
];
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(syncPaneItems);
|
||||
});
|
||||
}, [syncPaneItems]);
|
||||
|
||||
useEffect(() => { scheduleRetry(); return () => retryRef.current.forEach(clearTimeout); }, [scheduleRetry]);
|
||||
useEffect(() => {
|
||||
setPaneItems([]);
|
||||
scheduleRetry();
|
||||
return () => retryRef.current.forEach(clearTimeout);
|
||||
}, [scheduleRetry]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = manager.subscribePaneLayout(() => scheduleRetry());
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import { plotColorCss } from '../utils/plotColorUtils';
|
||||
|
||||
interface Props {
|
||||
manager: ChartManager;
|
||||
options: ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
function applyLineDash(ctx: CanvasRenderingContext2D, style: ChartPaneSeparatorOptions['lineStyle']) {
|
||||
if (style === 'dashed') ctx.setLineDash([6, 4]);
|
||||
else if (style === 'dotted') ctx.setLineDash([2, 3]);
|
||||
else ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = manager.getContainer();
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const cssW = container.clientWidth;
|
||||
const cssH = container.clientHeight;
|
||||
if (cssW <= 0 || cssH <= 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(cssW * dpr);
|
||||
canvas.height = Math.round(cssH * dpr);
|
||||
canvas.style.width = `${cssW}px`;
|
||||
canvas.style.height = `${cssH}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
if (!options.visible) return;
|
||||
|
||||
const layouts = manager.getPaneLayouts()
|
||||
.filter(l => l.height > 8)
|
||||
.sort((a, b) => a.topY - b.topY);
|
||||
if (layouts.length < 2) return;
|
||||
|
||||
ctx.strokeStyle = plotColorCss(options.color);
|
||||
ctx.lineWidth = options.width;
|
||||
applyLineDash(ctx, options.lineStyle);
|
||||
|
||||
for (let i = 0; i < layouts.length - 1; i++) {
|
||||
const y = layouts[i].topY + layouts[i].height;
|
||||
const lineY = y + options.width / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, lineY);
|
||||
ctx.lineTo(cssW, lineY);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [manager, options]);
|
||||
|
||||
useEffect(() => {
|
||||
redraw();
|
||||
const unsub = manager.subscribePaneLayout(redraw);
|
||||
const container = manager.getContainer();
|
||||
const ro = new ResizeObserver(() => redraw());
|
||||
if (container) ro.observe(container);
|
||||
return () => {
|
||||
unsub();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [manager, redraw]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pane-separator-overlay"
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaneSeparatorOverlay;
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
CHART_LEGEND_SETTING_ITEMS,
|
||||
type ChartLegendVisibility,
|
||||
} from '../types/chartLegend';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import {
|
||||
LINE_STYLE_LABELS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
LINE_WIDTH_OPTIONS,
|
||||
} from '../utils/plotColorUtils';
|
||||
import TimezonePicker from './TimezonePicker';
|
||||
import AdminPasswordGate from './AdminPasswordGate';
|
||||
import AdminSettingsPanel from './AdminSettingsPanel';
|
||||
@@ -108,6 +114,8 @@ interface SettingsPageProps {
|
||||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||
chartLegendOptions?: ChartLegendVisibility;
|
||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
paperTradingEnabled?: boolean;
|
||||
onPaperTradingEnabled?: (v: boolean) => void;
|
||||
paperInitialCapital?: number;
|
||||
@@ -638,6 +646,8 @@ interface ChartPanelProps {
|
||||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||
chartLegendOptions?: ChartLegendVisibility;
|
||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
}
|
||||
|
||||
const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
@@ -652,6 +662,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
onChartLiveReceiveHighlight,
|
||||
chartLegendOptions,
|
||||
onChartLegendOptionsChange,
|
||||
chartPaneSeparator,
|
||||
onChartPaneSeparatorChange,
|
||||
}) => {
|
||||
const [upColor, setUp] = useState('#26a69a');
|
||||
const [downColor, setDown] = useState('#ef5350');
|
||||
@@ -758,6 +770,75 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{chartPaneSeparator && onChartPaneSeparatorChange && (
|
||||
<SettingSection title="차트 영역 구분선">
|
||||
<SettingRow
|
||||
label="구분선 표시"
|
||||
desc="캔들·거래량·보조지표 pane 사이에 구분선을 표시합니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={chartPaneSeparator.visible}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
visible: e.target.checked,
|
||||
})}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
{chartPaneSeparator.visible && (
|
||||
<>
|
||||
<SettingRow label="선 색상" desc="pane 사이 구분선 색상입니다.">
|
||||
<div className="stg-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="stg-color"
|
||||
value={chartPaneSeparator.color.startsWith('#')
|
||||
? chartPaneSeparator.color.slice(0, 7)
|
||||
: '#2a2e3a'}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
color: e.target.value,
|
||||
})}
|
||||
/>
|
||||
<span className="stg-color-label">{chartPaneSeparator.color}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 굵기" desc="구분선 두께(px)입니다.">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={chartPaneSeparator.width}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
width: Number(e.target.value) as ChartPaneSeparatorOptions['width'],
|
||||
})}
|
||||
>
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<option key={w} value={w}>{w}px</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 스타일" desc="실선·점선·도트 중 선택합니다.">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={chartPaneSeparator.lineStyle}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
lineStyle: e.target.value as ChartPaneSeparatorOptions['lineStyle'],
|
||||
})}
|
||||
>
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
{chartLegendOptions && onChartLegendOptionsChange && (
|
||||
<SettingSection title="상단 정보 표시">
|
||||
{CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => (
|
||||
@@ -1443,6 +1524,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartLiveReceiveHighlight,
|
||||
chartLegendOptions,
|
||||
onChartLegendOptionsChange,
|
||||
chartPaneSeparator,
|
||||
onChartPaneSeparatorChange,
|
||||
tradeAlertSoundEnabled = true,
|
||||
onTradeAlertSoundEnabled,
|
||||
tradeAlertSound = 'bell',
|
||||
@@ -1533,6 +1616,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
|
||||
chartLegendOptions={chartLegendOptions}
|
||||
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
||||
/>
|
||||
);
|
||||
case 'indicators':
|
||||
|
||||
@@ -363,7 +363,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const handleStartMetaChange = useCallback((meta: Record<string, { candleType: string }>) => {
|
||||
const handleStartMetaChange = useCallback((meta: Record<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
@@ -51,6 +51,8 @@ import CandlePaneControls from './CandlePaneControls';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
|
||||
import { classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
|
||||
|
||||
interface TradingChartProps {
|
||||
bars: OHLCVBar[];
|
||||
@@ -126,6 +128,8 @@ interface TradingChartProps {
|
||||
seriesPriceLabelsEnabled?: boolean;
|
||||
/** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */
|
||||
chartVisible?: boolean;
|
||||
/** pane(캔들·거래량·보조지표) 구분선 */
|
||||
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -156,6 +160,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
showHoverToolbar = true,
|
||||
seriesPriceLabelsEnabled = true,
|
||||
chartVisible = true,
|
||||
paneSeparatorOptions,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -207,6 +212,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||
const indicatorSyncInFlightRef = useRef(false);
|
||||
const indicatorReloadGenRef = useRef(0);
|
||||
const prevMarket = useRef<string>(market);
|
||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||
const lastAppliedMarketRef = useRef<string>('');
|
||||
@@ -281,6 +287,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
managerRef.current?.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
|
||||
}, [seriesPriceLabelsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paneSeparatorOptions) return;
|
||||
managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
|
||||
}, [paneSeparatorOptions]);
|
||||
|
||||
/**
|
||||
* pane 높이 재배분 + 스크롤 컨테이너 확장
|
||||
*
|
||||
@@ -409,18 +420,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
) => {
|
||||
const cover = createIndicatorReloadCover(containerRef.current);
|
||||
const savedLR = mgr.getVisibleLogicalRange();
|
||||
const reloadGen = ++indicatorReloadGenRef.current;
|
||||
|
||||
indicatorSyncInFlightRef.current = true;
|
||||
void mgr.reloadIndicatorsOnly(inds).then(() => {
|
||||
if (reloadGen !== indicatorReloadGenRef.current) {
|
||||
fadeOutIndicatorReloadCover(cover);
|
||||
return;
|
||||
}
|
||||
afterIndicatorPaneMutation(mgr);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (reloadGen !== indicatorReloadGenRef.current) {
|
||||
fadeOutIndicatorReloadCover(cover);
|
||||
return;
|
||||
}
|
||||
restoreLogicalRange(mgr, savedLR);
|
||||
fadeOutIndicatorReloadCover(cover);
|
||||
indicatorSyncInFlightRef.current = false;
|
||||
onComplete?.();
|
||||
}));
|
||||
});
|
||||
}, [applyPaneLayout, afterIndicatorPaneMutation, restoreLogicalRange]);
|
||||
}, [afterIndicatorPaneMutation, restoreLogicalRange]);
|
||||
|
||||
const queueFullReload = useCallback(() => {
|
||||
if (!chartVisibleRef.current) return;
|
||||
@@ -635,6 +655,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined);
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
|
||||
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
|
||||
onManagerReady(mgr);
|
||||
|
||||
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
||||
@@ -1251,6 +1272,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && paneSeparatorOptions && (
|
||||
<PaneSeparatorOverlay
|
||||
manager={chartMgr}
|
||||
options={paneSeparatorOptions}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && (
|
||||
<DrawingCanvas
|
||||
manager={chartMgr}
|
||||
@@ -1403,7 +1430,7 @@ function sortedParamKey(inds: IndicatorConfig[]): string {
|
||||
*/
|
||||
function styleKey(inds: IndicatorConfig[]): string {
|
||||
return inds.map(i =>
|
||||
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}`
|
||||
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}`
|
||||
).join(';');
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,7 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={defaults.chartPaneSeparator}
|
||||
tickers={tickers}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
isInTargets={isInTargets}
|
||||
|
||||
@@ -55,6 +55,10 @@ import {
|
||||
} from '../utils/virtualLiveStrategySync';
|
||||
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
||||
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
||||
import {
|
||||
coalesceTargetsToDefaultStrategy,
|
||||
resolveVirtualTargetStrategyId,
|
||||
} from '../utils/virtualTargetStrategy';
|
||||
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
@@ -100,6 +104,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
|
||||
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
||||
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
||||
const chartPaneSeparator = appChartDefaults.chartPaneSeparator;
|
||||
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
|
||||
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
|
||||
|
||||
@@ -136,6 +141,13 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
|
||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||
|
||||
/** 예전 데이터: 종목 strategyId에 전역 ID가 복사된 경우 → 기본 전략 따름(null) */
|
||||
useEffect(() => {
|
||||
if (session.globalStrategyId == null) return;
|
||||
setTargets(prev => coalesceTargetsToDefaultStrategy(prev, session.globalStrategyId));
|
||||
}, [session.globalStrategyId]);
|
||||
|
||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||
|
||||
const strategyNames = useMemo(
|
||||
@@ -160,7 +172,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
const targetRefs = useMemo(() =>
|
||||
targets.map(t => ({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||
})),
|
||||
[targets, session.globalStrategyId]);
|
||||
|
||||
@@ -302,22 +314,24 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
setTargets(prev => prev.map(t =>
|
||||
t.market === market ? { ...t, strategyId } : t,
|
||||
));
|
||||
if (!session.running || !strategyId) return;
|
||||
if (!session.running) return;
|
||||
const effectiveId = resolveVirtualTargetStrategyId({ strategyId }, session.globalStrategyId);
|
||||
if (effectiveId == null) return;
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId,
|
||||
strategyId: effectiveId,
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
skipWatchlistSync: true,
|
||||
skipGlobalTemplate: true,
|
||||
});
|
||||
await pinStrategyEvaluationTimeframes(market, strategyId);
|
||||
await pinStrategyEvaluationTimeframes(market, effectiveId);
|
||||
} catch {
|
||||
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
||||
}
|
||||
}, [session, targets]);
|
||||
}, [session]);
|
||||
|
||||
const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => {
|
||||
const normalized = normalizeStartCandleType(candleType);
|
||||
@@ -326,7 +340,9 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
));
|
||||
if (!session.running) return;
|
||||
const target = targets.find(t => t.market === market);
|
||||
const strategyId = target?.strategyId ?? session.globalStrategyId;
|
||||
const strategyId = target
|
||||
? resolveVirtualTargetStrategyId(target, session.globalStrategyId)
|
||||
: session.globalStrategyId;
|
||||
if (!strategyId) return;
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
@@ -469,6 +485,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
tickers={tickers}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
}) => {
|
||||
const timeframe = normalizeChartTimeframe(timeframeRaw);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const { defaults: appDefaults } = useAppSettings();
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -198,6 +200,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
onCandlesReady={onCandlesReady}
|
||||
onAddDrawing={noop}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
paneSeparatorOptions={appDefaults.chartPaneSeparator}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && bars.length === 0 && (
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { memo, useCallback, useState } from 'react';
|
||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||
import {
|
||||
isStartNodeId,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
type HandleSide,
|
||||
type StrategyFlowNodeData,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { getStartCandleTypes } from '../../utils/strategyStartNodes';
|
||||
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
@@ -132,7 +134,9 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
const candleType = d.candleType ?? '1m';
|
||||
const candleTypes = d.candleTypes?.length
|
||||
? d.candleTypes
|
||||
: getStartCandleTypes({ candleType: d.candleType ?? '1m' });
|
||||
const canDelete = id !== '__strategy_start__' && !!d.onDeleteStart;
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
@@ -150,35 +154,32 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-start-head">
|
||||
<div className="se-flow-start-dot" />
|
||||
<span className="se-flow-start-label">START</span>
|
||||
<select
|
||||
className="se-flow-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
onChange={e => d.onStartCandleTypeChange?.(id, e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-del se-flow-del--start"
|
||||
title="START 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
d.onDeleteStart?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div className="se-flow-start-layout">
|
||||
<div className="se-flow-start-title-row">
|
||||
<div className="se-flow-start-title-left">
|
||||
<div className="se-flow-start-dot" />
|
||||
<span className="se-flow-start-label">START</span>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-del se-flow-del--start"
|
||||
title="START 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
d.onDeleteStart?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<StartTimeframeCheckboxes
|
||||
layout="graph"
|
||||
className="se-flow-start-tf-checks-wrap"
|
||||
selected={candleTypes}
|
||||
onChange={types => d.onStartCandleTypesChange?.(id, types)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -206,7 +207,15 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-gate-head">
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
{node.type === 'AND' || node.type === 'OR' ? (
|
||||
<LogicGateOpToggle
|
||||
value={node.type}
|
||||
onChange={op => d.onChangeLogicGateType?.(id, op)}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
)}
|
||||
</div>
|
||||
{(node.children ?? []).length === 0 && (
|
||||
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
||||
|
||||
@@ -94,7 +94,12 @@ function renderNode(node: LogicNode, def: DefType): React.ReactNode {
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const tf = formatStartCandleLabel(node.candleType ?? '1m');
|
||||
const types = node.candleTypes?.length
|
||||
? node.candleTypes
|
||||
: [node.candleType ?? '1m'];
|
||||
const tf = types.length > 1
|
||||
? types.map(formatStartCandleLabel).join(' · ')
|
||||
: formatStartCandleLabel(types[0]);
|
||||
return inner ? (
|
||||
<>
|
||||
<span className="se-formula-tf">[{tf}]</span>
|
||||
@@ -125,11 +130,14 @@ function renderSignalBranches(
|
||||
}
|
||||
|
||||
if (branches.length === 1) {
|
||||
const { candleType, root } = branches[0];
|
||||
const { candleType, candleTypes, root } = branches[0];
|
||||
if (!root) return <span className="se-formula-empty">(연결된 조건 없음)</span>;
|
||||
const tfLabel = candleTypes && candleTypes.length > 1
|
||||
? candleTypes.map(formatStartCandleLabel).join(' · ')
|
||||
: formatStartCandleLabel(candleType);
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-tf">[{formatStartCandleLabel(candleType)}]</span>
|
||||
<span className="se-formula-tf">[{tfLabel}]</span>
|
||||
{' '}
|
||||
{renderNode(root, def)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AND / OR 논리 게이트 — 서로 전환
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { LogicNodeType } from '../../utils/strategyTypes';
|
||||
|
||||
export type LogicGateOp = Extract<LogicNodeType, 'AND' | 'OR'>;
|
||||
|
||||
interface Props {
|
||||
value: LogicGateOp;
|
||||
onChange: (op: LogicGateOp) => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const LogicGateOpToggle: React.FC<Props> = ({ value, onChange, className = '', compact = false }) => {
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-logic-gate-toggle${compact ? ' se-logic-gate-toggle--compact' : ''}${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="논리 연산"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
<div className="se-start-combine-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--and${value === 'AND' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'AND'}
|
||||
onClick={() => onChange('AND')}
|
||||
>
|
||||
AND
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--or${value === 'OR' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'OR'}
|
||||
onClick={() => onChange('OR')}
|
||||
>
|
||||
OR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogicGateOpToggle;
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* START 노드 — 평가 시간봉 다중 선택 (마감봉마다 독립 체크)
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
START_TIMEFRAME_GRAPH_ROWS,
|
||||
STRATEGY_CANDLE_TYPE_OPTIONS,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
formatStrategyCandleLabel,
|
||||
type StrategyCandleType,
|
||||
} from '../../utils/strategyStartNodes';
|
||||
|
||||
interface Props {
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
className?: string;
|
||||
/** 그래프 START: 2행 고정 배치 + 값 라벨(1m…) */
|
||||
layout?: 'default' | 'graph';
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OPTIONS_BY_VALUE = Object.fromEntries(
|
||||
STRATEGY_CANDLE_TYPE_OPTIONS.map(o => [o.value, o]),
|
||||
) as Record<StrategyCandleType, { value: string; label: string }>;
|
||||
|
||||
function CheckItem({
|
||||
value,
|
||||
checked,
|
||||
showShortLabel,
|
||||
onToggle,
|
||||
}: {
|
||||
value: StrategyCandleType;
|
||||
checked: boolean;
|
||||
showShortLabel: boolean;
|
||||
onToggle: (value: string, checked: boolean) => void;
|
||||
}) {
|
||||
const opt = OPTIONS_BY_VALUE[value];
|
||||
return (
|
||||
<label className="se-start-tf-check" title={formatStrategyCandleLabel(value)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onToggle(value, e.target.checked)}
|
||||
/>
|
||||
<span>{showShortLabel ? value : opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const StartTimeframeCheckboxes: React.FC<Props> = ({
|
||||
selected,
|
||||
onChange,
|
||||
className = '',
|
||||
layout = 'default',
|
||||
compact = false,
|
||||
}) => {
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
const toggle = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!selectedSet.has(value)) onChange([...selected, value]);
|
||||
return;
|
||||
}
|
||||
const next = selected.filter(t => t !== value);
|
||||
onChange(next.length > 0 ? next : [value]);
|
||||
};
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
if (layout === 'graph') {
|
||||
return (
|
||||
<div
|
||||
className={`se-start-tf-checks se-start-tf-checks--graph${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
{START_TIMEFRAME_GRAPH_ROWS.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="se-start-tf-checks-row">
|
||||
{row.map(ct => (
|
||||
<CheckItem
|
||||
key={ct}
|
||||
value={ct}
|
||||
checked={selectedSet.has(ct)}
|
||||
showShortLabel
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-start-tf-checks${compact ? ' se-start-tf-checks--compact' : ''}${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<CheckItem
|
||||
key={ct}
|
||||
value={ct}
|
||||
checked={selectedSet.has(ct)}
|
||||
showShortLabel={compact}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartTimeframeCheckboxes;
|
||||
@@ -371,10 +371,12 @@ function StrategyEditorCanvasInner({
|
||||
};
|
||||
}, [emitLayoutChange]);
|
||||
|
||||
const handleStartCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE];
|
||||
const normalized = types.map(normalizeStartCandleType);
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[startId]: { candleType: normalizeStartCandleType(candleType) },
|
||||
[startId]: { candleTypes: normalized, candleType: normalized[0] },
|
||||
});
|
||||
}, [startMeta, onStartMetaChange]);
|
||||
|
||||
@@ -403,7 +405,7 @@ function StrategyEditorCanvasInner({
|
||||
onExtraStartIdsChange?.([...extraStartIds, newId]);
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[newId]: { candleType: DEFAULT_START_CANDLE },
|
||||
[newId]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE },
|
||||
});
|
||||
onExtraRootsChange?.({ ...extraRoots, [newId]: null });
|
||||
positionsRef.current.set(newId, {
|
||||
@@ -675,18 +677,47 @@ function StrategyEditorCanvasInner({
|
||||
}
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.map(o => (
|
||||
o.id === id && (o.type === 'AND' || o.type === 'OR')
|
||||
? { ...o, type: gateType }
|
||||
: o
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (root && findNodeInTree(root, id)) {
|
||||
onChange(updateNode(root, id, n => (
|
||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
||||
)));
|
||||
return;
|
||||
}
|
||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||
if (branch && findNodeInTree(branch, id)) {
|
||||
onExtraRootsChange?.({
|
||||
...extraRoots,
|
||||
[sid]: updateNode(branch, id, n => (
|
||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
||||
)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
onUpdateCondition: handleUpdateCondition,
|
||||
onChangeLogicGateType: handleChangeLogicGateType,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
onStartCandleTypeChange: handleStartCandleTypeChange,
|
||||
onStartCandleTypesChange: handleStartCandleTypesChange,
|
||||
onDeleteStart: handleDeleteStart,
|
||||
}), [
|
||||
handleDelete, handleUpdateCondition, handleDropTarget,
|
||||
handleDragOverTarget, handleDragLeaveTarget,
|
||||
handleStartCandleTypeChange, handleDeleteStart,
|
||||
handleDelete, handleUpdateCondition, handleChangeLogicGateType,
|
||||
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
||||
handleStartCandleTypesChange, handleDeleteStart,
|
||||
]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
type EditorConditionState,
|
||||
addExtraStartSection,
|
||||
updateBranchRoot,
|
||||
updateStartCandleType,
|
||||
updateStartCandleTypes,
|
||||
removeStartSection,
|
||||
collectStartSections,
|
||||
} from '../../utils/strategyConditionSerde';
|
||||
import { STRATEGY_CANDLE_TYPES } from '../../utils/strategyStartNodes';
|
||||
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
AND: '#4caf50',
|
||||
@@ -116,6 +118,13 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
</span>
|
||||
<span className="sp-node-label">{label}</span>
|
||||
<div className="sp-node-actions">
|
||||
{(node.type === 'AND' || node.type === 'OR') && (
|
||||
<LogicGateOpToggle
|
||||
value={node.type}
|
||||
onChange={op => onUpdate({ ...node, type: op })}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{showSettings && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,7 +189,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
|
||||
interface StartSectionBlockProps {
|
||||
startId: string;
|
||||
candleType: string;
|
||||
candleTypes: string[];
|
||||
root: LogicNode | null;
|
||||
canDelete: boolean;
|
||||
signalTab: 'buy' | 'sell';
|
||||
@@ -188,14 +197,14 @@ interface StartSectionBlockProps {
|
||||
dragOverKey: string | null;
|
||||
setDragOverKey: (key: string | null) => void;
|
||||
onRootChange: (root: LogicNode | null) => void;
|
||||
onCandleTypeChange: (candleType: string) => void;
|
||||
onCandleTypesChange: (candleTypes: string[]) => void;
|
||||
onDeleteStart?: () => void;
|
||||
onAddStart?: () => void;
|
||||
}
|
||||
|
||||
function StartSectionBlock({
|
||||
startId,
|
||||
candleType,
|
||||
candleTypes,
|
||||
root,
|
||||
canDelete,
|
||||
signalTab,
|
||||
@@ -203,7 +212,7 @@ function StartSectionBlock({
|
||||
dragOverKey,
|
||||
setDragOverKey,
|
||||
onRootChange,
|
||||
onCandleTypeChange,
|
||||
onCandleTypesChange,
|
||||
onDeleteStart,
|
||||
onAddStart,
|
||||
}: StartSectionBlockProps) {
|
||||
@@ -250,16 +259,11 @@ function StartSectionBlock({
|
||||
<header className="sp-start-head">
|
||||
<span className="sp-start-dot" aria-hidden />
|
||||
<span className="sp-start-label">START</span>
|
||||
<select
|
||||
className="sp-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onChange={e => onCandleTypeChange(e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
<StartTimeframeCheckboxes
|
||||
className="sp-start-tf-checks"
|
||||
selected={candleTypes}
|
||||
onChange={onCandleTypesChange}
|
||||
/>
|
||||
{canDelete && (
|
||||
<button type="button" className="sp-start-del" title="START 삭제" onClick={onDeleteStart}>×</button>
|
||||
)}
|
||||
@@ -277,7 +281,7 @@ function StartSectionBlock({
|
||||
>
|
||||
{!root ? (
|
||||
<div className="sp-drop-empty sp-drop-empty--section">
|
||||
논리 연산자·지표를 드래그하여 [{candleType}] 조건을 구성하세요.
|
||||
논리 연산자·지표를 드래그하여 [{formatStartCandleTypesLabel(candleTypes)}] 조건을 구성하세요.
|
||||
</div>
|
||||
) : (
|
||||
<TreeNodeComp
|
||||
@@ -332,8 +336,8 @@ export default function StrategyListEditor({
|
||||
onEditorStateChange(updateBranchRoot(editorState, startId, root));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
onEditorStateChange(updateStartCandleType(editorState, startId, candleType));
|
||||
const handleCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
onEditorStateChange(updateStartCandleTypes(editorState, startId, candleTypes));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleDeleteStart = useCallback((startId: string) => {
|
||||
@@ -375,7 +379,7 @@ export default function StrategyListEditor({
|
||||
<StartSectionBlock
|
||||
key={section.startId}
|
||||
startId={section.startId}
|
||||
candleType={section.candleType}
|
||||
candleTypes={section.candleTypes}
|
||||
root={section.root}
|
||||
canDelete={section.canDelete}
|
||||
signalTab={signalTab}
|
||||
@@ -383,7 +387,7 @@ export default function StrategyListEditor({
|
||||
dragOverKey={dragOverKey}
|
||||
setDragOverKey={setDragOverKey}
|
||||
onRootChange={root => handleRootChange(section.startId, root)}
|
||||
onCandleTypeChange={ct => handleCandleTypeChange(section.startId, ct)}
|
||||
onCandleTypesChange={types => handleCandleTypesChange(section.startId, types)}
|
||||
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
|
||||
onAddStart={handleAddStart}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -42,6 +43,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
}) => {
|
||||
const chartTf = toChartTimeframe(timeframe);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
@@ -196,6 +198,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
volumeVisible
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -39,6 +40,7 @@ const TrendSearchChartPanel: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
}) => {
|
||||
const chartTf = toChartTimeframe(timeframe);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
@@ -183,6 +185,7 @@ const TrendSearchChartPanel: React.FC<Props> = ({
|
||||
volumeVisible
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
ticker?: TickerData;
|
||||
updatedAt?: number;
|
||||
flash?: boolean;
|
||||
@@ -58,6 +59,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
ticker,
|
||||
updatedAt,
|
||||
flash = false,
|
||||
@@ -139,6 +141,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
/>
|
||||
) : (
|
||||
<TrendSearchCardSignalPanel
|
||||
|
||||
@@ -17,6 +17,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
tickers?: Map<string, TickerData>;
|
||||
lastUpdatedAt?: number;
|
||||
isInTargets?: (market: string) => boolean;
|
||||
@@ -37,6 +38,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
tickers,
|
||||
lastUpdatedAt,
|
||||
isInTargets,
|
||||
@@ -84,6 +86,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
ticker={tickers?.get(row.market)}
|
||||
updatedAt={lastUpdatedAt}
|
||||
flash={flashMarkets?.has(row.market)}
|
||||
|
||||
@@ -43,7 +43,7 @@ const VerificationIssueToastStack: React.FC<Props> = ({ onGoToIssue }) => {
|
||||
<VerificationStageIcon stage={item.stage} size={18} />
|
||||
<div className="vbd-toast-head-text">
|
||||
<p className="vbd-toast-kind">{headline(item.eventType, item.stage, item.previousStage)}</p>
|
||||
<h4 className="vbd-toast-title">{item.title}</h4>
|
||||
<p className="vbd-toast-title">{item.title}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
isVirtualTargetAddAllowed,
|
||||
virtualTargetLimitMessage,
|
||||
} from '../../utils/virtualTargetLimits';
|
||||
import {
|
||||
defaultStrategyOptionLabel,
|
||||
parseTargetStrategySelectValue,
|
||||
targetStrategySelectValue,
|
||||
} from '../../utils/virtualTargetStrategy';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -72,7 +77,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
...targets,
|
||||
{
|
||||
market,
|
||||
strategyId: globalStrategyId,
|
||||
strategyId: null,
|
||||
koreanName: names.koreanName,
|
||||
englishName: names.englishName,
|
||||
},
|
||||
@@ -192,15 +197,19 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
<span className="vtd-target-strategy-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-target-strategy-select"
|
||||
value={item.strategyId ?? globalStrategyId ?? ''}
|
||||
value={targetStrategySelectValue(item)}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onTargetStrategyChange(item.market, v ? Number(v) : null);
|
||||
onTargetStrategyChange(
|
||||
item.market,
|
||||
parseTargetStrategySelectValue(e.target.value),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
<option value={targetStrategySelectValue({ strategyId: null })}>
|
||||
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
||||
</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
ticker?: TickerData;
|
||||
}
|
||||
@@ -68,6 +69,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
chartLiveReceiveHighlight = true,
|
||||
ticker,
|
||||
}) => {
|
||||
@@ -174,6 +176,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartTimeframe={chartTimeframe}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -31,6 +31,7 @@ interface Props {
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
/** 차트 설정 — 지표 가격축 라벨·설명 */
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
/** 전체보기 분할 pane — 캔버스 높이 100% */
|
||||
fillHeight?: boolean;
|
||||
/** 카드 푸터 평가 분봉과 동기화 */
|
||||
@@ -46,6 +47,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
running = false,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
fillHeight = false,
|
||||
chartTimeframe,
|
||||
}) => {
|
||||
@@ -217,6 +219,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
volumeVisible={false}
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import { formatUpdatedTime } from '../../utils/virtualSignalMetrics';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
|
||||
import {
|
||||
defaultStrategyOptionLabel,
|
||||
parseTargetStrategySelectValue,
|
||||
targetStrategySelectValue,
|
||||
} from '../../utils/virtualTargetStrategy';
|
||||
|
||||
interface Props {
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
@@ -31,15 +36,16 @@ const VirtualTargetCardFoot: React.FC<Props> = ({
|
||||
<select
|
||||
className="vtd-card-foot-select"
|
||||
aria-label="투자전략"
|
||||
value={strategyId ?? globalStrategyId ?? ''}
|
||||
value={targetStrategySelectValue({ strategyId })}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange?.(v ? Number(v) : null);
|
||||
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
<option value={targetStrategySelectValue({ strategyId: null })}>
|
||||
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
||||
</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
ticker?: TickerData;
|
||||
}
|
||||
@@ -58,6 +59,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
chartLiveReceiveHighlight = true,
|
||||
ticker,
|
||||
}) => {
|
||||
@@ -114,6 +116,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
fillHeight
|
||||
chartTimeframe={chartTimeframe}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -28,6 +29,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
viewMode: VirtualCardViewMode;
|
||||
@@ -44,6 +46,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
chartLiveReceiveHighlight = true,
|
||||
tickers,
|
||||
viewMode,
|
||||
@@ -111,7 +114,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
{focusMarket && focusTarget ? (
|
||||
<VirtualTargetFocusView
|
||||
market={focusTarget.market}
|
||||
strategy={strategies.find(s => s.id === (focusTarget.strategyId ?? session.globalStrategyId))}
|
||||
strategy={strategies.find(s => s.id === resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId))}
|
||||
strategies={strategies}
|
||||
strategyId={focusTarget.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
@@ -127,13 +130,14 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
ticker={tickers?.get(focusTarget.market)}
|
||||
/>
|
||||
) : (
|
||||
<div className={`vtd-grid vtd-grid--${viewMode}`}>
|
||||
{targets.map(t => {
|
||||
const strat = strategies.find(s => s.id === (t.strategyId ?? session.globalStrategyId));
|
||||
const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId));
|
||||
return (
|
||||
<VirtualTargetCard
|
||||
key={t.market}
|
||||
@@ -158,6 +162,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
ticker={tickers?.get(t.market)}
|
||||
/>
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
resolveTrendSearchAppSettings,
|
||||
type TrendSearchAppSettings,
|
||||
} from '../utils/trendSearchAppSettings';
|
||||
import {
|
||||
resolveChartPaneSeparatorOptions,
|
||||
type ChartPaneSeparatorOptions,
|
||||
} from '../types/chartPaneSeparator';
|
||||
|
||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||
let _cache: AppSettingsDto | null = null;
|
||||
@@ -104,6 +108,10 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
||||
chartLegendOptions: resolveChartLegendOptions(
|
||||
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
|
||||
),
|
||||
chartPaneSeparator: resolveChartPaneSeparatorOptions(
|
||||
s.chartPaneSeparator as Partial<ChartPaneSeparatorOptions> | null | undefined,
|
||||
(s.defaultTheme ?? 'dark') as Theme,
|
||||
),
|
||||
tradeAlertPopup: s.tradeAlertPopup ?? true,
|
||||
tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true,
|
||||
tradeAlertSound: s.tradeAlertSound ?? 'bell',
|
||||
|
||||
@@ -285,13 +285,24 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: clamp(3px, 1.4cqh, 10px);
|
||||
justify-content: flex-start;
|
||||
gap: clamp(4px, 1.2cqh, 10px);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-panel-fields {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(4px, 1.2cqh, 10px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-paper-hint {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
@@ -313,15 +324,30 @@
|
||||
}
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-price-qty-block {
|
||||
flex: 1 1 0;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
gap: clamp(2px, 1.2cqh, 8px);
|
||||
justify-content: flex-start;
|
||||
gap: clamp(4px, 1.2cqh, 8px);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-pct-row--block {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-pct-row--block .top-pct-btn {
|
||||
flex: 1 1 calc(20% - 4px);
|
||||
min-width: 2.4rem;
|
||||
}
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-pct-row--block .top-pct-btn--direct {
|
||||
flex: 1 1 calc(33% - 4px);
|
||||
min-width: 3.2rem;
|
||||
}
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-field,
|
||||
.ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-pct-row--block {
|
||||
margin: 0;
|
||||
@@ -359,7 +385,7 @@
|
||||
|
||||
.ptd-split-panel--right .ptd-order-card .top-actions {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0;
|
||||
margin-top: auto;
|
||||
gap: clamp(6px, 1.4cqh, 8px);
|
||||
}
|
||||
|
||||
@@ -649,8 +675,11 @@
|
||||
.ptd-split-panel--right .ptd-order-card > .top-panel {
|
||||
gap: clamp(6px, 2cqh, 14px);
|
||||
}
|
||||
.ptd-split-panel--right .ptd-order-card .top-panel-fields {
|
||||
gap: clamp(6px, 1.8cqh, 12px);
|
||||
}
|
||||
.ptd-split-panel--right .ptd-order-card .top-price-qty-block {
|
||||
gap: clamp(4px, 1.8cqh, 12px);
|
||||
gap: clamp(6px, 1.8cqh, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,8 +691,8 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
column-gap: 8px;
|
||||
row-gap: 4px;
|
||||
align-content: space-evenly;
|
||||
row-gap: 6px;
|
||||
align-content: start;
|
||||
}
|
||||
.ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; }
|
||||
.ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; }
|
||||
|
||||
@@ -158,14 +158,100 @@
|
||||
color: var(--se-node-start-accent, var(--se-gold));
|
||||
}
|
||||
|
||||
.sp-start-tf {
|
||||
font-size: 0.72rem;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--se-bg) 75%, transparent);
|
||||
color: var(--se-text);
|
||||
.sp-start-tf-checks {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.se-start-tf-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.se-start-tf-checks--compact {
|
||||
gap: 2px 5px;
|
||||
}
|
||||
|
||||
.se-start-tf-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.62rem;
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.se-start-tf-checks--compact .se-start-tf-check {
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.se-start-tf-check input {
|
||||
margin: 0;
|
||||
accent-color: var(--se-node-start-accent, var(--se-gold));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.se-start-tf-check:has(input:checked) {
|
||||
color: var(--se-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.se-flow-start-tf-checks-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.se-start-tf-checks--graph {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
column-gap: 2px;
|
||||
row-gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.se-start-tf-checks-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.se-start-tf-checks--graph .se-start-tf-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 3px;
|
||||
font-size: 0.58rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.se-start-tf-checks--graph .se-start-tf-check input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-flow-start-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.se-flow-start-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.se-flow-start-title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sp-start-del {
|
||||
@@ -877,12 +963,6 @@
|
||||
border-color: color-mix(in srgb, var(--se-sell) 42%, transparent);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--se-sell) 16%, transparent);
|
||||
}
|
||||
.se-flow-start-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.se-flow-start-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
@@ -890,22 +970,6 @@
|
||||
flex-shrink: 0;
|
||||
color: var(--se-node-start-accent, var(--se-gold));
|
||||
}
|
||||
.se-flow-start-tf {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 72px;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--se-bg) 70%, transparent);
|
||||
color: var(--se-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-flow-start-tf:focus {
|
||||
outline: none;
|
||||
border-color: var(--se-accent);
|
||||
}
|
||||
.se-flow-del--start {
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
@@ -932,6 +996,19 @@
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--se-sell) 15%, transparent);
|
||||
}
|
||||
|
||||
.se-flow-gate-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.se-logic-gate-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.se-logic-gate-toggle--compact .se-start-combine-btn {
|
||||
padding: 3px 9px;
|
||||
font-size: 0.68rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.se-flow-gate-badge {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
|
||||
@@ -850,16 +850,22 @@
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
/* 검증 이슈 알림 팝업 */
|
||||
/* 검증 이슈 알림 팝업 — body 포털(.app 밖) · 매매 시그널 팝업(tsn-)과 동일 톤 */
|
||||
html.theme-dark .vbd-toast-stack,
|
||||
html.theme-light .vbd-toast-stack,
|
||||
html.theme-blue .vbd-toast-stack {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vbd-toast-stack {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 6500;
|
||||
z-index: 20002;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -869,31 +875,42 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.vbd-toast-toolbar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
color: var(--text2, var(--text-muted));
|
||||
}
|
||||
|
||||
.vbd-toast-dismiss-all {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, rgba(122, 162, 247, 0.2));
|
||||
background: var(--bg3, #1a1b26);
|
||||
color: var(--text2, #a9b1d6);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.vbd-toast-dismiss-all:hover {
|
||||
background: var(--bg4, var(--hover));
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
|
||||
.vbd-toast-card {
|
||||
pointer-events: auto;
|
||||
padding: 12px 14px;
|
||||
padding: 12px 14px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--popup-shadow);
|
||||
animation: vbd-toast-in 0.22s ease-out;
|
||||
border: 1px solid var(--border, rgba(122, 162, 247, 0.25));
|
||||
background: var(--bg2, #1e2030);
|
||||
color: var(--text);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
animation: vbd-toast-in 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes vbd-toast-in {
|
||||
@@ -915,50 +932,71 @@
|
||||
.vbd-toast-kind {
|
||||
margin: 0 0 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
color: var(--text2, var(--text-muted));
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.vbd-toast-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vbd-toast-close {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
color: var(--text3, var(--text-muted));
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.vbd-toast-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vbd-toast-meta {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vbd-toast-meta .vbd-stage-badge {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vbd-toast-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.vbd-toast-go {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg2, var(--panel)));
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.vbd-toast-go:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--panel));
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg2, var(--panel)));
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
}
|
||||
|
||||
.vbd-toast-go svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Theme } from '../types';
|
||||
import type { HLineStyle } from '../utils/indicatorRegistry';
|
||||
|
||||
/** 캔들·거래량·보조지표 pane 사이 구분선 */
|
||||
export interface ChartPaneSeparatorOptions {
|
||||
visible: boolean;
|
||||
color: string;
|
||||
width: 1 | 2 | 3 | 4;
|
||||
lineStyle: HLineStyle;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHART_PANE_SEPARATOR: ChartPaneSeparatorOptions = {
|
||||
visible: true,
|
||||
color: '#2a2e3a',
|
||||
width: 1,
|
||||
lineStyle: 'solid',
|
||||
};
|
||||
|
||||
const THEME_DEFAULT_COLORS: Record<Theme, string> = {
|
||||
dark: 'rgba(122, 162, 247, 0.15)',
|
||||
light: 'rgba(0, 0, 0, 0.08)',
|
||||
blue: 'rgba(94, 181, 255, 0.15)',
|
||||
};
|
||||
|
||||
export function themeDefaultPaneSeparatorColor(theme: Theme): string {
|
||||
return THEME_DEFAULT_COLORS[theme] ?? THEME_DEFAULT_COLORS.dark;
|
||||
}
|
||||
|
||||
export function resolveChartPaneSeparatorOptions(
|
||||
raw?: Partial<ChartPaneSeparatorOptions> | null,
|
||||
theme: Theme = 'dark',
|
||||
): ChartPaneSeparatorOptions {
|
||||
const base = { ...DEFAULT_CHART_PANE_SEPARATOR };
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...base, color: themeDefaultPaneSeparatorColor(theme) };
|
||||
}
|
||||
if (typeof raw.visible === 'boolean') base.visible = raw.visible;
|
||||
if (typeof raw.color === 'string' && raw.color.trim()) base.color = raw.color.trim();
|
||||
else base.color = themeDefaultPaneSeparatorColor(theme);
|
||||
const w = Number(raw.width);
|
||||
if (w >= 1 && w <= 4) base.width = Math.round(w) as 1 | 2 | 3 | 4;
|
||||
if (raw.lineStyle === 'solid' || raw.lineStyle === 'dashed' || raw.lineStyle === 'dotted') {
|
||||
base.lineStyle = raw.lineStyle;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** LWC pane 구분선 — 커스텀 오버레이 사용 시 투명 처리 */
|
||||
export function applyPaneSeparatorToChartOptions(opts: ChartPaneSeparatorOptions) {
|
||||
return {
|
||||
layout: {
|
||||
panes: {
|
||||
enableResize: false,
|
||||
separatorColor: 'transparent',
|
||||
separatorHoverColor: 'transparent',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
@@ -39,8 +39,15 @@ import {
|
||||
getIchimokuPlotTitle,
|
||||
isIchimokuCloudVisible,
|
||||
resolveIchimokuCloudColors,
|
||||
resolveIchimokuDisplacements,
|
||||
} from './ichimokuConfig';
|
||||
import type { PlotDef, HLineStyle } from './indicatorRegistry';
|
||||
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
|
||||
import {
|
||||
applyPaneSeparatorToChartOptions,
|
||||
resolveChartPaneSeparatorOptions,
|
||||
type ChartPaneSeparatorOptions,
|
||||
} from '../types/chartPaneSeparator';
|
||||
|
||||
function plotSeriesLineStyle(style?: HLineStyle): number {
|
||||
if (style === 'dashed') return LineStyle.Dashed;
|
||||
@@ -217,6 +224,10 @@ export class ChartManager {
|
||||
/** 차트 하단 거래량 pane 표시 */
|
||||
private _volumeVisible = true;
|
||||
|
||||
/** pane 간 구분선 (설정 화면) */
|
||||
private _paneSeparatorOptions: ChartPaneSeparatorOptions =
|
||||
resolveChartPaneSeparatorOptions(null, 'dark');
|
||||
|
||||
/** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
|
||||
private _lastLayoutAvailableHeight?: number;
|
||||
|
||||
@@ -231,7 +242,7 @@ export class ChartManager {
|
||||
background: { type: ColorType.Solid, color: t.bgColor },
|
||||
textColor: t.textColor,
|
||||
fontSize: 12,
|
||||
panes: { enableResize: false },
|
||||
panes: { enableResize: false, separatorColor: 'transparent', separatorHoverColor: 'transparent' },
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: t.gridColor },
|
||||
@@ -258,6 +269,8 @@ export class ChartManager {
|
||||
});
|
||||
|
||||
this._applyDisplayTimezoneOptions();
|
||||
this._paneSeparatorOptions = resolveChartPaneSeparatorOptions(null, theme);
|
||||
this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions));
|
||||
|
||||
// 크로스헤어 이동 시 hover 중인 시리즈 추적 (오버레이 클릭 감지에 사용)
|
||||
this.chart.subscribeCrosshairMove((params: MouseEventParams<Time>) => {
|
||||
@@ -470,7 +483,11 @@ export class ChartManager {
|
||||
this.currentTheme = theme;
|
||||
const t = getTheme(theme);
|
||||
this.chart.applyOptions({
|
||||
layout: { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor },
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: t.bgColor },
|
||||
textColor: t.textColor,
|
||||
panes: applyPaneSeparatorToChartOptions(this._paneSeparatorOptions).layout.panes,
|
||||
},
|
||||
grid: { vertLines: { color: t.gridColor }, horzLines: { color: t.gridColor } },
|
||||
crosshair: { vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' }, horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' } },
|
||||
timeScale: { borderColor: t.borderColor },
|
||||
@@ -480,6 +497,20 @@ export class ChartManager {
|
||||
this._applyVolumeSeriesTheme(t);
|
||||
}
|
||||
|
||||
getContainer(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
getPaneSeparatorOptions(): ChartPaneSeparatorOptions {
|
||||
return this._paneSeparatorOptions;
|
||||
}
|
||||
|
||||
setPaneSeparatorOptions(opts: ChartPaneSeparatorOptions): void {
|
||||
this._paneSeparatorOptions = opts;
|
||||
this.chart.applyOptions(applyPaneSeparatorToChartOptions(opts));
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
private _applyMainSeriesTheme(t: ThemeTokens): void {
|
||||
if (!this.mainSeries) return;
|
||||
switch (this.currentChartType) {
|
||||
@@ -582,14 +613,7 @@ export class ChartManager {
|
||||
let series: ISeriesApi<SeriesType>;
|
||||
if (plotDef.type === 'histogram') {
|
||||
series = this.chart.addSeries(HistogramSeries, { color: plotDef.color }, pane);
|
||||
const colorData = filtered.map((p, i, arr) => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (i > 0 && p.value < arr[i - 1].value
|
||||
? '#ef535088'
|
||||
: p.value < 0 ? '#ef535088' : `${plotDef.color}88`),
|
||||
}));
|
||||
series.setData(colorData);
|
||||
series.setData(mapHistogramSeriesData(filtered, plotDef));
|
||||
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
|
||||
} else {
|
||||
const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false;
|
||||
@@ -743,6 +767,8 @@ export class ChartManager {
|
||||
await this.addIndicator(ind);
|
||||
}
|
||||
|
||||
if (this._indicatorLoadStale(loadGen)) return;
|
||||
|
||||
// 빈 pane stretch 정리 + 캔들 pane 비율 복구
|
||||
this.resetPaneHeights();
|
||||
this._notifyPaneLayout();
|
||||
@@ -907,7 +933,7 @@ export class ChartManager {
|
||||
_config: IndicatorConfig,
|
||||
seriesList: ISeriesApi<SeriesType>[],
|
||||
): Promise<{ cloudPlugin: IchimokuCloudPlugin; seriesMeta: IndicatorEntry['seriesMeta'] }> {
|
||||
const displacement = (_config.params?.displacement as number) ?? 26;
|
||||
const { senkou: displacement } = resolveIchimokuDisplacements(_config.params);
|
||||
const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52;
|
||||
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
|
||||
|
||||
@@ -1233,7 +1259,7 @@ export class ChartManager {
|
||||
// ── IchimokuCloud: SpanA/SpanB 미래 프로젝션 갱신 ──────────────────
|
||||
if (updateFutureSpans && entry.type === 'IchimokuCloud') {
|
||||
const config = entry.config;
|
||||
const disp = (config.params?.displacement as number) ?? 26;
|
||||
const { senkou: disp } = resolveIchimokuDisplacements(config.params);
|
||||
const lagPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
|
||||
const convPlot = (plots['plot0'] as PlotData) ?? [];
|
||||
const basePlot = (plots['plot1'] as PlotData) ?? [];
|
||||
@@ -1268,11 +1294,12 @@ export class ChartManager {
|
||||
|
||||
// ── Histogram: 색상 재계산 ──────────────────────────────────────────
|
||||
if (meta.isHistogram) {
|
||||
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
|
||||
const isDown = prevVal !== null && (curVal as number) < (prevVal as number);
|
||||
const isNeg = (curVal as number) < 0;
|
||||
const barColor = lastPoint.color
|
||||
?? (isDown || isNeg ? '#ef535088' : `${meta.color}88`);
|
||||
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
|
||||
.find(p => p.id === meta.plotId);
|
||||
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
|
||||
const barColor = plotDef
|
||||
? histogramBarColor(curVal as number, prevVal, plotDef, lastPoint.color)
|
||||
: (lastPoint.color ?? '#26A69A88');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(entry.seriesList[i] as ISeriesApi<any>).update({
|
||||
time: lastPoint.time as Time,
|
||||
@@ -1294,6 +1321,29 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** histogram 막대 색상(상승/하락) 변경 후 전체 재색칠 */
|
||||
private async _repaintHistogramSeries(indicatorId: string, config: IndicatorConfig): Promise<void> {
|
||||
const entry = this.indicators.get(indicatorId);
|
||||
if (!entry || this.rawBars.length === 0) return;
|
||||
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? [];
|
||||
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
||||
try {
|
||||
const { plots } = await calculateIndicator(config.type, this.rawBars.slice(), config.params ?? {});
|
||||
for (let i = 0; i < entry.seriesList.length; i++) {
|
||||
const meta = entry.seriesMeta[i];
|
||||
if (!meta?.isHistogram) continue;
|
||||
const plot = plotById[meta.plotId];
|
||||
if (!plot) continue;
|
||||
const plotData = plots[meta.plotId] as PlotData | undefined;
|
||||
if (!plotData?.length) continue;
|
||||
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (filtered.length === 0) continue;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(entry.seriesList[i] as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plot));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 일목균형표 구름 플러그인 — 색상·가시성·데이터 동기화 */
|
||||
private _applyIchimokuCloudPlugin(
|
||||
plugin: IchimokuCloudPlugin,
|
||||
@@ -1302,6 +1352,7 @@ export class ChartManager {
|
||||
): void {
|
||||
const colors = resolveIchimokuCloudColors(config.cloudColors);
|
||||
plugin.setColors(colors.bullishColor, colors.bearishColor);
|
||||
plugin.setVisibility(colors.bullishVisible !== false, colors.bearishVisible !== false);
|
||||
|
||||
if (!isIchimokuCloudVisible(config)) {
|
||||
plugin.setCloudData([]);
|
||||
@@ -1314,7 +1365,7 @@ export class ChartManager {
|
||||
const basePlot = plots['plot1'] as PlotData ?? [];
|
||||
if (!spanAPlot || !spanBPlot) return;
|
||||
|
||||
const displacement = (config.params?.displacement as number) ?? 26;
|
||||
const { senkou: displacement } = resolveIchimokuDisplacements(config.params);
|
||||
const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
|
||||
|
||||
const cloud = this._buildIchimokuCloudData(
|
||||
@@ -1990,6 +2041,12 @@ export class ChartManager {
|
||||
}
|
||||
|
||||
entry.config = config;
|
||||
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
||||
void this._repaintHistogramSeries(config.id, config);
|
||||
}
|
||||
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
||||
void this._repaintHistogramSeries(config.id, config);
|
||||
}
|
||||
|
||||
if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) {
|
||||
this._hideSubPaneIndicator(entry);
|
||||
@@ -2773,18 +2830,10 @@ export class ChartManager {
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
if (meta.isHistogram) {
|
||||
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
|
||||
.find(p => p.id === meta.plotId) ?? { id: meta.plotId, title: '', color: meta.color, type: 'histogram' as const };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(series as ISeriesApi<any>).setData(
|
||||
filtered.map((p, idx, arr) => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (
|
||||
idx > 0 && p.value < arr[idx - 1].value
|
||||
? '#ef535088'
|
||||
: p.value < 0 ? '#ef535088' : `${meta.color}88`
|
||||
),
|
||||
}))
|
||||
);
|
||||
(series as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plotDef));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(series as ISeriesApi<any>).setData(
|
||||
@@ -2809,7 +2858,7 @@ export class ChartManager {
|
||||
entry: IndicatorEntry,
|
||||
plots: Record<string, PlotData>,
|
||||
): void {
|
||||
const displacement = (entry.config?.params?.displacement as number) ?? 26;
|
||||
const { senkou: displacement } = resolveIchimokuDisplacements(entry.config?.params);
|
||||
const laggingPeriod = (entry.config?.params?.laggingSpan2Periods as number) ?? 52;
|
||||
const convPlot = (plots['plot0'] as PlotData) ?? [];
|
||||
const basePlot = (plots['plot1'] as PlotData) ?? [];
|
||||
|
||||
@@ -43,6 +43,8 @@ class CloudRenderer implements IPrimitivePaneRenderer {
|
||||
private _data: IchimokuCloudPoint[];
|
||||
private _bullishColor: string;
|
||||
private _bearishColor: string;
|
||||
private _bullishVisible: boolean;
|
||||
private _bearishVisible: boolean;
|
||||
|
||||
constructor(
|
||||
chart: IChartApiBase<Time>,
|
||||
@@ -50,12 +52,16 @@ class CloudRenderer implements IPrimitivePaneRenderer {
|
||||
data: IchimokuCloudPoint[],
|
||||
bullishColor: string,
|
||||
bearishColor: string,
|
||||
bullishVisible: boolean,
|
||||
bearishVisible: boolean,
|
||||
) {
|
||||
this._chart = chart;
|
||||
this._series = series;
|
||||
this._data = data;
|
||||
this._bullishColor = bullishColor;
|
||||
this._bearishColor = bearishColor;
|
||||
this._bullishVisible = bullishVisible;
|
||||
this._bearishVisible = bearishVisible;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -81,6 +87,8 @@ class CloudRenderer implements IPrimitivePaneRenderer {
|
||||
|
||||
// 한국 관례: SpanA > SpanB → 양운(빨강), SpanA <= SpanB → 음운(청록)
|
||||
const bullish = (a.spanA + b.spanA) > (a.spanB + b.spanB);
|
||||
if (bullish && !this._bullishVisible) continue;
|
||||
if (!bullish && !this._bearishVisible) continue;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
@@ -112,6 +120,8 @@ class CloudPaneView implements IPrimitivePaneView {
|
||||
this._plugin.getCloudData(),
|
||||
this._plugin.getBullishColor(),
|
||||
this._plugin.getBearishColor(),
|
||||
this._plugin.getBullishVisible(),
|
||||
this._plugin.getBearishVisible(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +133,8 @@ export class IchimokuCloudPlugin implements ISeriesPrimitive<Time> {
|
||||
private _data: IchimokuCloudPoint[] = [];
|
||||
private _bullishColor = 'rgba(239, 83, 80, 0.2)';
|
||||
private _bearishColor = 'rgba(38, 166, 154, 0.2)';
|
||||
private _bullishVisible = true;
|
||||
private _bearishVisible = true;
|
||||
private _updateFn: (() => void) | null = null;
|
||||
|
||||
attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
|
||||
@@ -147,8 +159,16 @@ export class IchimokuCloudPlugin implements ISeriesPrimitive<Time> {
|
||||
this._updateFn?.();
|
||||
}
|
||||
|
||||
setVisibility(bullishVisible: boolean, bearishVisible: boolean): void {
|
||||
this._bullishVisible = bullishVisible;
|
||||
this._bearishVisible = bearishVisible;
|
||||
this._updateFn?.();
|
||||
}
|
||||
|
||||
getBullishColor() { return this._bullishColor; }
|
||||
getBearishColor() { return this._bearishColor; }
|
||||
getBullishVisible() { return this._bullishVisible; }
|
||||
getBearishVisible() { return this._bearishVisible; }
|
||||
|
||||
paneViews(): readonly IPrimitivePaneView[] {
|
||||
if (!this._chart || !this._series) return [];
|
||||
|
||||
@@ -392,6 +392,13 @@ export interface AppSettingsDto {
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
||||
chartLegendOptions?: Record<string, boolean> | null;
|
||||
/** 차트 pane(캔들·거래량·보조지표) 구분선 */
|
||||
chartPaneSeparator?: {
|
||||
visible?: boolean;
|
||||
color?: string;
|
||||
width?: number;
|
||||
lineStyle?: string;
|
||||
} | null;
|
||||
/** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */
|
||||
tradeAlertPopup?: boolean;
|
||||
/** 매매 시그널 알림 사운드 ON/OFF */
|
||||
|
||||
@@ -6,7 +6,8 @@ export function timeframeToCandleType(tf: Timeframe): string {
|
||||
case '1m': case '3m': case '5m': case '10m': case '15m': case '30m': case '1h': case '4h':
|
||||
return tf;
|
||||
case '1D': return '1d';
|
||||
case '1W': case '1M': return '1d';
|
||||
case '1W': return '1w';
|
||||
case '1M': return '1d';
|
||||
default: return '1m';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ export function getIchimokuPlotTitle(plotId: string): string {
|
||||
export const ICHIMOKU_LINE_ROWS: IchimokuLineRowDef[] = [
|
||||
{ plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' },
|
||||
{ plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' },
|
||||
{ plotIndex: 2, plotId: 'plot2', paramKey: 'displacement' },
|
||||
{ plotIndex: 3, plotId: 'plot3' },
|
||||
{ plotIndex: 2, plotId: 'plot2', paramKey: 'chikouDisplacement' },
|
||||
{ plotIndex: 3, plotId: 'plot3', paramKey: 'senkouDisplacement' },
|
||||
{ plotIndex: 4, plotId: 'plot4', paramKey: 'laggingSpan2Periods' },
|
||||
];
|
||||
|
||||
@@ -37,9 +37,28 @@ export const ICHIMOKU_DEFAULT_PARAMS: Record<string, number> = {
|
||||
conversionPeriods: 9,
|
||||
basePeriods: 26,
|
||||
laggingSpan2Periods: 52,
|
||||
/** @deprecated senkouDisplacement 우선 — API·구버전 호환 */
|
||||
displacement: 26,
|
||||
chikouDisplacement: 26,
|
||||
senkouDisplacement: 26,
|
||||
};
|
||||
|
||||
/** 후행·선행 이동 기간 (구 displacement 단일 키 마이그레이션) */
|
||||
export function resolveIchimokuDisplacements(
|
||||
params: Record<string, number | string | boolean> | undefined,
|
||||
): { chikou: number; senkou: number } {
|
||||
const legacy = typeof params?.displacement === 'number' && params.displacement >= 1
|
||||
? params.displacement
|
||||
: 26;
|
||||
const chikou = typeof params?.chikouDisplacement === 'number' && params.chikouDisplacement >= 1
|
||||
? params.chikouDisplacement
|
||||
: legacy;
|
||||
const senkou = typeof params?.senkouDisplacement === 'number' && params.senkouDisplacement >= 1
|
||||
? params.senkouDisplacement
|
||||
: legacy;
|
||||
return { chikou, senkou };
|
||||
}
|
||||
|
||||
export function createDefaultIchimokuParams(): Record<string, number> {
|
||||
return { ...ICHIMOKU_DEFAULT_PARAMS };
|
||||
}
|
||||
@@ -55,12 +74,18 @@ export interface IchimokuCloudColors {
|
||||
bullishColor: string;
|
||||
/** 선행스팬1 < 선행스팬2 (하락 구름) */
|
||||
bearishColor: string;
|
||||
/** 상승 구름 영역 표시 */
|
||||
bullishVisible?: boolean;
|
||||
/** 하락 구름 영역 표시 */
|
||||
bearishVisible?: boolean;
|
||||
}
|
||||
|
||||
/** #RRGGBBAA — 상승 구름 빨강 20%, 하락 구름 청록 20% */
|
||||
export const DEFAULT_ICHIMOKU_CLOUD_COLORS: IchimokuCloudColors = {
|
||||
bullishColor: '#EF535033',
|
||||
bearishColor: '#26A69A33',
|
||||
bullishVisible: true,
|
||||
bearishVisible: true,
|
||||
};
|
||||
|
||||
/** registry plot ID → lightweight-charts-indicators plot ID */
|
||||
@@ -80,19 +105,29 @@ export const ICHIMOKU_LIB_TO_REGISTRY: Record<string, string> = {
|
||||
plot4: 'plot3',
|
||||
};
|
||||
|
||||
export function isIchimokuCloudVisible(config: IndicatorConfig): boolean {
|
||||
/** 선행스팬1·2 라인이 켜져 있어 구름 계산이 가능한지 */
|
||||
export function areIchimokuSpanLinesVisible(config: IndicatorConfig): boolean {
|
||||
return (
|
||||
config.plotVisibility?.['plot2'] !== false &&
|
||||
config.plotVisibility?.['plot3'] !== false
|
||||
config.plotVisibility?.['plot3'] !== false &&
|
||||
config.plotVisibility?.['plot4'] !== false
|
||||
);
|
||||
}
|
||||
|
||||
/** 구름 영역을 그릴 수 있는지 (선행스팬 + 상승/하락 구름 중 하나 이상 on) */
|
||||
export function isIchimokuCloudVisible(config: IndicatorConfig): boolean {
|
||||
if (!areIchimokuSpanLinesVisible(config)) return false;
|
||||
const colors = resolveIchimokuCloudColors(config.cloudColors);
|
||||
return colors.bullishVisible !== false || colors.bearishVisible !== false;
|
||||
}
|
||||
|
||||
export function resolveIchimokuCloudColors(
|
||||
colors?: Partial<IchimokuCloudColors>,
|
||||
): IchimokuCloudColors {
|
||||
return {
|
||||
bullishColor: colors?.bullishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bullishColor,
|
||||
bearishColor: colors?.bearishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bearishColor,
|
||||
bullishVisible: colors?.bullishVisible !== false,
|
||||
bearishVisible: colors?.bearishVisible !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +145,11 @@ export function normalizeIchimokuConfig(config: IndicatorConfig): IndicatorConfi
|
||||
}
|
||||
}
|
||||
|
||||
const { chikou, senkou } = resolveIchimokuDisplacements(params);
|
||||
params.chikouDisplacement = chikou;
|
||||
params.senkouDisplacement = senkou;
|
||||
params.displacement = senkou;
|
||||
|
||||
const plotVisibility: Record<string, boolean> = {
|
||||
...createDefaultIchimokuPlotVisibility(),
|
||||
...config.plotVisibility,
|
||||
|
||||
@@ -26,6 +26,8 @@ const PARAM_LABELS: Record<string, LabelPair> = {
|
||||
factor: { ko: '계수', en: 'Factor' },
|
||||
atrPeriod: { ko: 'ATR 기간', en: 'ATR Period' },
|
||||
displacement: { ko: '이동 기간', en: 'Displacement' },
|
||||
chikouDisplacement: { ko: '후행 기간', en: 'Chikou Displacement' },
|
||||
senkouDisplacement: { ko: '선행 기간', en: 'Senkou Displacement' },
|
||||
conversionPeriods: { ko: '전환선 기간', en: 'Conversion Periods' },
|
||||
basePeriods: { ko: '기준선 기간', en: 'Base Periods' },
|
||||
laggingSpan2Periods: { ko: '선행스팬2 기간', en: 'Lagging Span 2 Periods' },
|
||||
|
||||
@@ -48,7 +48,7 @@ function mergeOptions(
|
||||
}
|
||||
|
||||
function isPeriodKey(key: string): boolean {
|
||||
return /^(length|len|period\d*|kLength|dSmoothing|smooth|adxSmoothing|diLength|ccilength|turbolen|signalLength|fastLength|slowLength|longLength|shortLength|maLength|ultraLength|midLength|longLength|displacement|conversionPeriods|basePeriods|laggingSpan2Periods|length1|length2|length3|lengthRSI|lengthStoch|rsiLength|upDownLength|rocLength|stdevLength|atrLength|p|q|x)$/i.test(key);
|
||||
return /^(length|len|period\d*|kLength|dSmoothing|smooth|adxSmoothing|diLength|ccilength|turbolen|signalLength|fastLength|slowLength|longLength|shortLength|maLength|ultraLength|midLength|longLength|displacement|chikouDisplacement|senkouDisplacement|conversionPeriods|basePeriods|laggingSpan2Periods|length1|length2|length3|lengthRSI|lengthStoch|rsiLength|upDownLength|rocLength|stdevLength|atrLength|p|q|x)$/i.test(key);
|
||||
}
|
||||
|
||||
function isMultKey(key: string): boolean {
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
calculateSmaMulti,
|
||||
normalizeSmaConfig,
|
||||
} from './smaConfig';
|
||||
import { normalizeIchimokuConfig, mergeIchimokuPlots } from './ichimokuConfig';
|
||||
import {
|
||||
normalizeIchimokuConfig,
|
||||
mergeIchimokuPlots,
|
||||
resolveIchimokuDisplacements,
|
||||
} from './ichimokuConfig';
|
||||
import {
|
||||
normalizePsychologicalParams,
|
||||
resolvePsychologicalIndicatorType,
|
||||
@@ -37,6 +41,10 @@ export interface PlotDef {
|
||||
lineWidth?: number;
|
||||
/** 라인 시리즈 선 유형 (기본 solid) */
|
||||
lineStyle?: HLineStyle;
|
||||
/** histogram — 이전 봉 대비 상승·0선 위 막대 색 */
|
||||
histogramUpColor?: string;
|
||||
/** histogram — 이전 봉 대비 하락 또는 0선 아래 막대 색 */
|
||||
histogramDownColor?: string;
|
||||
}
|
||||
|
||||
export type HLineStyle = 'solid' | 'dashed' | 'dotted';
|
||||
@@ -303,7 +311,7 @@ export const INDICATOR_REGISTRY: IndicatorDef[] = [
|
||||
{ type:'ChopZone', name:'Chop Zone', koreanName:'변동성구분지표', shortName:'CZ', category:'Oscillators', overlay:false, defaultParams:{length:30, atrLength:1}, plots:[{id:'plot0',title:'CZ', color:'#FF9800',type:'histogram'}], hlines:[], description:'변동성 vs 추세 구분 지표' },
|
||||
|
||||
// ── Momentum ──────────────────────────────────────────────────────────────
|
||||
{ type:'MACD', name:'MACD', koreanName:'이동평균수렴발산', shortName:'MACD', category:'Momentum', overlay:false, defaultParams:{fastLength:12, slowLength:26, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'Histogram',color:'#26A69A',type:'histogram'},{id:'plot1',title:'MACD',color:'#2962FF',type:'line',lineWidth:1},{id:'plot2',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'이동평균 수렴·발산' },
|
||||
{ type:'MACD', name:'MACD', koreanName:'이동평균수렴발산', shortName:'MACD', category:'Momentum', overlay:false, defaultParams:{fastLength:12, slowLength:26, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'Histogram',color:'#26A69A',type:'histogram',histogramUpColor:'#26A69A',histogramDownColor:'#EF5350'},{id:'plot1',title:'MACD',color:'#2962FF',type:'line',lineWidth:1},{id:'plot2',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'이동평균 수렴·발산' },
|
||||
{ type:'Momentum', name:'Momentum', koreanName:'모멘텀', shortName:'MOM', category:'Momentum', overlay:false, defaultParams:{length:10, src:'close'}, plots:[{id:'plot0',title:'MOM', color:'#26A69A',type:'line',lineWidth:2}], hlines:[{price:0,color:'#607D8B'}], description:'모멘텀 지표' },
|
||||
{ type:'ROC', name:'Rate of Change', koreanName:'가격변화율', shortName:'ROC', category:'Momentum', overlay:false, defaultParams:{length:9, src:'close'}, plots:[{id:'plot0',title:'ROC', color:'#00BCD4',type:'line',lineWidth:2}], hlines:[{price:10,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-10,color:'#4CAF5080'}], description:'가격 변화율 (%)' },
|
||||
{ type:'TSI', name:'True Strength Index', koreanName:'참강도지수', shortName:'TSI', category:'Momentum', overlay:false, defaultParams:{longLength:25, shortLength:13, signalLength:13, src:'close'}, plots:[{id:'plot0',title:'TSI', color:'#2196F3',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:25,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-25,color:'#4CAF50'}], description:'참 강도 지수' },
|
||||
@@ -319,7 +327,7 @@ export const INDICATOR_REGISTRY: IndicatorDef[] = [
|
||||
{ type:'ADX', name:'Average Directional Index', koreanName:'평균방향성지수', shortName:'ADX', category:'Trend', overlay:false, defaultParams:{adxSmoothing:14, diLength:14}, plots:[{id:'plot0',title:'ADX',color:'#FF6D00',type:'line',lineWidth:2},{id:'plot1',title:'+DI',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot2',title:'-DI',color:'#EF5350',type:'line',lineWidth:1}], hlines:ADX_DEFAULT_HLINES, description:'평균 방향성 지수 (추세 강도)' },
|
||||
{ type:'DMI', name:'Directional Movement Index', koreanName:'방향성이동지수', shortName:'DMI', category:'Trend', overlay:false, defaultParams:{diLength:14, adxSmoothing:14}, plots:[{id:'plot0',title:'+DI',color:'#2962FF',type:'line',lineWidth:1},{id:'plot1',title:'-DI',color:'#FF6D00',type:'line',lineWidth:1},{id:'plot2',title:'DX',color:'#FF9800',type:'line',lineWidth:1},{id:'plot3',title:'ADX',color:'#E91E63',type:'line',lineWidth:1},{id:'plot4',title:'ADXR',color:'#9C27B0',type:'line',lineWidth:1}], hlines:DMI_DEFAULT_HLINES, description:'방향성 이동 지수 (+DI·-DI·DX·ADX·ADXR)' },
|
||||
{ type:'Aroon', name:'Aroon', koreanName:'아룬지표', shortName:'Aroon', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'Up', color:'#4CAF50',type:'line',lineWidth:1},{id:'plot1',title:'Down',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:70,color:'#4CAF5060'},{price:30,color:'#EF535060'}], description:'아룬 추세 지표' },
|
||||
{ type:'IchimokuCloud', name:'Ichimoku Cloud', koreanName:'일목균형표', shortName:'Ichi', category:'Trend', overlay:true, defaultParams:{conversionPeriods:9, basePeriods:26, laggingSpan2Periods:52, displacement:26}, plots:[{id:'plot0',title:'전환선',color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'기준선',color:'#EF5350',type:'line',lineWidth:1},{id:'plot2',title:'후행스팬',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot3',title:'선행스팬1',color:'#EF5350',type:'line',lineWidth:1},{id:'plot4',title:'선행스팬2',color:'#9C27B0',type:'line',lineWidth:1}], description:'일목균형표' },
|
||||
{ type:'IchimokuCloud', name:'Ichimoku Cloud', koreanName:'일목균형표', shortName:'Ichi', category:'Trend', overlay:true, defaultParams:{conversionPeriods:9, basePeriods:26, laggingSpan2Periods:52, displacement:26, chikouDisplacement:26, senkouDisplacement:26}, plots:[{id:'plot0',title:'전환선',color:'#2196F3',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot1',title:'기준선',color:'#EF5350',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot2',title:'후행스팬',color:'#4CAF50',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot3',title:'선행스팬1',color:'#EF5350',type:'line',lineWidth:1,lineStyle:'solid'},{id:'plot4',title:'선행스팬2',color:'#9C27B0',type:'line',lineWidth:1,lineStyle:'solid'}], description:'일목균형표' },
|
||||
{ type:'Supertrend', name:'Supertrend', koreanName:'슈퍼트렌드', shortName:'ST', category:'Trend', overlay:true, defaultParams:{atrPeriod:10, factor:3}, plots:[{id:'plot0',title:'ST', color:'#4CAF50',type:'line',lineWidth:2}], description:'슈퍼트렌드 (ATR 기반)' },
|
||||
{ type:'ParabolicSAR', name:'Parabolic SAR', koreanName:'파라볼릭SAR', shortName:'SAR', category:'Trend', overlay:true, defaultParams:{start:0.02, increment:0.02, maximum:0.2}, plots:[{id:'plot0',title:'SAR', color:'#FF6D00',type:'scatter'}], description:'파라볼릭 SAR' },
|
||||
{ type:'Choppiness', name:'Choppiness Index', koreanName:'불규칙성지수', shortName:'CHOP', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'CHOP',color:'#FFC107',type:'line',lineWidth:2}], hlines:[{price:61.8,color:'#EF535080'},{price:38.2,color:'#4CAF5080'}], description:'변동성 vs 추세 구분 (61.8이하=추세)' },
|
||||
@@ -603,6 +611,10 @@ export async function calculateIndicator(
|
||||
|
||||
let calcBars = bars;
|
||||
let calcParams = params;
|
||||
if (type === 'IchimokuCloud') {
|
||||
const { senkou } = resolveIchimokuDisplacements(params);
|
||||
calcParams = { ...params, displacement: senkou };
|
||||
}
|
||||
if (type === 'BollingerBands') {
|
||||
calcParams = normalizeBollingerParams(params);
|
||||
calcBars = await resolveBollingerBars(bars, calcParams, chartContext.timeframe);
|
||||
|
||||
@@ -46,10 +46,10 @@ export const INDICATOR_PLOT_PARAMS: Record<string, Record<string, string[]>> = {
|
||||
IchimokuCloud: {
|
||||
plot0: ['conversionPeriods'],
|
||||
plot1: ['basePeriods'],
|
||||
/** 후행스팬 — chikou 이동(displacement) */
|
||||
plot2: ['displacement'],
|
||||
/** 선행스팬1 — 전환·기준선에서 산출, 별도 기간 없음 */
|
||||
plot3: [],
|
||||
/** 후행스팬 — 과거 이동 기간 */
|
||||
plot2: ['chikouDisplacement'],
|
||||
/** 선행스팬1 — 미래 이동 기간 */
|
||||
plot3: ['senkouDisplacement'],
|
||||
/** 선행스팬2 — Donchian 기간 */
|
||||
plot4: ['laggingSpan2Periods'],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { HLineStyle } from './indicatorRegistry';
|
||||
import type { HLineStyle, PlotDef } from './indicatorRegistry';
|
||||
|
||||
const DEFAULT_HIST_UP = '#26A69A';
|
||||
const DEFAULT_HIST_DOWN = '#EF5350';
|
||||
|
||||
/** 업비트/트레이딩뷰 스타일 색상 팔레트 (10×7) */
|
||||
export const COLOR_PALETTE: string[] = [
|
||||
@@ -48,3 +51,44 @@ export const LINE_STYLE_LABELS: Record<HLineStyle, string> = {
|
||||
dashed: '점선',
|
||||
dotted: '도트',
|
||||
};
|
||||
|
||||
/** histogram 막대 색에 투명도(88) 보장 */
|
||||
export function withHistogramAlpha(color: string): string {
|
||||
if (color.startsWith('rgba(') || color.startsWith('rgb(')) return color;
|
||||
if (color.length >= 9 && color.startsWith('#')) return color;
|
||||
if (color.length >= 7 && color.startsWith('#')) return `${color}88`;
|
||||
return `${color}88`;
|
||||
}
|
||||
|
||||
export function resolveHistogramUpColor(plot: PlotDef): string {
|
||||
return plot.histogramUpColor ?? plot.color ?? DEFAULT_HIST_UP;
|
||||
}
|
||||
|
||||
export function resolveHistogramDownColor(plot: PlotDef): string {
|
||||
return plot.histogramDownColor ?? DEFAULT_HIST_DOWN;
|
||||
}
|
||||
|
||||
/** MACD 등 histogram 막대 색 (상승·하락/음수 구분) */
|
||||
export function histogramBarColor(
|
||||
value: number,
|
||||
prev: number | null | undefined,
|
||||
plot: PlotDef,
|
||||
pointColor?: string,
|
||||
): string {
|
||||
if (pointColor) return withHistogramAlpha(pointColor);
|
||||
const isDown = prev != null && !isNaN(prev) && value < prev;
|
||||
const isNeg = value < 0;
|
||||
const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot);
|
||||
return withHistogramAlpha(raw);
|
||||
}
|
||||
|
||||
export function mapHistogramSeriesData(
|
||||
filtered: Array<{ time: number; value: number; color?: string }>,
|
||||
plot: PlotDef,
|
||||
): Array<{ time: import('lightweight-charts').Time; value: number; color: string }> {
|
||||
return filtered.map((p, idx, arr) => ({
|
||||
time: p.time as import('lightweight-charts').Time,
|
||||
value: p.value,
|
||||
color: histogramBarColor(p.value, idx > 0 ? arr[idx - 1].value : null, plot, p.color),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
DEFAULT_START_CANDLE,
|
||||
DEFAULT_START_COMBINE_OP,
|
||||
START_NODE_ID,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
createStartNodeId,
|
||||
defaultStartMeta,
|
||||
getStartCandleTypes,
|
||||
normalizeStartCandleType,
|
||||
type StartCombineOp,
|
||||
type StartNodeMeta,
|
||||
@@ -25,30 +27,42 @@ export type { StartCombineOp };
|
||||
|
||||
export type ConditionBranch = {
|
||||
candleType: string;
|
||||
candleTypes?: string[];
|
||||
root: LogicNode | null;
|
||||
};
|
||||
|
||||
export type StartSection = {
|
||||
startId: string;
|
||||
candleType: string;
|
||||
candleTypes: string[];
|
||||
root: LogicNode | null;
|
||||
canDelete: boolean;
|
||||
};
|
||||
|
||||
function metaToStartSection(
|
||||
startId: string,
|
||||
meta: Record<string, StartNodeMeta>,
|
||||
root: LogicNode | null,
|
||||
canDelete: boolean,
|
||||
): StartSection {
|
||||
const candleTypes = getStartCandleTypes(meta[startId]);
|
||||
return {
|
||||
startId,
|
||||
candleType: candleTypes[0],
|
||||
candleTypes,
|
||||
root,
|
||||
canDelete,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectStartSections(state: EditorConditionState): StartSection[] {
|
||||
const sections: StartSection[] = [{
|
||||
startId: START_NODE_ID,
|
||||
candleType: normalizeStartCandleType(state.startMeta[START_NODE_ID]?.candleType),
|
||||
root: state.root,
|
||||
canDelete: false,
|
||||
}];
|
||||
const sections: StartSection[] = [
|
||||
metaToStartSection(START_NODE_ID, state.startMeta, state.root, false),
|
||||
];
|
||||
for (const startId of state.extraStartIds) {
|
||||
sections.push({
|
||||
startId,
|
||||
candleType: normalizeStartCandleType(state.startMeta[startId]?.candleType),
|
||||
root: state.extraRoots[startId] ?? null,
|
||||
canDelete: true,
|
||||
});
|
||||
sections.push(
|
||||
metaToStartSection(startId, state.startMeta, state.extraRoots[startId] ?? null, true),
|
||||
);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
@@ -110,8 +124,12 @@ function syncSubtreeCandleTypes(node: LogicNode | null, candleType: string): Log
|
||||
|
||||
function collectExplicitCandleTypesFromTree(node: LogicNode | null, out: Set<string>): void {
|
||||
if (!node) return;
|
||||
if (node.type === 'TIMEFRAME' && node.candleType) {
|
||||
out.add(normalizeStartCandleType(node.candleType));
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
if (node.candleTypes?.length) {
|
||||
for (const ct of node.candleTypes) out.add(normalizeStartCandleType(ct));
|
||||
} else if (node.candleType) {
|
||||
out.add(normalizeStartCandleType(node.candleType));
|
||||
}
|
||||
}
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = node.condition as ConditionDSL;
|
||||
@@ -130,22 +148,23 @@ function inferPrimaryCandleFromTree(root: LogicNode | null): string {
|
||||
return DEFAULT_START_CANDLE;
|
||||
}
|
||||
|
||||
export function updateStartCandleType(
|
||||
export function updateStartCandleTypes(
|
||||
state: EditorConditionState,
|
||||
startId: string,
|
||||
candleType: string,
|
||||
candleTypes: string[],
|
||||
): EditorConditionState {
|
||||
const ct = normalizeStartCandleType(candleType);
|
||||
const types = normalizeCandleTypesList(candleTypes);
|
||||
const primary = types[0];
|
||||
const nextMeta = {
|
||||
...state.startMeta,
|
||||
[startId]: { candleType: ct },
|
||||
[startId]: { candleTypes: types, candleType: primary },
|
||||
};
|
||||
|
||||
if (startId === START_NODE_ID) {
|
||||
return {
|
||||
...state,
|
||||
startMeta: nextMeta,
|
||||
root: syncSubtreeCandleTypes(state.root, ct),
|
||||
root: syncSubtreeCandleTypes(state.root, primary),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,11 +173,39 @@ export function updateStartCandleType(
|
||||
startMeta: nextMeta,
|
||||
extraRoots: {
|
||||
...state.extraRoots,
|
||||
[startId]: syncSubtreeCandleTypes(state.extraRoots[startId] ?? null, ct),
|
||||
[startId]: syncSubtreeCandleTypes(state.extraRoots[startId] ?? null, primary),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 단일 분봉 — updateStartCandleTypes 사용 */
|
||||
export function updateStartCandleType(
|
||||
state: EditorConditionState,
|
||||
startId: string,
|
||||
candleType: string,
|
||||
): EditorConditionState {
|
||||
return updateStartCandleTypes(state, startId, [candleType]);
|
||||
}
|
||||
|
||||
function normalizeCandleTypesList(types: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const ct of STRATEGY_CANDLE_TYPES) {
|
||||
if (types.some(t => normalizeStartCandleType(t) === ct) && !seen.has(ct)) {
|
||||
seen.add(ct);
|
||||
out.push(ct);
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : [DEFAULT_START_CANDLE];
|
||||
}
|
||||
|
||||
function readTimeframeCandleTypes(node: LogicNode): string[] {
|
||||
if (node.candleTypes?.length) {
|
||||
return normalizeCandleTypesList(node.candleTypes);
|
||||
}
|
||||
return [normalizeStartCandleType(node.candleType)];
|
||||
}
|
||||
|
||||
export function addExtraStartSection(state: EditorConditionState): EditorConditionState {
|
||||
const startId = createStartNodeId();
|
||||
return {
|
||||
@@ -166,7 +213,7 @@ export function addExtraStartSection(state: EditorConditionState): EditorConditi
|
||||
extraStartIds: [...state.extraStartIds, startId],
|
||||
startMeta: {
|
||||
...state.startMeta,
|
||||
[startId]: { candleType: DEFAULT_START_CANDLE },
|
||||
[startId]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE },
|
||||
},
|
||||
extraRoots: { ...state.extraRoots, [startId]: null },
|
||||
};
|
||||
@@ -206,32 +253,48 @@ export function emptyEditorConditionState(): EditorConditionState {
|
||||
}
|
||||
|
||||
function wrapTimeframe(candleType: string, root: LogicNode): LogicNode {
|
||||
const ct = normalizeStartCandleType(candleType);
|
||||
return {
|
||||
id: generateNodeId(),
|
||||
type: 'TIMEFRAME',
|
||||
candleType: normalizeStartCandleType(candleType),
|
||||
candleType: ct,
|
||||
children: [root],
|
||||
};
|
||||
}
|
||||
|
||||
function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
||||
const types = normalizeCandleTypesList(candleTypes);
|
||||
if (types.length === 1) return wrapTimeframe(types[0], root);
|
||||
return {
|
||||
id: generateNodeId(),
|
||||
type: 'TIMEFRAME',
|
||||
candleType: types[0],
|
||||
candleTypes: types,
|
||||
children: [root],
|
||||
};
|
||||
}
|
||||
|
||||
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
||||
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
||||
const branches = collectEditorBranches(state).filter(b => b.root != null) as Array<{
|
||||
candleType: string;
|
||||
root: LogicNode;
|
||||
}>;
|
||||
const branches = collectEditorBranches(state).filter(
|
||||
(b): b is ConditionBranch & { root: LogicNode } => b.root != null,
|
||||
);
|
||||
|
||||
if (branches.length === 0) return null;
|
||||
if (branches.length === 1) {
|
||||
const { candleType, root } = branches[0];
|
||||
return wrapTimeframe(candleType, root);
|
||||
const { candleType, candleTypes, root } = branches[0];
|
||||
const types = candleTypes?.length ? candleTypes : [candleType];
|
||||
return wrapTimeframes(types, root);
|
||||
}
|
||||
|
||||
const combineOp = normalizeStartCombineOp(state.startCombineOp);
|
||||
return {
|
||||
id: generateNodeId(),
|
||||
type: combineOp,
|
||||
children: branches.map(b => wrapTimeframe(b.candleType, b.root)),
|
||||
children: branches.map(b => {
|
||||
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
||||
return wrapTimeframes(types, b.root);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,15 +310,16 @@ function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null {
|
||||
let primaryRoot: LogicNode | null = null;
|
||||
|
||||
children.forEach((tf, index) => {
|
||||
const candleType = normalizeStartCandleType(tf.candleType);
|
||||
const candleTypes = readTimeframeCandleTypes(tf as LogicNode);
|
||||
const candleType = candleTypes[0];
|
||||
const branchRoot = tf.children?.[0] ?? null;
|
||||
if (index === 0) {
|
||||
startMeta[START_NODE_ID] = { candleType };
|
||||
startMeta[START_NODE_ID] = { candleTypes, candleType };
|
||||
primaryRoot = branchRoot;
|
||||
return;
|
||||
}
|
||||
const startId = createStartNodeId();
|
||||
startMeta[startId] = { candleType };
|
||||
startMeta[startId] = { candleTypes, candleType };
|
||||
extraStartIds.push(startId);
|
||||
extraRoots[startId] = branchRoot;
|
||||
});
|
||||
@@ -277,10 +341,11 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
||||
if (multiStart) return multiStart;
|
||||
|
||||
if (dsl.type === 'TIMEFRAME') {
|
||||
const candleTypes = readTimeframeCandleTypes(dsl);
|
||||
return {
|
||||
root: dsl.children?.[0] ?? null,
|
||||
startMeta: {
|
||||
[START_NODE_ID]: { candleType: normalizeStartCandleType(dsl.candleType) },
|
||||
[START_NODE_ID]: { candleTypes, candleType: candleTypes[0] },
|
||||
},
|
||||
extraStartIds: [],
|
||||
extraRoots: {},
|
||||
@@ -292,7 +357,7 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
||||
return {
|
||||
root: dsl,
|
||||
startMeta: {
|
||||
[START_NODE_ID]: { candleType: inferred },
|
||||
[START_NODE_ID]: { candleTypes: [inferred], candleType: inferred },
|
||||
},
|
||||
extraStartIds: [],
|
||||
extraRoots: {},
|
||||
@@ -303,14 +368,22 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
||||
/** Logic Expression / 미리보기용 — START별 평가 분봉과 서브트리 */
|
||||
export function collectEditorBranches(state: EditorConditionState): ConditionBranch[] {
|
||||
const branches: ConditionBranch[] = [];
|
||||
const primaryCt = state.startMeta[START_NODE_ID]?.candleType ?? DEFAULT_START_CANDLE;
|
||||
if (state.root) branches.push({ candleType: primaryCt, root: state.root });
|
||||
const primaryTypes = getStartCandleTypes(state.startMeta[START_NODE_ID]);
|
||||
if (state.root) {
|
||||
branches.push({
|
||||
candleType: primaryTypes[0],
|
||||
candleTypes: primaryTypes.length > 1 ? primaryTypes : undefined,
|
||||
root: state.root,
|
||||
});
|
||||
}
|
||||
|
||||
for (const startId of state.extraStartIds) {
|
||||
const root = state.extraRoots[startId] ?? null;
|
||||
if (root) {
|
||||
const types = getStartCandleTypes(state.startMeta[startId]);
|
||||
branches.push({
|
||||
candleType: state.startMeta[startId]?.candleType ?? DEFAULT_START_CANDLE,
|
||||
candleType: types[0],
|
||||
candleTypes: types.length > 1 ? types : undefined,
|
||||
root,
|
||||
});
|
||||
}
|
||||
@@ -318,6 +391,15 @@ export function collectEditorBranches(state: EditorConditionState): ConditionBra
|
||||
return branches;
|
||||
}
|
||||
|
||||
/** DSL·UI에서 사용하는 전략 평가 분봉 목록 */
|
||||
export function collectTimeframesFromEditorState(state: EditorConditionState): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const section of collectStartSections(state)) {
|
||||
for (const ct of section.candleTypes) set.add(ct);
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
||||
export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
||||
return collectEditorBranches(decodeConditionForEditor(dsl));
|
||||
@@ -335,7 +417,8 @@ export function mergeStartMetaForLoad(
|
||||
...stored,
|
||||
};
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
merged[startId] = { candleType: normalizeStartCandleType(meta.candleType) };
|
||||
const types = getStartCandleTypes(meta);
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,12 @@ function describeNode(
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const tf = candleKo(node.candleType ?? '1m');
|
||||
const types = node.candleTypes?.length
|
||||
? node.candleTypes
|
||||
: [node.candleType ?? '1m'];
|
||||
const tf = types.length > 1
|
||||
? types.map(candleKo).join(', ')
|
||||
: candleKo(types[0]);
|
||||
if (!inner) return [`[${tf}] 연결된 조건이 없습니다.`];
|
||||
const innerLines = describeNode(inner, signalType, def, depth + 1);
|
||||
return [`[${tf} 기준]`, ...innerLines.map(l => (l.startsWith('•') ? ` ${l}` : ` • ${l}`))];
|
||||
@@ -248,9 +253,15 @@ function describeSignalBranches(
|
||||
const paragraphs: string[] = [];
|
||||
|
||||
if (active.length === 1) {
|
||||
const { candleType, root } = active[0];
|
||||
const tf = candleKo(candleType);
|
||||
paragraphs.push(`${tf} 차트를 기준으로 아래 조건을 평가합니다.`);
|
||||
const { candleType, candleTypes, root } = active[0];
|
||||
const tf = candleTypes && candleTypes.length > 1
|
||||
? candleTypes.map(candleKo).join(', ')
|
||||
: candleKo(candleType);
|
||||
paragraphs.push(
|
||||
candleTypes && candleTypes.length > 1
|
||||
? `${tf} 각 시간봉 마감 시점마다 아래 조건을 독립적으로 평가합니다.`
|
||||
: `${tf} 차트를 기준으로 아래 조건을 평가합니다.`,
|
||||
);
|
||||
bullets.push(...describeNode(root!, signalType, def));
|
||||
return { hasContent: true, paragraphs, bullets };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { LogicNode, ConditionDSL } from './strategyTypes';
|
||||
import { nodeToText, type DefType } from './strategyEditorShared';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
defaultStartMeta,
|
||||
getStartCandleTypes,
|
||||
isStartNodeId,
|
||||
normalizeStartCandleType,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
@@ -30,12 +32,15 @@ export type StrategyFlowNodeData = {
|
||||
def?: DefType;
|
||||
signalTab?: 'buy' | 'sell';
|
||||
selected?: boolean;
|
||||
/** START 노드 — 조건 판별 시간봉 */
|
||||
/** START 노드 — 조건 판별 시간봉 (표시용 대표) */
|
||||
candleType?: string;
|
||||
onStartCandleTypeChange?: (startId: string, candleType: string) => void;
|
||||
candleTypes?: string[];
|
||||
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
|
||||
onDeleteStart?: (startId: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
||||
/** AND ↔ OR 전환 */
|
||||
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
|
||||
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
||||
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
|
||||
onDragLeaveTarget?: (targetId: string) => void;
|
||||
@@ -353,8 +358,8 @@ export type EdgeHandleBinding = {
|
||||
|
||||
export const FLOW_NODE_W = 200;
|
||||
export const FLOW_NODE_H = 72;
|
||||
export const FLOW_START_W = 168;
|
||||
export const FLOW_START_H = 56;
|
||||
export const FLOW_START_W = 200;
|
||||
export const FLOW_START_H = 78;
|
||||
|
||||
function nodeCenter(
|
||||
id: string,
|
||||
@@ -464,7 +469,11 @@ function layoutTree(
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
callbacks: Pick<
|
||||
StrategyFlowNodeData,
|
||||
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
|
||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||
>,
|
||||
dragOverId: string | null,
|
||||
): void {
|
||||
const yBase = 220;
|
||||
@@ -484,6 +493,7 @@ function layoutTree(
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onUpdateCondition: callbacks.onUpdateCondition,
|
||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
@@ -610,7 +620,11 @@ function appendOrphanFlowNodes(
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
callbacks: Pick<
|
||||
StrategyFlowNodeData,
|
||||
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
|
||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||
>,
|
||||
): void {
|
||||
let orphanY = 80;
|
||||
for (const node of orphans) {
|
||||
@@ -630,6 +644,7 @@ function appendOrphanFlowNodes(
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onUpdateCondition: callbacks.onUpdateCondition,
|
||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
@@ -655,12 +670,13 @@ export function logicNodeToFlow(
|
||||
| 'onDropTarget'
|
||||
| 'onDragOverTarget'
|
||||
| 'onDragLeaveTarget'
|
||||
| 'onStartCandleTypeChange'
|
||||
| 'onStartCandleTypesChange'
|
||||
| 'onDeleteStart'
|
||||
| 'onChangeLogicGateType'
|
||||
>,
|
||||
dragOverId: string | null = null,
|
||||
orphans: LogicNode[] = [],
|
||||
startMeta: Record<string, StartNodeMeta> = { [START_NODE_ID]: { candleType: '1m' } },
|
||||
startMeta: Record<string, StartNodeMeta> = defaultStartMeta(),
|
||||
extraStartIds: string[] = [],
|
||||
extraRoots: Record<string, LogicNode | null> = {},
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
@@ -668,7 +684,7 @@ export function logicNodeToFlow(
|
||||
const edges: Edge[] = [];
|
||||
|
||||
const appendStart = (startId: string, branchRoot: LogicNode | null, defaultY: number) => {
|
||||
const candleType = normalizeStartCandleType(startMeta[startId]?.candleType);
|
||||
const candleTypes = getStartCandleTypes(startMeta[startId]);
|
||||
nodes.push({
|
||||
id: startId,
|
||||
type: 'start',
|
||||
@@ -676,8 +692,9 @@ export function logicNodeToFlow(
|
||||
data: {
|
||||
label: 'START',
|
||||
signalTab,
|
||||
candleType,
|
||||
onStartCandleTypeChange: callbacks.onStartCandleTypeChange,
|
||||
candleType: candleTypes[0],
|
||||
candleTypes,
|
||||
onStartCandleTypesChange: callbacks.onStartCandleTypesChange,
|
||||
onDeleteStart: startId === START_NODE_ID ? undefined : callbacks.onDeleteStart,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
|
||||
@@ -4,13 +4,22 @@ export const START_NODE_ID = '__strategy_start__';
|
||||
export const DEFAULT_START_CANDLE = '1m';
|
||||
|
||||
export const STRATEGY_CANDLE_TYPES = [
|
||||
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d',
|
||||
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1w', '1d',
|
||||
] as const;
|
||||
|
||||
/** 그래프 START 노드 — 시간봉 체크박스 2행 배치 */
|
||||
export const START_TIMEFRAME_GRAPH_ROWS: readonly (readonly StrategyCandleType[])[] = [
|
||||
['1m', '3m', '5m', '10m', '15m'],
|
||||
['30m', '1h', '4h', '1w', '1d'],
|
||||
] as const;
|
||||
|
||||
export type StrategyCandleType = (typeof STRATEGY_CANDLE_TYPES)[number];
|
||||
|
||||
export type StartNodeMeta = {
|
||||
candleType: string;
|
||||
/** @deprecated 단일 분봉 — candleTypes 우선 */
|
||||
candleType?: string;
|
||||
/** 선택된 평가 시간봉 (마감봉마다 독립 체크) */
|
||||
candleTypes?: string[];
|
||||
};
|
||||
|
||||
/** START가 2개 이상일 때 분기 간 결합 연산 */
|
||||
@@ -27,12 +36,42 @@ export function createStartNodeId(): string {
|
||||
}
|
||||
|
||||
export function defaultStartMeta(): Record<string, StartNodeMeta> {
|
||||
return { [START_NODE_ID]: { candleType: DEFAULT_START_CANDLE } };
|
||||
return { [START_NODE_ID]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE } };
|
||||
}
|
||||
|
||||
/** START 메타에서 정렬된 평가 분봉 목록 (최소 1개) */
|
||||
export function getStartCandleTypes(meta?: StartNodeMeta | null): string[] {
|
||||
const raw = meta?.candleTypes?.length
|
||||
? meta.candleTypes
|
||||
: meta?.candleType
|
||||
? [meta.candleType]
|
||||
: [DEFAULT_START_CANDLE];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const ct of STRATEGY_CANDLE_TYPES) {
|
||||
if (raw.some(r => normalizeStartCandleType(r) === ct) && !seen.has(ct)) {
|
||||
seen.add(ct);
|
||||
out.push(ct);
|
||||
}
|
||||
}
|
||||
for (const r of raw) {
|
||||
const n = normalizeStartCandleType(r);
|
||||
if (!seen.has(n)) {
|
||||
seen.add(n);
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : [DEFAULT_START_CANDLE];
|
||||
}
|
||||
|
||||
export function formatStartCandleTypesLabel(types: string[]): string {
|
||||
const list = getStartCandleTypes({ candleTypes: types });
|
||||
return list.map(formatStrategyCandleLabel).join(' · ');
|
||||
}
|
||||
|
||||
export function normalizeStartCandleType(value: string | undefined | null): string {
|
||||
const raw = (value ?? DEFAULT_START_CANDLE).trim().toLowerCase();
|
||||
if (raw === '1d') return '1d';
|
||||
if (raw === '1d' || raw === '1w') return raw;
|
||||
return (STRATEGY_CANDLE_TYPES as readonly string[]).includes(raw) ? raw : DEFAULT_START_CANDLE;
|
||||
}
|
||||
|
||||
@@ -49,6 +88,7 @@ const CANDLE_TYPE_LABELS: Record<string, string> = {
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1w': '주간봉',
|
||||
'1d': '일봉',
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ function newIndId(): string {
|
||||
export function candleTypeToTimeframe(candleType: string): Timeframe {
|
||||
const c = (candleType ?? '1m').trim().toLowerCase();
|
||||
if (c === '1d') return '1D';
|
||||
if (c === '1w') return '1W';
|
||||
if (c === '1h') return '1h';
|
||||
if (c === '4h') return '4h';
|
||||
if (['1m', '3m', '5m', '10m', '15m', '30m'].includes(c)) return c as Timeframe;
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface LogicNode {
|
||||
description?: string;
|
||||
/** TIMEFRAME 노드 — 조건 판별 대상 캔들 타입 (1m, 5m, 1h …) */
|
||||
candleType?: string;
|
||||
/** TIMEFRAME 노드 — 동일 조건을 여러 분봉 마감마다 평가 */
|
||||
candleTypes?: string[];
|
||||
}
|
||||
|
||||
export interface StrategyDSLDto {
|
||||
|
||||
@@ -48,6 +48,7 @@ const CANDLE_TYPE_KO: Record<string, string> = {
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1w': '주간봉',
|
||||
'1d': '일봉',
|
||||
'1D': '일봉',
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import type { StrategyDto } from './backendApi';
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
|
||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||
|
||||
function collectUiTimeframes(strategy: StrategyDto | null | undefined): string[] {
|
||||
if (!strategy) return [];
|
||||
@@ -15,7 +16,8 @@ function collectUiTimeframes(strategy: StrategyDto | null | undefined): string[]
|
||||
...collectDslBranches(buy ?? null),
|
||||
...collectDslBranches(sell ?? null),
|
||||
]) {
|
||||
set.add(normalizeStartCandleType(b.candleType));
|
||||
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
||||
for (const ct of types) set.add(normalizeStartCandleType(ct));
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
@@ -76,7 +78,7 @@ export async function syncVirtualTargetsToBackend(
|
||||
}
|
||||
|
||||
const pinJobs = targets.map(async t => {
|
||||
const strategyId = t.strategyId ?? session.globalStrategyId;
|
||||
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
|
||||
if (strategyId == null) return;
|
||||
await pinStrategyEvaluationTimeframes(t.market, strategyId);
|
||||
});
|
||||
@@ -86,7 +88,7 @@ export async function syncVirtualTargetsToBackend(
|
||||
targets.map(t =>
|
||||
saveLiveStrategySettings({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||
isPinned: !!t.pinned,
|
||||
skipGlobalTemplate: true,
|
||||
...shared,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../utils/virtualTargetLimits';
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync';
|
||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||
import {
|
||||
loadVirtualSession,
|
||||
loadVirtualTargets,
|
||||
@@ -46,7 +47,8 @@ export async function addVirtualTarget(
|
||||
const en = meta.englishName ?? market.replace(/^KRW-/, '');
|
||||
const item: VirtualTargetItem = {
|
||||
market,
|
||||
strategyId: session.globalStrategyId,
|
||||
/** null = 상단 기본 전략 사용 */
|
||||
strategyId: null,
|
||||
koreanName: meta.koreanName,
|
||||
englishName: en,
|
||||
candleType: meta.candleType ? normalizeStartCandleType(meta.candleType) : undefined,
|
||||
@@ -82,7 +84,9 @@ export async function persistVirtualTargetPinned(market: string, pinned: boolean
|
||||
const item = loadVirtualTargets().find(t => t.market === market);
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId: item?.strategyId ?? session.globalStrategyId,
|
||||
strategyId: item
|
||||
? resolveVirtualTargetStrategyId(item, session.globalStrategyId)
|
||||
: session.globalStrategyId,
|
||||
isLiveCheck: session.running,
|
||||
isPinned: pinned,
|
||||
executionType: session.executionType,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { VirtualTargetItem } from './virtualTradingStorage';
|
||||
|
||||
/** 종목 전략 셀렉트 — 기본(전역) 전략 옵션 값 */
|
||||
export const VIRTUAL_DEFAULT_STRATEGY_VALUE = '__default__';
|
||||
|
||||
/** 종목에 전략이 지정되지 않았으면 전역(기본) 전략 ID */
|
||||
export function resolveVirtualTargetStrategyId(
|
||||
target: Pick<VirtualTargetItem, 'strategyId'>,
|
||||
globalStrategyId: number | null,
|
||||
): number | null {
|
||||
if (target.strategyId != null) return target.strategyId;
|
||||
return globalStrategyId;
|
||||
}
|
||||
|
||||
export function usesDefaultStrategy(target: Pick<VirtualTargetItem, 'strategyId'>): boolean {
|
||||
return target.strategyId == null;
|
||||
}
|
||||
|
||||
export function targetStrategySelectValue(target: Pick<VirtualTargetItem, 'strategyId'>): string {
|
||||
return target.strategyId != null ? String(target.strategyId) : VIRTUAL_DEFAULT_STRATEGY_VALUE;
|
||||
}
|
||||
|
||||
export function parseTargetStrategySelectValue(value: string): number | null {
|
||||
if (!value || value === VIRTUAL_DEFAULT_STRATEGY_VALUE) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function defaultStrategyOptionLabel(
|
||||
globalStrategyId: number | null,
|
||||
strategies: Array<{ id?: number; name?: string }>,
|
||||
): string {
|
||||
if (globalStrategyId == null) return '기본 전략 (미설정)';
|
||||
const name = strategies.find(s => s.id === globalStrategyId)?.name;
|
||||
return name ? `기본 전략 (${name})` : '기본 전략';
|
||||
}
|
||||
|
||||
/**
|
||||
* 예전에 전역 전략 ID가 종목에 복사 저장된 항목 → null(기본 전략 따름)로 정규화
|
||||
*/
|
||||
export function coalesceTargetsToDefaultStrategy(
|
||||
targets: VirtualTargetItem[],
|
||||
globalStrategyId: number | null,
|
||||
): VirtualTargetItem[] {
|
||||
if (globalStrategyId == null) return targets;
|
||||
let changed = false;
|
||||
const next = targets.map(t => {
|
||||
if (t.strategyId != null && t.strategyId === globalStrategyId) {
|
||||
changed = true;
|
||||
return { ...t, strategyId: null };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
return changed ? next : targets;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
# GoldenChart 설정 저장소 정리
|
||||
|
||||
> 작성 기준: `goldenChart` 프론트엔드 + Spring Boot 백엔드 코드베이스 (2026-05-23)
|
||||
> **파일 시스템에 사용자 설정 JSON을 두는 방식은 없음.** 브라우저 **localStorage / sessionStorage** 와 **MySQL(DB)** 만 사용.
|
||||
|
||||
---
|
||||
|
||||
## 1. 저장소 종류 요약
|
||||
|
||||
| 저장소 | 용도 | 특징 |
|
||||
|--------|------|------|
|
||||
| **DB (MySQL)** | 계정·기기별 영구 설정, 전략 DSL, 워크스페이스, 체결·알림 이력 | API `PATCH/POST` 저장, Flyway 마이그레이션 |
|
||||
| **localStorage** | 오프라인 미러, UI 레이아웃, 레거시 백업, 읽음/숨김 ID | 브라우저·기기별, 용량 제한 |
|
||||
| **sessionStorage** | 탭 세션 동안만 유효 | 새 탭·브라우저 종료 시 소멸 |
|
||||
| **메모리(React state)** | 현재 세션 UI만 | 새로고침 시 DB/localStorage에서 복원 |
|
||||
|
||||
### 로드 우선순위 (대표)
|
||||
|
||||
| 영역 | 1순위 | 2순위 | 3순위 |
|
||||
|------|--------|--------|--------|
|
||||
| 앱 전역 기본값 | `gc_app_settings` (DB) | `trading_chart_state` (localStorage) | 코드 하드코딩 |
|
||||
| 멀티차트 레이아웃 ID | DB `default_layout_id` + `gc_chart_workspace` | localStorage `chartLayout` | `LAYOUTS[0]` |
|
||||
| 동기화 옵션 | DB `sync_options_json` | localStorage `chartLayoutSync` | `DEFAULT_SYNC` |
|
||||
| 전략 목록 | DB `gc_strategy` | localStorage `gc_strat_v2` | — |
|
||||
| 관심종목 | DB `gc_watchlist` | localStorage `gc_favorites` | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. DB — `gc_app_settings` (설정 화면 · 앱 전역)
|
||||
|
||||
API: `GET/POST /api/app-settings` · 프론트: `useAppSettings` → `saveAppDef()` / `loadAppSettings()`
|
||||
|
||||
| 필드 (API 키) | DB 컬럼 | 설명 |
|
||||
|---------------|---------|------|
|
||||
| `defaultSymbol` | `default_symbol` | 기본 종목 |
|
||||
| `defaultTimeframe` | `default_timeframe` | 기본 타임프레임 |
|
||||
| `defaultChartType` | `default_chart_type` | 기본 차트 타입 |
|
||||
| `defaultTheme` | `default_theme` | 기본 테마 (`dark` / `light` / `blue`) |
|
||||
| `defaultLogScale` | `default_log_scale` | 로그 스케일 기본값 |
|
||||
| `defaultLayoutId` | `default_layout_id` | 멀티차트 레이아웃 ID |
|
||||
| `displayTimezone` | `display_timezone` | 표시 시간대 (IANA) |
|
||||
| `mainChartStyle` | `main_chart_style_json` | **캔들 색상** (상승/하락/심지/테두리) |
|
||||
| `syncOptions` | `sync_options_json` | 멀티차트 동기화 (`symbol`, `interval`, `crosshair`, `time`, `dateRange`) |
|
||||
| `btAutoPopup` | `bt_auto_popup` | 백테스트 완료 시 결과 팝업 |
|
||||
| `btShowPrice` | `bt_show_price` | 백테스트 마커 금액 표시 |
|
||||
| `chartSeriesPriceLabels` | `chart_series_price_labels` | 보조지표 우측 가격축 라벨·설명 |
|
||||
| `chartVolumeVisible` | `chart_volume_visible` | 거래량 pane 표시 |
|
||||
| `chartLiveReceiveHighlight` | `chart_live_receive_highlight` | 실시간 수신 카드 glow |
|
||||
| `chartLegendOptions` | `chart_legend_options_json` | 상단 범례(tv-legend) 항목별 ON/OFF |
|
||||
| `chartPaneSeparator` | `chart_pane_separator_json` | **pane 구분선** (`visible`, `color`, `width`, `lineStyle`) |
|
||||
| `chartRealtimeSource` | `chart_realtime_source` | `BACKEND_STOMP` / `UPBIT_DIRECT` |
|
||||
| `tradeAlertPopup` | `trade_alert_popup` | 매매 시그널 알림 팝업 |
|
||||
| `tradeAlertSoundEnabled` | `trade_alert_sound_enabled` | 알림 사운드 ON/OFF |
|
||||
| `tradeAlertSound` | `trade_alert_sound` | 알림음 ID (`bell` 등) |
|
||||
| `tradeAlertPopupPosition` | `trade_alert_popup_position` | 팝업 위치 |
|
||||
| `tradeAlertPopupLayout` | `trade_alert_popup_layout` | 팝업 배치 (`stack` / `grid` 등) |
|
||||
| `tradeAlertPopupGridCols` | `trade_alert_popup_grid_cols` | 그리드 열 수 |
|
||||
| `verificationIssueNotify` | `verification_issue_notify` | 검증 이슈 알림 |
|
||||
| `liveStrategyCheck` | `live_strategy_check` | 실시간 전략 체크 마스터 |
|
||||
| `liveStrategyId` | `live_strategy_id` | 관심종목 공통 전략 ID |
|
||||
| `liveExecutionType` | `live_execution_type` | `CANDLE_CLOSE` / `REALTIME_TICK` |
|
||||
| `livePositionMode` | `live_position_mode` | `LONG_ONLY` / `SIGNAL_ONLY` |
|
||||
| `paperTradingEnabled` | `paper_trading_enabled` | 모의투자 기능 |
|
||||
| `paperInitialCapital` | `paper_initial_capital` | 모의 초기 자본 |
|
||||
| `paperFeeRatePct` | `paper_fee_rate_pct` | 모의 수수료율 |
|
||||
| `paperSlippagePct` | `paper_slippage_pct` | 모의 슬리피지 |
|
||||
| `paperMinOrderKrw` | `paper_min_order_krw` | 모의 최소 주문 금액 |
|
||||
| `paperAutoTradeEnabled` | `paper_auto_trade_enabled` | 자동 모의매매 |
|
||||
| `paperAutoTradeBudgetPct` | `paper_auto_trade_budget_pct` | 자동매수 예산 비율 |
|
||||
| `virtualTargetMaxCount` | `virtual_target_max_count` | 가상매매 종목 수 상한 |
|
||||
| `tradingMode` | `trading_mode` | `PAPER` / `LIVE` / `BOTH` |
|
||||
| `liveAutoTradeEnabled` | `live_auto_trade_enabled` | 실거래 자동매매 |
|
||||
| `liveAutoTradeBudgetPct` | `live_auto_trade_budget_pct` | 실거래 자동매수 비율 |
|
||||
| `fcmPushEnabled` | `fcm_push_enabled` | FCM 푸시 (기능 플래그) |
|
||||
| `upbitAccessKey` / `upbitSecretKey` | `upbit_access_key`, `upbit_secret_key` | 업비트 API 키 (암호화 저장) |
|
||||
| `trendSearchSettings` | `trend_search_settings_json` | 추세검색 가중치·자동갱신·자동추가 등 |
|
||||
|
||||
키 식별: `device_id` 또는 로그인 `user_id` (백엔드 `AppSettingsService`).
|
||||
|
||||
---
|
||||
|
||||
## 3. DB — 차트·지표·전략·매매 (설정 화면 외)
|
||||
|
||||
### 3.1 차트 워크스페이스
|
||||
|
||||
| 테이블 | API | 저장 내용 |
|
||||
|--------|-----|-----------|
|
||||
| `gc_chart_workspace` | `GET/POST /api/chart/workspace` | `layout_id`, `sync_options_json` |
|
||||
| `gc_chart_slot` | `PUT /api/chart/slot/{index}` | 슬롯별 `symbol`, `timeframe`, `chart_type`, `theme`, `mode`, `log_scale`, **`indicators_json`**, **`drawings_json`**, `drawings_locked`, `drawings_visible`, `pane_layout_json`, **`main_chart_style_json`** |
|
||||
|
||||
- **싱글 차트(`App.tsx`)**: `useWorkspacePersist` → 슬롯 0 위주로 워크스페이스 전체 POST (2초 debounce).
|
||||
- **멀티 슬롯(`ChartSlot`)**: 슬롯마다 `saveSlot()` 개별 PUT (2초 debounce). App의 `workspaceState`는 주석대로 **슬롯 0만** 배열에 넣는 경우 있음 → 멀티는 슬롯 컴포넌트 저장이 실질적 기준.
|
||||
|
||||
### 3.2 보조지표 전역 기본값 (설정 → 지표 탭)
|
||||
|
||||
| 테이블 | API | 저장 내용 |
|
||||
|--------|-----|-----------|
|
||||
| `gc_indicator_settings` | `GET/POST` 지표 설정 API | `params_json` — 타입별 **계산 파라미터** (length 등) |
|
||||
| ↑ 동일 | 시각 설정 API | `visual_config_json` — 타입별 **plots(색상·선굵기·선스타일)**, **hlines**, `cloudColors` |
|
||||
|
||||
프론트: `useIndicatorSettings` · 설정 화면 `IndicatorMainDefaultsPanel` · 차트에 지표 추가 시 DB 값이 `indicatorRegistry` 기본값을 덮어씀.
|
||||
|
||||
**차트에 올라간 인스턴스**(`IndicatorConfig[]`, id/plotVisibility/mergedWith 등)는 **`gc_chart_slot.indicators_json`** (또는 싱글 워크스페이스 슬롯 0).
|
||||
|
||||
### 3.3 투자전략 (DSL)
|
||||
|
||||
| 테이블 | API | 저장 내용 |
|
||||
|--------|-----|-----------|
|
||||
| `gc_strategy` | `/api/strategies` | `name`, `description`, `buy_condition_json`, `sell_condition_json`, `enabled` |
|
||||
|
||||
- **정본: DB.** 저장 시 `StrategyPage` / `StrategyEditorPage`가 API 호출.
|
||||
- localStorage `gc_strat_v2`는 **백업·마이그레이션·오프라인 폴백** (`saveStratsLocal`).
|
||||
|
||||
### 3.4 백테스트
|
||||
|
||||
| 테이블 | API | 저장 내용 |
|
||||
|--------|-----|-----------|
|
||||
| `gc_backtest_settings` | `/api/backtest-settings` | 초기자본, 수수료, 슬리피지, 진입/청산가, 손절·익절, 트레일링, 재진입 등 |
|
||||
| `gc_backtest_result` (등) | `/api/backtest-results` | 실행 결과·분석 JSON (설정값 아님) |
|
||||
|
||||
### 3.5 실시간 전략 / 가상매매 연동
|
||||
|
||||
| 테이블 | API | 저장 내용 |
|
||||
|--------|-----|-----------|
|
||||
| `gc_live_strategy_settings` | `/api/strategy/settings` | 종목별 `strategy_id`, `is_live_check`, `execution_type`, `candle_type`, `position_mode`, `is_pinned` |
|
||||
|
||||
가상매매 UI의 종목 목록·세션은 **localStorage** (아래)이고, 실행·핀·분봉은 위 DB와 동기화.
|
||||
|
||||
### 3.6 관심종목·보유·모의·시그널·검증
|
||||
|
||||
| 테이블 | 용도 |
|
||||
|--------|------|
|
||||
| `gc_watchlist` | 관심종목 (DB 정본) |
|
||||
| `gc_holdings` | 보유종목 |
|
||||
| `gc_paper_account`, `gc_paper_position`, `gc_paper_trade` | 모의 계좌·포지션·체결 |
|
||||
| `gc_trade_signal` | 매매 시그널 이력 (서버) |
|
||||
| `gc_verification_issue` (+ comment, image) | 검증 게시판 |
|
||||
| `gc_trend_search_snapshot` | 추세검색 스냅샷 |
|
||||
| `gc_user`, 역할·메뉴 권한 테이블 | 로그인·`AdminSettingsPanel` |
|
||||
|
||||
### 3.7 지표 계산 캐시
|
||||
|
||||
| 테이블 | 용도 |
|
||||
|--------|------|
|
||||
| `gc_indicator_cache` | 서버 계산 결과 캐시 (사용자 UI 설정 아님) |
|
||||
|
||||
---
|
||||
|
||||
## 4. localStorage 전체 목록
|
||||
|
||||
| 키 | 파일/모듈 | 저장 내용 | DB와 관계 |
|
||||
|----|-----------|-----------|-----------|
|
||||
| `trading_chart_state` | `utils/storage.ts` | symbol, timeframe, chartType, theme, indicators[], watchlist[], alertPrices[], mainChartStyle (v8) | **미러·레거시**; 앱은 DB 우선 로드 후 `saveState`로 동기 갱신 |
|
||||
| `chartLayout` | `App.tsx` | 멀티차트 레이아웃 ID | DB `default_layout_id` / workspace와 **이중** |
|
||||
| `chartLayoutSync` | `App.tsx` | `SyncOptions` JSON | DB `sync_options_json`와 **이중** |
|
||||
| `gc_app_settings` (캐시 아님) | — | (해당 없음 — DB만) | |
|
||||
| `gc_favorites` | `utils/marketStorage.ts` | 관심종목 심볼 배열 | DB `gc_watchlist` **미러** |
|
||||
| `upbit_market_favorites` | ↑ | 구버전 키 → `gc_favorites` 1회 이전 | — |
|
||||
| `gc_holdings` | `marketStorage.ts` | 보유종목 배열 | DB `gc_holdings` **미러** |
|
||||
| `gc_market_names` | `marketNameCache.ts` | 종목 한글명 캐시 | 성능용 캐시 |
|
||||
| `gc_device_id` | `backendApi.ts` 등 | 기기 UUID | API 헤더·DB 행 식별 |
|
||||
| `gc_user_id`, `gc_username`, `gc_display_name`, `gc_user_role` | `auth.ts` | 로그인 세션 정보 | 서버 세션 보조 |
|
||||
| `gc_strat_v2` | `StrategyPage`, `strategyEditorShared`, Toolbar | 전략 목록 JSON 백업 | DB `gc_strategy` **폴백/마이그레이션** |
|
||||
| `gc_strategies_v1` | `strategyTypes.ts` | 구버전 전략 DSL 배열 | 레거시 (v2와 별도) |
|
||||
| `gc_indicator_settings` | — | (직접 키 없음 — DB API) | |
|
||||
| `gc-indicator-custom-tabs-v1` | `indicatorCustomTabsStorage.ts` | 지표 추가 팝업 **사용자 정의 탭** (이름 + 지표 type 목록) | **localStorage만** |
|
||||
| `gc_se_flow_layout_v1` | `strategyEditorLayoutStorage.ts` | 전략편집기 **그래프 노드 위치·엣지·START 메타** (전략 키별) | **localStorage만** (DSL 본문은 DB) |
|
||||
| `gc_se_editor_mode` | `strategyEditorModeStorage.ts` | `graph` / `list` 편집 모드 | **localStorage만** |
|
||||
| `gc_se_canvas_interaction` | `strategyCanvasInteractionStorage.ts` | 캔버스 인터랙션 모드 | **localStorage만** |
|
||||
| `se-palette-auxiliary-v1` | `strategyPaletteStorage.ts` | 전략편집기 팔레트 — 보조지표 목록 커스텀 | **localStorage만** |
|
||||
| `se-palette-composite-v1` | ↑ | 팔레트 — 복합지표 목록 커스텀 | **localStorage만** |
|
||||
| `se-left-width` | `StrategyEditorPage` | 좌측 패널 너비(px) | **localStorage만** |
|
||||
| `se-terminal-height` | ↑ | 하단 터미널 높이(px) | **localStorage만** |
|
||||
| `gc_virtual_targets_v1` | `virtualTradingStorage.ts` | 가상매매 **투자대상** (market, strategyId, candleType, pinned, 이름) | DB live settings와 **동기** |
|
||||
| `gc_virtual_session_v1` | ↑ | 가상매매 **세션** (globalStrategyId, executionType, positionMode, running) | 부분적으로 app settings와 중복 |
|
||||
| `gc_virtual_card_view_v1` | ↑ | 카드 보기 `summary` / `detail` | **localStorage만** |
|
||||
| `gc_trade_notify_read_v1` | `TradeNotificationContext.tsx` | 읽은 알림 ID 목록 | 서버 이력 + 클라이언트 읽음 |
|
||||
| `gc_trade_notify_hidden_v1` | ↑ | 목록에서 숨긴 알림 ID | **localStorage만** |
|
||||
| `tsd-display-mode` | `TrendSearchPage.tsx` | 추세검색 결과 `chart` / `signal` 표시 모드 | **localStorage만** |
|
||||
| `btd-left-width` | `BacktestHistoryPage.tsx` | 백테스트 이력 좌측 패널 너비 | **localStorage만** |
|
||||
| `btd-right-width` | ↑ | 백테스트 이력 우측 패널 너비 | **localStorage만** |
|
||||
|
||||
---
|
||||
|
||||
## 5. sessionStorage
|
||||
|
||||
| 키 | 모듈 | 내용 |
|
||||
|----|------|------|
|
||||
| `gc_app_entered` | `appEntry.ts` | 스플래시 통과 여부 (탭 세션) |
|
||||
| `gc_admin_unlock_until` | `adminUnlock.ts` | 관리자 설정 패널 잠금 해제 만료 시각 (30분) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 설정 화면 UI — 저장 여부 상세
|
||||
|
||||
### 6.1 DB에 저장됨 (`gc_app_settings` / API)
|
||||
|
||||
- 일반 · 표시 시간대 · 레이아웃 기본 · 테마
|
||||
- **차트**: 실시간 데이터 소스, 수신 음영, 거래량, 지표 가격축 라벨, **pane 구분선**, 상단 범례 항목, (캔들 색은 **메인 차트 설정 모달** → `mainChartStyle`)
|
||||
- **지표 탭**: `gc_indicator_settings` params + visual (색·선·hline)
|
||||
- 백테스트 · 알림 · 실시간/모의/실거래 · 추세검색 · FCM 플래그
|
||||
- 관리자: 사용자·역할 메뉴 권한 → **별도 DB 테이블** (`AdminSettingsPanel`)
|
||||
|
||||
### 6.2 DB 또는 워크스페이스에 저장 (설정 화면 밖)
|
||||
|
||||
| 항목 | 저장 위치 |
|
||||
|------|-----------|
|
||||
| 캔들 색상 (실제 적용) | `mainChartStyle` → DB + `gc_chart_slot.main_chart_style_json` |
|
||||
| 차트에 붙은 보조지표 목록·개별 plot 색 | `gc_chart_slot.indicators_json` |
|
||||
| 드로잉 | `gc_chart_slot.drawings_json` (싱글 App state → workspace) |
|
||||
| 격자선 ON/OFF (`showGrid`) | 워크스페이스/슬롯에 **전용 필드 없음** → **미영구** (세션 state) |
|
||||
| 로그 스케일 | 슬롯 `log_scale` (DB 가능, ChartSlot은 현재 `false` 고정 저장) |
|
||||
| 퍼센트 스케일 | **미저장** |
|
||||
| 자석 모드 (`magnetMode`) | 설정 토글은 App state만, **DB/localStorage 없음** |
|
||||
|
||||
### 6.3 설정 화면에만 있고 **저장되지 않음** (주의)
|
||||
|
||||
`SettingsPage` → `ChartPanel` 내부 **로컬 state** (`useState` 초기값 고정). 저장 API와 **연결 안 됨**:
|
||||
|
||||
| UI 라벨 | state | 실제 반영 |
|
||||
|---------|--------|-----------|
|
||||
| 상승/하락/배경 캔들 색 | `upColor`, `downColor`, `bgColor` | **없음** (진짜 색상은 메인 차트 설정 모달 → DB) |
|
||||
| 격자선 표시 | `gridLines` | **없음** (툴바 `showGrid`는 App state, 미영구) |
|
||||
| 크로스헤어 모드 | `crosshair` | **없음** |
|
||||
| 가격축 위치 | `priceScale` | **없음** |
|
||||
| 소수점 자리수 | `precision` | **없음** |
|
||||
|
||||
---
|
||||
|
||||
## 7. 차트 선색·선스타일 — 어디에 무엇이 저장되는가
|
||||
|
||||
| 대상 | 색상 | 선 굵기 | 선 스타일 | 저장소 |
|
||||
|------|------|---------|-----------|--------|
|
||||
| **캔들(메인)** | O | (캔들 형태) | — | DB `main_chart_style_json` |
|
||||
| **보조지표 plot (전역 기본)** | O | O | O (`solid`/`dashed`/`dotted`) | DB `visual_config_json` |
|
||||
| **보조지표 hline (전역)** | O | O | O | DB `visual_config_json` |
|
||||
| **차트에 올린 지표 인스턴스** | O (plots 복사) | O | O | DB `indicators_json` (슬롯별) |
|
||||
| **pane 구분선** | O | O (1–4px) | O | DB `chart_pane_separator_json` |
|
||||
| **드로잉 도구 선** | Drawing 객체 필드 | width 등 | style | `drawings_json` (저장 시) |
|
||||
| **격자선** | 테마 `gridColor` | — | — | 테마·`showGrid` state (격자 ON/OFF 미영구) |
|
||||
|
||||
일목균형 `cloudColors` → `visual_config_json` 내 Ichimoku 전용.
|
||||
|
||||
---
|
||||
|
||||
## 8. 지표 탭 · 전략편집기 · 기타 UI
|
||||
|
||||
| 기능 | 저장소 |
|
||||
|------|--------|
|
||||
| 설정 → 지표 기본값 (파라미터·스타일) | DB `gc_indicator_settings` |
|
||||
| 지표 추가 팝업 **커스텀 탭** | localStorage `gc-indicator-custom-tabs-v1` only |
|
||||
| 전략편집기 DSL | DB `gc_strategy` |
|
||||
| 전략편집기 노드 좌표·START 분봉 메타 | localStorage `gc_se_flow_layout_v1` |
|
||||
| 전략편집기 팔레트 항목 | localStorage `se-palette-*` |
|
||||
| 전략편집기 패널 크기 | localStorage `se-left-width`, `se-terminal-height` |
|
||||
| 전략편집기 graph/list 모드 | localStorage `gc_se_editor_mode` |
|
||||
|
||||
---
|
||||
|
||||
## 9. 이중 저장·동기화 시 주의
|
||||
|
||||
1. **`trading_chart_state` vs DB**
|
||||
App이 변경마다 `saveState()` 호출. DB `useAppSettings`와 **별도**. 초기 로드는 DB → `loadState` 혼용.
|
||||
|
||||
2. **`chartLayout` / `chartLayoutSync` vs workspace**
|
||||
레이아웃 변경 시 localStorage + `saveAppDef({ defaultLayoutId })` + workspace POST.
|
||||
|
||||
3. **전략 `gc_strat_v2` vs `gc_strategy`**
|
||||
DB가 비면 localStorage에서 마이그레이션. Toolbar 백테스트 전략 목록은 DB 실패 시 local 폴백.
|
||||
|
||||
4. **가상매매**
|
||||
- 목록/세션: localStorage
|
||||
- 종목별 전략·live·핀·분봉: `gc_live_strategy_settings`
|
||||
- 전역 기본 전략: `gc_app_settings.liveStrategyId` 등
|
||||
|
||||
5. **관심종목**
|
||||
DB 정본 + `gc_favorites` 미러 (`marketStorage.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 10. 파일 시스템
|
||||
|
||||
- 사용자 설정을 **로컬 디스크 파일**로 export/import 하는 공식 기능 **없음**.
|
||||
- Firebase FCM: 환경변수 `VITE_FIREBASE_*` (빌드 설정, DB 아님).
|
||||
|
||||
---
|
||||
|
||||
## 11. 빠른 참조 — “이 설정 어디?”
|
||||
|
||||
| 사용자가 바꾸는 것 | 저장 |
|
||||
|--------------------|------|
|
||||
| 설정 → 차트 → 구분선/거래량/범례/실시간 소스 | **DB** `gc_app_settings` |
|
||||
| 설정 → 차트 → 캔들 색 (모달) | **DB** `mainChartStyle` |
|
||||
| 설정 → 지표 RSI 색/기간 | **DB** indicator settings |
|
||||
| 차트에 RSI 추가 후 색만 변경 | **DB** slot `indicators_json` |
|
||||
| 멀티차트 4분할 레이아웃 | **DB** workspace + localStorage `chartLayout` |
|
||||
| 전략 저장 | **DB** `gc_strategy` |
|
||||
| 전략 편집기에서 블록 위치 | **localStorage** flow layout |
|
||||
| 가상매매 종목 추가 | **localStorage** + **DB** live settings |
|
||||
| 알림 읽음/삭제 | 읽음·숨김 ID → **localStorage**; 이력 → **DB** signals |
|
||||
| 백테스트 수수료·손절 | **DB** `gc_backtest_settings` |
|
||||
| 추세검색 가중치 | **DB** `trendSearchSettings` |
|
||||
| 추세검색 카드/시그널 보기 | **localStorage** `tsd-display-mode` |
|
||||
|
||||
---
|
||||
|
||||
*문서 위치: 프로젝트 루트 `setting_save_storage.md`*
|
||||
Reference in New Issue
Block a user