가상투자 종목카드박스 레이아웃 수정
This commit is contained in:
@@ -86,6 +86,8 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map.entry("VR", "VR"),
|
||||
Map.entry("DISPARITY", "Disparity"),
|
||||
Map.entry("DONCHIAN", "DonchianChannels"),
|
||||
Map.entry("NEW_HIGH", "PriceExtreme"),
|
||||
Map.entry("NEW_LOW", "PriceExtreme"),
|
||||
Map.entry("VOLUME", "VOLUME")
|
||||
);
|
||||
|
||||
@@ -281,6 +283,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
return buildCrossTimeframeCompositeRule(cond, ctx);
|
||||
}
|
||||
|
||||
PriceExtremeNorm px = normalizePriceExtremeCondition(cond);
|
||||
if (px != null) {
|
||||
indType = px.indType();
|
||||
condType = px.condType();
|
||||
leftField = px.leftField();
|
||||
rightField = px.rightField();
|
||||
condPeriod = px.period();
|
||||
}
|
||||
|
||||
// DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회
|
||||
String registryKey = toRegistryKey(indType);
|
||||
Map<String, Object> indParams = params != null
|
||||
@@ -291,7 +302,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod);
|
||||
Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod);
|
||||
|
||||
return switch (condType) {
|
||||
Rule core = switch (condType) {
|
||||
case "GT" -> new OverIndicatorRule(left, right);
|
||||
case "LT" -> new UnderIndicatorRule(left, right);
|
||||
// GTE/LTE: OverIndicator OR 동일 (epsilon 비교)
|
||||
@@ -336,12 +347,72 @@ public class StrategyDslToTa4jAdapter {
|
||||
yield new BooleanRule(false);
|
||||
}
|
||||
};
|
||||
if (px != null) {
|
||||
return nanSafeCompareRule(core, left, right);
|
||||
}
|
||||
return core;
|
||||
} catch (Exception e) {
|
||||
log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage());
|
||||
return new BooleanRule(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고가·신저가 DSL 정규화 — 구버전 DC_UPPER/DC_LOWER·복합지표 승격 필드를 NH_PRIOR/NL_PRIOR 로 통일.
|
||||
*/
|
||||
private record PriceExtremeNorm(String indType, String condType, String leftField, String rightField, int period) {}
|
||||
|
||||
private PriceExtremeNorm normalizePriceExtremeCondition(JsonNode cond) {
|
||||
String ind = cond.path("indicatorType").asText("");
|
||||
if (!"NEW_HIGH".equals(ind) && !"NEW_LOW".equals(ind)) return null;
|
||||
|
||||
String ct = cond.path("conditionType").asText("GT");
|
||||
String left = cond.path("leftField").asText("CLOSE_PRICE");
|
||||
String right = cond.path("rightField").asText("");
|
||||
|
||||
int period = cond.path("period").asInt(-1);
|
||||
int leftP = cond.path("leftPeriod").asInt(-1);
|
||||
if (period <= 0) period = leftP > 0 ? leftP : 9;
|
||||
|
||||
if (right.startsWith("DC_UPPER_")) {
|
||||
period = parseTrailingInt(right, "DC_UPPER_", period);
|
||||
right = "NH_PRIOR_" + period;
|
||||
} else if (right.startsWith("DC_LOWER_")) {
|
||||
period = parseTrailingInt(right, "DC_LOWER_", period);
|
||||
right = "NL_PRIOR_" + period;
|
||||
} else if (right.startsWith("NH_PRIOR_")) {
|
||||
period = parseTrailingInt(right, "NH_PRIOR_", period);
|
||||
} else if (right.startsWith("NL_PRIOR_")) {
|
||||
period = parseTrailingInt(right, "NL_PRIOR_", period);
|
||||
} else if (right.isBlank() || "NONE".equals(right)) {
|
||||
right = "NEW_HIGH".equals(ind) ? "NH_PRIOR_" + period : "NL_PRIOR_" + period;
|
||||
}
|
||||
|
||||
if (left.isBlank() || "NONE".equals(left)) left = "CLOSE_PRICE";
|
||||
|
||||
// Donchian 당일봉 포함 DC_* + CROSS 는 거의 성립하지 않음 → PRIOR 필드 + GTE/LTE
|
||||
if ("NEW_HIGH".equals(ind) && "CROSS_UP".equals(ct) && !right.startsWith("NH_PRIOR_")) {
|
||||
ct = "GTE";
|
||||
} else if ("NEW_LOW".equals(ind) && "CROSS_DOWN".equals(ct) && !right.startsWith("NL_PRIOR_")) {
|
||||
ct = "LTE";
|
||||
}
|
||||
|
||||
return new PriceExtremeNorm(ind, ct, left, right, period);
|
||||
}
|
||||
|
||||
/** 직전 N봉 극값이 NaN 인 구간(워밍업)에서는 조건 미충족 */
|
||||
private Rule nanSafeCompareRule(Rule inner, Indicator<Num> left, Indicator<Num> right) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
Num l = left.getValue(index);
|
||||
Num r = right.getValue(index);
|
||||
if (l == null || r == null || l.isNaN() || r.isNaN()) return false;
|
||||
return inner.isSatisfied(index, tradingRecord);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */
|
||||
private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) {
|
||||
if (!cond.path("composite").asBoolean(false)) return false;
|
||||
@@ -611,6 +682,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
return new WilliamsRIndicator(s, period);
|
||||
}
|
||||
if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14));
|
||||
// 신고가·신저가 — 직전 N봉 최고/최저 (당일 봉 제외, PreviousValueIndicator)
|
||||
if (field.startsWith("NH_PRIOR_")) {
|
||||
int period = parseTrailingInt(field, "NH_PRIOR_", intP(p, "length", 9));
|
||||
return priorHighestHigh(s, period);
|
||||
}
|
||||
if (field.startsWith("NL_PRIOR_")) {
|
||||
int period = parseTrailingInt(field, "NL_PRIOR_", intP(p, "length", 9));
|
||||
return priorLowestLow(s, period);
|
||||
}
|
||||
// Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사
|
||||
if (field.startsWith("DC_UPPER_")) {
|
||||
int period = parseTrailingInt(field, "DC_UPPER_", intP(p, "length", 20));
|
||||
@@ -857,6 +937,25 @@ public class StrategyDslToTa4jAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직전 N봉 고가 중 최고값 (현재 봉 제외).
|
||||
* index t 에서 max(high[t-N]..high[t-1]).
|
||||
*/
|
||||
private Indicator<Num> priorHighestHigh(BarSeries s, int period) {
|
||||
HighPriceIndicator high = new HighPriceIndicator(s);
|
||||
HighestValueIndicator highest = new HighestValueIndicator(high, period);
|
||||
return new PreviousValueIndicator(highest, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직전 N봉 저가 중 최저값 (현재 봉 제외).
|
||||
*/
|
||||
private Indicator<Num> priorLowestLow(BarSeries s, int period) {
|
||||
LowPriceIndicator low = new LowPriceIndicator(s);
|
||||
LowestValueIndicator lowest = new LowestValueIndicator(low, period);
|
||||
return new PreviousValueIndicator(lowest, 1);
|
||||
}
|
||||
|
||||
// ── 파라미터 헬퍼 ─────────────────────────────────────────────────────────
|
||||
|
||||
private int intP(Map<String, Object> p, String k, int def) {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.ta4j.core.TradingRecord;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 9일 신고가 매수 / 9일 신저가 매도 — Rule·백테스트 루프 동작 검증.
|
||||
*/
|
||||
class PriceExtremeRuleTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private StrategyDslToTa4jAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new StrategyDslToTa4jAdapter();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nhPrior_buyGte_firesOnBreakout_notOnFirstBars() throws Exception {
|
||||
BarSeries series = buildTrendSeries(120, true);
|
||||
Rule buy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_HIGH",
|
||||
"conditionType": "GTE",
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "NH_PRIOR_9",
|
||||
"period": 9
|
||||
}}
|
||||
""");
|
||||
|
||||
List<Integer> hits = satisfiedIndices(buy, series);
|
||||
assertTrue(hits.stream().noneMatch(i -> i < 9),
|
||||
"9봉 미만에서는 신고가선이 정의되지 않아 매수 조건이 없어야 함");
|
||||
assertFalse(hits.isEmpty(), "상승 구간에서 최소 1회 이상 매수 조건 충족");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nlPrior_sellLte_firesOnBreakdown() throws Exception {
|
||||
BarSeries series = buildTrendSeries(120, false);
|
||||
Rule sell = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_LOW",
|
||||
"conditionType": "LTE",
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "NL_PRIOR_9",
|
||||
"period": 9
|
||||
}}
|
||||
""");
|
||||
|
||||
List<Integer> hits = satisfiedIndices(sell, series);
|
||||
assertFalse(hits.isEmpty(), "하락 구간에서 신저가 이탈 조건이 최소 1회 충족되어야 함");
|
||||
}
|
||||
|
||||
/** LONG_ONLY 백테스트 루프 — 매수 후 하락 시 매도가 나와야 함 */
|
||||
@Test
|
||||
void longOnlyLoop_buyThenSell_completesRoundTrip() throws Exception {
|
||||
BarSeries series = buildCrashThenRecoverSeries(80);
|
||||
Rule buy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
|
||||
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9
|
||||
}}
|
||||
""");
|
||||
Rule sell = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_LOW", "conditionType": "LTE",
|
||||
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9
|
||||
}}
|
||||
""");
|
||||
|
||||
SimResult sim = simulateLongOnly(buy, sell, series);
|
||||
assertTrue(sim.buyBars.size() >= 1, "매수 시그널 최소 1회");
|
||||
assertTrue(sim.sellBars.size() >= 1,
|
||||
"하락 구간에서 신저가 매도가 나와야 함. buys=" + sim.buyBars + " sells=" + sim.sellBars);
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizedLegacySell_inLoop_producesSellAfterAdapterMigrate() throws Exception {
|
||||
BarSeries series = buildCrashThenRecoverSeries(80);
|
||||
Rule buy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
|
||||
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9
|
||||
}}
|
||||
""");
|
||||
Rule sellLegacy = rule(series, """
|
||||
{ "type": "CONDITION", "condition": {
|
||||
"indicatorType": "NEW_LOW",
|
||||
"conditionType": "CROSS_DOWN",
|
||||
"composite": true,
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "DC_LOWER_9",
|
||||
"leftPeriod": 9,
|
||||
"rightPeriod": 20
|
||||
}}
|
||||
""");
|
||||
|
||||
SimResult sim = simulateLongOnly(buy, sellLegacy, series);
|
||||
assertTrue(sim.buyBars.size() >= 1);
|
||||
assertTrue(sim.sellBars.size() >= 1,
|
||||
"어댑터가 DC_LOWER→NL_PRIOR 로 정규화하면 하락 구간에서 매도가 나와야 함");
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Rule rule(BarSeries series, String json) throws Exception {
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
return adapter.toRule(node, series, Map.of());
|
||||
}
|
||||
|
||||
private List<Integer> satisfiedIndices(Rule rule, BarSeries series) {
|
||||
List<Integer> hits = new ArrayList<>();
|
||||
TradingRecord record = null;
|
||||
for (int i = 0; i < series.getBarCount(); i++) {
|
||||
if (rule.isSatisfied(i, record)) hits.add(i);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
private static BarSeries buildTrendSeries(int barCount, boolean up) {
|
||||
Duration period = Duration.ofDays(1);
|
||||
BarSeries series = new BaseBarSeriesBuilder()
|
||||
.withName("trend")
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||
Instant base = Instant.parse("2024-01-01T00:00:00Z");
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
double delta = up ? 1.5 : -1.5;
|
||||
double close = price + delta;
|
||||
double high = Math.max(price, close) + 0.5;
|
||||
double low = Math.min(price, close) - 0.5;
|
||||
factory.createBarBuilder(series)
|
||||
.timePeriod(period)
|
||||
.endTime(base.plus(period.multipliedBy(i + 1L)))
|
||||
.openPrice(price).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(1000)
|
||||
.add();
|
||||
price = close;
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
/** 초반 횡보 후 급락 → 회복 (차트와 유사) */
|
||||
private static BarSeries buildCrashThenRecoverSeries(int barCount) {
|
||||
Duration period = Duration.ofDays(1);
|
||||
BarSeries series = new BaseBarSeriesBuilder()
|
||||
.withName("crash-recover")
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||
Instant base = Instant.parse("2024-01-01T00:00:00Z");
|
||||
double price = 100_000_000;
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
double chg;
|
||||
if (i < 15) chg = (i % 3 - 1) * 0.002;
|
||||
else if (i < 35) chg = -0.04;
|
||||
else chg = 0.008;
|
||||
double close = price * (1 + chg);
|
||||
double high = Math.max(price, close) * 1.005;
|
||||
double low = Math.min(price, close) * 0.995;
|
||||
factory.createBarBuilder(series)
|
||||
.timePeriod(period)
|
||||
.endTime(base.plus(period.multipliedBy(i + 1L)))
|
||||
.openPrice(price).highPrice(high).lowPrice(low).closePrice(close)
|
||||
.volume(1000)
|
||||
.add();
|
||||
price = close;
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
private SimResult simulateLongOnly(Rule entryRule, Rule exitRule, BarSeries series) {
|
||||
List<Integer> buyBars = new ArrayList<>();
|
||||
List<Integer> sellBars = new ArrayList<>();
|
||||
boolean inPosition = false;
|
||||
org.ta4j.core.BaseTradingRecord record = new org.ta4j.core.BaseTradingRecord();
|
||||
for (int i = 0; i < series.getBarCount(); i++) {
|
||||
if (!inPosition) {
|
||||
if (entryRule.isSatisfied(i, record)) {
|
||||
buyBars.add(i);
|
||||
inPosition = true;
|
||||
}
|
||||
} else if (exitRule.isSatisfied(i, record)) {
|
||||
sellBars.add(i);
|
||||
inPosition = false;
|
||||
}
|
||||
}
|
||||
return new SimResult(buyBars, sellBars);
|
||||
}
|
||||
|
||||
private record SimResult(List<Integer> buyBars, List<Integer> sellBars) {}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
loadPaperTrades,
|
||||
loadStrategies,
|
||||
saveLiveStrategySettings,
|
||||
pinCandleWatch,
|
||||
loadLiveStrategySettings,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
type StrategyDto,
|
||||
@@ -39,7 +41,9 @@ import {
|
||||
type VirtualSessionConfig,
|
||||
type VirtualTargetItem,
|
||||
type VirtualCardViewMode,
|
||||
resolveTargetCandleType,
|
||||
} from '../utils/virtualTradingStorage';
|
||||
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
|
||||
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
import {
|
||||
@@ -231,10 +235,15 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
t.market === market ? { ...t, strategyId } : t,
|
||||
));
|
||||
if (!session.running || !strategyId) return;
|
||||
const target = targets.find(t => t.market === market);
|
||||
const candleType = normalizeStartCandleType(
|
||||
resolveTargetCandleType(target ?? { candleType: undefined }, snapshots[market]?.timeframe),
|
||||
);
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId,
|
||||
candleType,
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
@@ -243,7 +252,59 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
} catch {
|
||||
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
||||
}
|
||||
}, [session]);
|
||||
}, [session, targets, snapshots]);
|
||||
|
||||
const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => {
|
||||
const normalized = normalizeStartCandleType(candleType);
|
||||
setTargets(prev => prev.map(t =>
|
||||
t.market === market ? { ...t, candleType: normalized } : t,
|
||||
));
|
||||
if (!session.running) return;
|
||||
const target = targets.find(t => t.market === market);
|
||||
const strategyId = target?.strategyId ?? session.globalStrategyId;
|
||||
if (!strategyId) return;
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId,
|
||||
candleType: normalized,
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
skipWatchlistSync: true,
|
||||
});
|
||||
await pinCandleWatch(market, normalized);
|
||||
if (normalized !== '1m') {
|
||||
await pinCandleWatch(market, '1m');
|
||||
}
|
||||
} catch {
|
||||
window.alert('종목 평가 분봉 변경 저장에 실패했습니다.');
|
||||
}
|
||||
}, [session, targets]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const missing = targets.filter(t => !t.candleType);
|
||||
if (missing.length === 0) return;
|
||||
void Promise.all(
|
||||
missing.map(async t => {
|
||||
try {
|
||||
const s = await loadLiveStrategySettings(t.market);
|
||||
return { market: t.market, candleType: s.candleType };
|
||||
} catch {
|
||||
return { market: t.market, candleType: undefined };
|
||||
}
|
||||
}),
|
||||
).then(rows => {
|
||||
if (cancelled) return;
|
||||
setTargets(prev => prev.map(t => {
|
||||
const row = rows.find(r => r.market === t.market);
|
||||
if (t.candleType || !row?.candleType) return t;
|
||||
return { ...t, candleType: normalizeStartCandleType(row.candleType) };
|
||||
}));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]);
|
||||
|
||||
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
||||
if (!session.running || targets.length === 0) return;
|
||||
@@ -334,6 +395,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
snapshots={snapshots}
|
||||
session={session}
|
||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||
onTargetCandleTypeChange={(market, ct) => void handleTargetCandleTypeChange(market, ct)}
|
||||
liveStatusByMarket={mergedLiveStatus}
|
||||
lastTickAtByMarket={lastTickAtByMarket}
|
||||
selectedMarket={selectedMarket}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getConditionValuePeriod,
|
||||
getCompositePeriodPresetOptions,
|
||||
getPeriodPresetOptions,
|
||||
getPeriodSettingsLabel,
|
||||
getThresholdBounds,
|
||||
getThresholdPresetOptions,
|
||||
hasEditableThreshold,
|
||||
@@ -121,7 +122,7 @@ export default function ConditionNodeSettings({
|
||||
</>
|
||||
) : usesValuePeriodField(condition) ? (
|
||||
<label className="se-flow-settings-field">
|
||||
<span>{condition.indicatorType} 기간 (일)</span>
|
||||
<span>{getPeriodSettingsLabel(condition) ?? `${condition.indicatorType} 기간 (일)`}</span>
|
||||
<ComboNumberInput
|
||||
value={getConditionValuePeriod(condition, def)}
|
||||
options={periodPresets}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 팔레트·Logic Expression 공통 — 지표 카테고리 */
|
||||
|
||||
export const BAND_INDICATORS = new Set(['MA', 'EMA', 'BOLLINGER', 'DONCHIAN', 'ICHIMOKU']);
|
||||
export const BAND_INDICATORS = new Set(['MA', 'EMA', 'BOLLINGER', 'DONCHIAN', 'NEW_HIGH', 'NEW_LOW', 'ICHIMOKU']);
|
||||
|
||||
export type PaletteIndicatorCategory = 'band' | 'ind';
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSn
|
||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { buildConditionMetrics, resolveVirtualTradeTiming } from '../../utils/virtualSignalMetrics';
|
||||
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import { normalizeStartCandleType } from '../../utils/strategyStartNodes';
|
||||
import type { Timeframe } from '../../types';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
@@ -13,6 +16,7 @@ import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||
import type { Theme } from '../../types';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import VirtualTargetQuote from './VirtualTargetQuote';
|
||||
import VirtualTargetCardFoot from './VirtualTargetCardFoot';
|
||||
|
||||
export type VirtualCardDisplayMode = 'signal' | 'chart';
|
||||
|
||||
@@ -23,6 +27,8 @@ interface Props {
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
candleType: string;
|
||||
onCandleTypeChange?: (candleType: string) => void;
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
running: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
@@ -47,6 +53,8 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
onStrategyChange,
|
||||
candleType,
|
||||
onCandleTypeChange,
|
||||
snapshot,
|
||||
running,
|
||||
liveStatus = 'idle',
|
||||
@@ -65,7 +73,6 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
}) => {
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
const tf = snapshot?.timeframe ?? '—';
|
||||
const rows = snapshot?.rows ?? [];
|
||||
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
@@ -75,6 +82,8 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
|
||||
const isDetail = viewMode === 'detail';
|
||||
const isChart = displayMode === 'chart';
|
||||
const evalCandleType = normalizeStartCandleType(candleType);
|
||||
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
|
||||
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!running) return null;
|
||||
@@ -113,23 +122,6 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
<span className="vtd-card-ko">{ko}</span>
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
</div>
|
||||
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
|
||||
<select
|
||||
className="vtd-card-strategy-select"
|
||||
aria-label="투자전략"
|
||||
value={strategyId ?? globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange?.(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="vtd-card-tf">시간봉 {tf}</span>
|
||||
</div>
|
||||
<div className="vtd-card-head-actions">
|
||||
{onEnterFocus && (
|
||||
@@ -185,6 +177,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartTimeframe={chartTimeframe}
|
||||
/>
|
||||
) : (
|
||||
<VirtualTargetSignalPanel
|
||||
@@ -193,6 +186,16 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
receiving={highlightReceiving}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VirtualTargetCardFoot
|
||||
snapshot={snapshot}
|
||||
strategies={strategies}
|
||||
strategyId={strategyId}
|
||||
globalStrategyId={globalStrategyId}
|
||||
candleType={evalCandleType}
|
||||
onStrategyChange={onStrategyChange}
|
||||
onCandleTypeChange={onCandleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Props {
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
/** 전체보기 분할 pane — 캔버스 높이 100% */
|
||||
fillHeight?: boolean;
|
||||
/** 카드 푸터 평가 분봉과 동기화 */
|
||||
chartTimeframe?: Timeframe;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -45,14 +47,21 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
fillHeight = false,
|
||||
chartTimeframe,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy));
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>(
|
||||
() => chartTimeframe ?? resolveStrategyPrimaryTimeframe(strategy),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartTimeframe) {
|
||||
setTimeframe(chartTimeframe);
|
||||
return;
|
||||
}
|
||||
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
|
||||
}, [strategy, market]);
|
||||
}, [strategy, market, chartTimeframe]);
|
||||
|
||||
const indicators = useMemo(
|
||||
() => buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
strategies: StrategyDto[];
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
candleType: string;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
onCandleTypeChange?: (candleType: string) => void;
|
||||
}
|
||||
|
||||
/** 카드 하단 — 갱신 시각(좌) · 전략·시간봉 드롭다운(우) */
|
||||
const VirtualTargetCardFoot: React.FC<Props> = ({
|
||||
snapshot,
|
||||
strategies,
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
candleType,
|
||||
onStrategyChange,
|
||||
onCandleTypeChange,
|
||||
}) => (
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<div className="vtd-card-foot-controls" onClick={e => e.stopPropagation()}>
|
||||
<label className="vtd-card-foot-field">
|
||||
<select
|
||||
className="vtd-card-foot-select"
|
||||
aria-label="투자전략"
|
||||
value={strategyId ?? globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange?.(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="vtd-card-foot-field">
|
||||
<select
|
||||
className="vtd-card-foot-select vtd-card-foot-select--tf"
|
||||
aria-label="시간봉"
|
||||
value={candleType}
|
||||
onChange={e => onCandleTypeChange?.(e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default VirtualTargetCardFoot;
|
||||
@@ -12,6 +12,10 @@ import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import VirtualTargetQuote from './VirtualTargetQuote';
|
||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||
import VirtualTargetCardFoot from './VirtualTargetCardFoot';
|
||||
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import { normalizeStartCandleType } from '../../utils/strategyStartNodes';
|
||||
import type { Timeframe } from '../../types';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
@@ -20,6 +24,8 @@ interface Props {
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
candleType: string;
|
||||
onCandleTypeChange?: (candleType: string) => void;
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
running: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
@@ -41,6 +47,8 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
onStrategyChange,
|
||||
candleType,
|
||||
onCandleTypeChange,
|
||||
snapshot,
|
||||
running,
|
||||
liveStatus = 'idle',
|
||||
@@ -55,7 +63,6 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
}) => {
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
const tf = snapshot?.timeframe ?? '—';
|
||||
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!running) return null;
|
||||
@@ -63,6 +70,8 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||
const evalCandleType = normalizeStartCandleType(candleType);
|
||||
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
|
||||
|
||||
return (
|
||||
<div className={`vtd-focus-wrap${highlightReceiving ? ' vtd-focus-wrap--receiving' : ''}`}>
|
||||
@@ -72,23 +81,6 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
<span className="vtd-card-ko">{ko}</span>
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
</div>
|
||||
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
|
||||
<select
|
||||
className="vtd-card-strategy-select"
|
||||
aria-label="투자전략"
|
||||
value={strategyId ?? globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange?.(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="vtd-card-tf">시간봉 {tf}</span>
|
||||
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||
</div>
|
||||
<button
|
||||
@@ -123,6 +115,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
fillHeight
|
||||
chartTimeframe={chartTimeframe}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -137,6 +130,16 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<VirtualTargetCardFoot
|
||||
snapshot={snapshot}
|
||||
strategies={strategies}
|
||||
strategyId={strategyId}
|
||||
globalStrategyId={globalStrategyId}
|
||||
candleType={evalCandleType}
|
||||
onStrategyChange={onStrategyChange}
|
||||
onCandleTypeChange={onCandleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,12 @@ import VirtualTargetFocusView from './VirtualTargetFocusView';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { Theme } from '../../types';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } from '../../utils/virtualTradingStorage';
|
||||
import {
|
||||
type VirtualTargetItem,
|
||||
type VirtualCardViewMode,
|
||||
type VirtualSessionConfig,
|
||||
resolveTargetCandleType,
|
||||
} from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
@@ -15,6 +20,7 @@ interface Props {
|
||||
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||
onTargetCandleTypeChange: (market: string, candleType: string) => void;
|
||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||
lastTickAtByMarket?: Record<string, number>;
|
||||
selectedMarket?: string;
|
||||
@@ -32,6 +38,7 @@ interface Props {
|
||||
const VirtualTargetGrid: React.FC<Props> = ({
|
||||
targets, strategies, snapshots, session,
|
||||
onTargetStrategyChange,
|
||||
onTargetCandleTypeChange,
|
||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
||||
onSelectMarket,
|
||||
theme = 'dark',
|
||||
@@ -109,6 +116,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
strategyId={focusTarget.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)}
|
||||
candleType={resolveTargetCandleType(focusTarget, snapshots[focusTarget.market]?.timeframe)}
|
||||
onCandleTypeChange={ct => onTargetCandleTypeChange(focusTarget.market, ct)}
|
||||
snapshot={snapshots[focusTarget.market]}
|
||||
running={session.running}
|
||||
liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||
@@ -134,6 +143,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
strategyId={t.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
onStrategyChange={id => onTargetStrategyChange(t.market, id)}
|
||||
candleType={resolveTargetCandleType(t, snapshots[t.market]?.timeframe)}
|
||||
onCandleTypeChange={ct => onTargetCandleTypeChange(t.market, ct)}
|
||||
snapshot={snapshots[t.market]}
|
||||
running={session.running}
|
||||
liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSn
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
computeMatchRate,
|
||||
formatUpdatedTime,
|
||||
getTrafficLightState,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
||||
@@ -67,10 +66,6 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
<VirtualIndicatorCompareTable metrics={metrics} />
|
||||
</div>
|
||||
)}
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<span className="vtd-card-match-summary">매매조건 일치율 {matchRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1431,6 +1431,37 @@
|
||||
color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.vtd-card-foot-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px 8px;
|
||||
min-width: 0;
|
||||
max-width: 78%;
|
||||
}
|
||||
|
||||
.vtd-card-foot-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vtd-card-foot-select {
|
||||
min-width: 100px;
|
||||
max-width: 140px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-panel-card-bg);
|
||||
color: var(--se-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vtd-card-foot-select--tf {
|
||||
min-width: 72px;
|
||||
max-width: 96px;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-title {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
|
||||
import type { ConditionDSL } from './strategyTypes';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { migratePriceExtremeCondition } from './priceExtremeIndicators';
|
||||
|
||||
export interface CompositePeriodDef {
|
||||
rsiPeriod: number;
|
||||
@@ -21,8 +22,40 @@ export interface CompositeIndicatorItem {
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 복합지표 팔레트 — 동일 지표 타입 2인스턴스 교차 */
|
||||
/** 돈치안 채널 — 단기·장기 고가/저가선 비교 */
|
||||
export function isDonchianComposite(ind: string): boolean {
|
||||
return ind === 'DONCHIAN';
|
||||
}
|
||||
|
||||
/** @deprecated DONCHIAN 전용 — isDonchianComposite 사용 */
|
||||
export function isPriceExtremeComposite(ind: string): boolean {
|
||||
return isDonchianComposite(ind);
|
||||
}
|
||||
|
||||
export function donchianUpperField(period: number): string {
|
||||
return `DC_UPPER_${period}`;
|
||||
}
|
||||
|
||||
export function donchianLowerField(period: number): string {
|
||||
return `DC_LOWER_${period}`;
|
||||
}
|
||||
|
||||
export function donchianMiddleField(period: number): string {
|
||||
return `DC_MIDDLE_${period}`;
|
||||
}
|
||||
|
||||
/** DC_UPPER_9 / DC_LOWER_20 등에서 기간 추출 */
|
||||
export function parseDonchianPeriod(field?: string): number | null {
|
||||
if (!field) return null;
|
||||
const m = field.match(/^DC_(?:UPPER|LOWER|MIDDLE)_(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/** 복합지표 팔레트 */
|
||||
export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [
|
||||
{ value: 'DONCHIAN', label: '돈치안 채널', desc: '단기·장기 고가/저가 채널 비교 (9·20일)' },
|
||||
{ value: 'RSI', label: 'RSI', desc: 'RSI 기간 교차' },
|
||||
{ value: 'CCI', label: 'CCI', desc: 'CCI 기간 교차' },
|
||||
{ value: 'ADX', label: 'ADX', desc: 'ADX 기간 교차' },
|
||||
@@ -36,6 +69,8 @@ export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [
|
||||
|
||||
export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef): { short: number; long: number } {
|
||||
switch (ind) {
|
||||
case 'DONCHIAN':
|
||||
return { short: 9, long: 20 };
|
||||
case 'RSI': return { short: def.rsiPeriod, long: Math.max(def.rsiPeriod * 2, 20) };
|
||||
case 'CCI': return { short: def.cciPeriod, long: Math.max(def.cciPeriod * 2, 26) };
|
||||
case 'ADX': return { short: def.adxPeriod, long: Math.max(def.adxPeriod * 2, 28) };
|
||||
@@ -52,6 +87,8 @@ export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef)
|
||||
|
||||
export function compositeValueField(ind: string, period: number): string {
|
||||
switch (ind) {
|
||||
case 'DONCHIAN':
|
||||
return donchianUpperField(period);
|
||||
case 'RSI': return `RSI_VALUE_${period}`;
|
||||
case 'CCI': return `CCI_VALUE_${period}`;
|
||||
case 'ADX': return `ADX_VALUE_${period}`;
|
||||
@@ -86,6 +123,9 @@ const COMPOSITE_VALUE_PREFIX: Record<string, string> = {
|
||||
/** 복합지표용 값 라인 필드(CCI_VALUE_9 등)인지 — K_100 임계값은 false */
|
||||
export function isCompositeValueField(ind: string, field?: string): boolean {
|
||||
if (!field || isThresholdField(field)) return false;
|
||||
if (isDonchianComposite(ind)) {
|
||||
return parseDonchianPeriod(field) != null;
|
||||
}
|
||||
if (ind === 'DISPARITY') {
|
||||
return field.startsWith('DISPARITY') && field.length > 'DISPARITY'.length
|
||||
&& /^\d+$/.test(field.slice('DISPARITY'.length));
|
||||
@@ -97,6 +137,9 @@ export function isCompositeValueField(ind: string, field?: string): boolean {
|
||||
|
||||
export function parsePeriodFromCompositeField(ind: string, field?: string): number | null {
|
||||
if (!field || isThresholdField(field)) return null;
|
||||
if (isDonchianComposite(ind)) {
|
||||
return parseDonchianPeriod(field);
|
||||
}
|
||||
if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) {
|
||||
const n = parseInt(field.slice('DISPARITY'.length), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
@@ -107,8 +150,32 @@ export function parsePeriodFromCompositeField(ind: string, field?: string): numb
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/** 돈치안 — 단기·장기 고가/저가선 비교 */
|
||||
export function syncDonchianCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
const leftP = cond.leftPeriod ?? 9;
|
||||
const rightP = cond.rightPeriod ?? 20;
|
||||
const leftCt = normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
|
||||
const rightCt = normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
|
||||
const defaultBand = (period: number) => donchianUpperField(period);
|
||||
|
||||
return {
|
||||
...cond,
|
||||
composite: true,
|
||||
leftPeriod: leftP,
|
||||
rightPeriod: rightP,
|
||||
leftField: cond.leftField ?? defaultBand(leftP),
|
||||
rightField: cond.rightField ?? defaultBand(rightP),
|
||||
leftCandleType: leftCt,
|
||||
rightCandleType: rightCt,
|
||||
period: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
if (!cond.composite) return cond;
|
||||
if (isDonchianComposite(cond.indicatorType)) {
|
||||
return syncDonchianCompositeFields(cond);
|
||||
}
|
||||
const leftP = cond.leftPeriod
|
||||
?? parsePeriodFromCompositeField(cond.indicatorType, cond.leftField)
|
||||
?? undefined;
|
||||
@@ -139,6 +206,9 @@ export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
}
|
||||
|
||||
export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
const migrated = migratePriceExtremeCondition(cond);
|
||||
if (migrated !== cond) return migrated;
|
||||
|
||||
if (cond.composite) {
|
||||
if (isThresholdField(cond.leftField) || isThresholdField(cond.rightField)) {
|
||||
return {
|
||||
@@ -151,8 +221,21 @@ export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
return syncCompositeFields(cond);
|
||||
}
|
||||
const ind = cond.indicatorType;
|
||||
if (parseDonchianPeriod(cond.leftField) != null && parseDonchianPeriod(cond.rightField) != null) {
|
||||
const leftP = parseDonchianPeriod(cond.leftField)!;
|
||||
const rightP = parseDonchianPeriod(cond.rightField)!;
|
||||
if (leftP !== rightP) {
|
||||
return syncDonchianCompositeFields({
|
||||
...cond,
|
||||
composite: true,
|
||||
indicatorType: 'DONCHIAN',
|
||||
leftPeriod: leftP,
|
||||
rightPeriod: rightP,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!isCompositeValueField(ind, cond.leftField) || !isCompositeValueField(ind, cond.rightField)) {
|
||||
return cond;
|
||||
return migratePriceExtremeCondition(cond);
|
||||
}
|
||||
const leftP = parsePeriodFromCompositeField(ind, cond.leftField);
|
||||
const rightP = parsePeriodFromCompositeField(ind, cond.rightField);
|
||||
@@ -164,7 +247,7 @@ export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
rightPeriod: rightP,
|
||||
});
|
||||
}
|
||||
return cond;
|
||||
return migratePriceExtremeCondition(cond);
|
||||
}
|
||||
|
||||
export function makeCompositeCondition(
|
||||
@@ -173,6 +256,18 @@ export function makeCompositeCondition(
|
||||
def: CompositePeriodDef,
|
||||
): ConditionDSL {
|
||||
const { short, long } = getCompositeDefaultPeriods(ind, def);
|
||||
if (isDonchianComposite(ind)) {
|
||||
return syncDonchianCompositeFields({
|
||||
indicatorType: ind,
|
||||
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||
composite: true,
|
||||
leftPeriod: short,
|
||||
rightPeriod: long,
|
||||
leftCandleType: DEFAULT_START_CANDLE,
|
||||
rightCandleType: DEFAULT_START_CANDLE,
|
||||
candleRange: 1,
|
||||
});
|
||||
}
|
||||
return syncCompositeFields({
|
||||
indicatorType: ind,
|
||||
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||
@@ -185,6 +280,16 @@ export function makeCompositeCondition(
|
||||
});
|
||||
}
|
||||
|
||||
/** 돈치안 복합 — 조건대상 드롭다운 옵션 */
|
||||
export function getDonchianFieldOpts(
|
||||
allPeriods: number[],
|
||||
): { value: string; label: string }[] {
|
||||
return [
|
||||
...allPeriods.map(p => ({ value: donchianUpperField(p), label: `${p}일 최고가선` })),
|
||||
...allPeriods.map(p => ({ value: donchianLowerField(p), label: `${p}일 최저가선` })),
|
||||
];
|
||||
}
|
||||
|
||||
export function compositePeriodLabel(ind: string, def: CompositePeriodDef): string {
|
||||
const { short, long } = getCompositeDefaultPeriods(ind, def);
|
||||
return `${short} / ${long}`;
|
||||
@@ -192,17 +297,24 @@ export function compositePeriodLabel(ind: string, def: CompositePeriodDef): stri
|
||||
|
||||
export function compositeDisplayName(ind: string): string {
|
||||
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
|
||||
if (ind === 'DONCHIAN') return '돈치안 채널 (9·20일)';
|
||||
if (item) return `${item.label} + ${item.label}`;
|
||||
return `${ind} + ${ind}`;
|
||||
}
|
||||
|
||||
export function compositeElementLabel(ind: string, slot: 1 | 2): string {
|
||||
if (ind === 'DONCHIAN') {
|
||||
return slot === 1 ? '채널 단기' : '채널 장기';
|
||||
}
|
||||
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
|
||||
const name = item?.label ?? ind;
|
||||
return `${name} ${slot}`;
|
||||
}
|
||||
|
||||
/** 복합지표 조건대상 라벨 — 예: CCI 1 라인(9일) */
|
||||
/** 복합지표 조건대상 라벨 */
|
||||
export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string {
|
||||
if (ind === 'DONCHIAN') {
|
||||
return `${period}일 ${slot === 1 ? '단기' : '장기'} 고가선`;
|
||||
}
|
||||
return `${compositeElementLabel(ind, slot)} 라인(${period}일)`;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ import {
|
||||
compositeFieldLabel,
|
||||
compositeValueField,
|
||||
getCompositeDefaultPeriods,
|
||||
getDonchianFieldOpts,
|
||||
isDonchianComposite,
|
||||
isThresholdField,
|
||||
parseDonchianPeriod,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
type CompositePeriodDef,
|
||||
} from './compositeIndicators';
|
||||
import {
|
||||
isPriceExtremeIndicator,
|
||||
migratePriceExtremeCondition,
|
||||
parsePriorExtremePeriod,
|
||||
syncPriceExtremeSimpleFields,
|
||||
} from './priceExtremeIndicators';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
export interface IndicatorPeriodDef {
|
||||
@@ -36,6 +45,9 @@ const VALUE_FIELD_PREFIX: Record<string, string> = {
|
||||
|
||||
export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number {
|
||||
switch (indicatorType) {
|
||||
case 'NEW_HIGH':
|
||||
case 'NEW_LOW':
|
||||
return 9;
|
||||
case 'RSI': return def.rsiPeriod;
|
||||
case 'CCI': return def.cciPeriod;
|
||||
case 'ADX': return def.adxPeriod;
|
||||
@@ -59,6 +71,12 @@ function parseFieldPeriod(field?: string): number | null {
|
||||
|
||||
/** 조건 노드의 RSI 등 값(좌측) 기간 */
|
||||
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||
if (cond.period && cond.period > 0) return cond.period;
|
||||
const fromRight = parsePriorExtremePeriod(cond.rightField);
|
||||
if (fromRight) return fromRight;
|
||||
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||
}
|
||||
if (cond.composite) {
|
||||
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
|
||||
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
||||
@@ -100,6 +118,7 @@ export function isValuePeriodFieldStored(indicatorType: string, field?: string):
|
||||
}
|
||||
|
||||
export function usesValuePeriodField(cond: ConditionDSL): boolean {
|
||||
if (isPriceExtremeIndicator(cond.indicatorType)) return true;
|
||||
if (cond.composite) return true;
|
||||
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
||||
if (!prefix) return false;
|
||||
@@ -133,6 +152,9 @@ export function setConditionValuePeriod(
|
||||
_def: IndicatorPeriodDef,
|
||||
): ConditionDSL {
|
||||
const p = Math.max(1, Math.min(500, Math.round(period)));
|
||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||
return syncPriceExtremeSimpleFields({ ...cond, period: p });
|
||||
}
|
||||
if (cond.composite) {
|
||||
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p });
|
||||
}
|
||||
@@ -170,6 +192,10 @@ export function initConditionPeriods(
|
||||
condition: ConditionDSL,
|
||||
def: IndicatorPeriodDef,
|
||||
): ConditionDSL {
|
||||
if (isPriceExtremeIndicator(indicatorType)) {
|
||||
const period = condition.period ?? getDefaultIndicatorPeriod(indicatorType, def);
|
||||
return syncPriceExtremeSimpleFields({ ...condition, period });
|
||||
}
|
||||
if (condition.composite) return condition;
|
||||
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||
if (!prefix) return condition;
|
||||
@@ -191,9 +217,19 @@ const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120];
|
||||
|
||||
export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] {
|
||||
const base = getDefaultIndicatorPeriod(indicatorType, def);
|
||||
if (isPriceExtremeIndicator(indicatorType)) {
|
||||
return [...new Set([base, 5, 9, 10, 20, 55, ...COMMON_PERIODS])].sort((a, b) => a - b);
|
||||
}
|
||||
return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function getPeriodSettingsLabel(cond: ConditionDSL): string | null {
|
||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||
return cond.indicatorType === 'NEW_LOW' ? '신저가 기간 (봉)' : '신고가 기간 (봉)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 복합지표 요소1(단기)·요소2(장기) 기간 프리셋 */
|
||||
export function getCompositePeriodPresetOptions(
|
||||
indicatorType: string,
|
||||
@@ -242,6 +278,18 @@ export function getCompositeFieldOpts(
|
||||
: getConditionRightPeriod(cond, def);
|
||||
const presets = getCompositePeriodPresetOptions(indicatorType, def, side);
|
||||
const periods = [...new Set([...presets, current])].sort((a, b) => a - b);
|
||||
|
||||
if (isDonchianComposite(indicatorType)) {
|
||||
const opts = getDonchianFieldOpts(periods);
|
||||
const cur = slot === 1 ? cond.leftField : cond.rightField;
|
||||
if (cur && !opts.some(o => o.value === cur)) {
|
||||
const p = parseDonchianPeriod(cur);
|
||||
const label = p != null ? compositeFieldLabel(indicatorType, slot, p) : cur;
|
||||
return [{ value: cur, label }, ...opts];
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
return periods.map(p => ({
|
||||
value: compositeValueField(indicatorType, p),
|
||||
label: compositeFieldLabel(indicatorType, slot, p),
|
||||
|
||||
@@ -384,8 +384,18 @@ export function getIndicatorDef(type: string): IndicatorDef | undefined {
|
||||
return INDICATOR_REGISTRY.find(d => d.type === type);
|
||||
}
|
||||
|
||||
const DSL_DISPLAY_ALIASES: Record<string, { koreanName: string; shortName: string }> = {
|
||||
NEW_HIGH: { koreanName: '신고가', shortName: 'NH' },
|
||||
NEW_LOW: { koreanName: '신저가', shortName: 'NL' },
|
||||
DONCHIAN: { koreanName: '돈치안채널', shortName: 'DC' },
|
||||
};
|
||||
|
||||
/** 가상투자·조건 목록 — 한글명 (영문약어) 예: 상품채널지수 (CCI) */
|
||||
export function formatIndicatorDisplayLabel(indicatorType: string): string {
|
||||
const alias = DSL_DISPLAY_ALIASES[indicatorType];
|
||||
if (alias) {
|
||||
return `${alias.koreanName} (${alias.shortName})`;
|
||||
}
|
||||
const def = getIndicatorDef(indicatorType);
|
||||
const abbr = def?.shortName ?? indicatorType;
|
||||
const ko = def?.koreanName ?? abbr;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** 신고가·신저가 — 단일(보조) 지표: 종가/고가 vs 직전 N봉 최고·최저 */
|
||||
import type { ConditionDSL } from './strategyTypes';
|
||||
import { parseDonchianPeriod } from './compositeIndicators';
|
||||
|
||||
export function isPriceExtremeIndicator(ind?: string): boolean {
|
||||
return ind === 'NEW_HIGH' || ind === 'NEW_LOW';
|
||||
}
|
||||
|
||||
export function nhPriorField(period: number): string {
|
||||
return `NH_PRIOR_${Math.max(1, Math.round(period))}`;
|
||||
}
|
||||
|
||||
export function nlPriorField(period: number): string {
|
||||
return `NL_PRIOR_${Math.max(1, Math.round(period))}`;
|
||||
}
|
||||
|
||||
export function parsePriorExtremePeriod(field?: string): number | null {
|
||||
if (!field) return null;
|
||||
const m = field.match(/^(?:NH|NL)_PRIOR_(\d+)$/);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
return parseDonchianPeriod(field);
|
||||
}
|
||||
|
||||
export function priorExtremeField(ind: string, period: number): string {
|
||||
return ind === 'NEW_LOW' ? nlPriorField(period) : nhPriorField(period);
|
||||
}
|
||||
|
||||
/** 단일 지표 — 기간에 맞춰 직전 N봉 신고가/신저가선 필드 동기화 */
|
||||
export function syncPriceExtremeSimpleFields(cond: ConditionDSL): ConditionDSL {
|
||||
if (!isPriceExtremeIndicator(cond.indicatorType)) return cond;
|
||||
const period = cond.period
|
||||
?? parsePriorExtremePeriod(cond.rightField)
|
||||
?? parsePriorExtremePeriod(cond.leftField)
|
||||
?? 9;
|
||||
const p = Math.max(1, Math.min(500, Math.round(period)));
|
||||
const band = priorExtremeField(cond.indicatorType, p);
|
||||
const priceFields = new Set(['CLOSE_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'OPEN_PRICE']);
|
||||
let leftField = cond.leftField;
|
||||
let rightField = cond.rightField;
|
||||
if (cond.indicatorType === 'NEW_HIGH') {
|
||||
leftField = leftField && priceFields.has(leftField) ? leftField : 'CLOSE_PRICE';
|
||||
rightField = band;
|
||||
} else {
|
||||
leftField = leftField && priceFields.has(leftField) ? leftField : 'CLOSE_PRICE';
|
||||
rightField = band;
|
||||
}
|
||||
return {
|
||||
...cond,
|
||||
composite: false,
|
||||
period: p,
|
||||
leftPeriod: undefined,
|
||||
rightPeriod: undefined,
|
||||
leftCandleType: undefined,
|
||||
rightCandleType: undefined,
|
||||
leftField,
|
||||
rightField,
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 DSL — 복합지표 승격·DC_UPPER_N 필드를 단일 신고가/신저가로 정규화 */
|
||||
export function migratePriceExtremeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
const ind = cond.indicatorType;
|
||||
if (!isPriceExtremeIndicator(ind)) return cond;
|
||||
|
||||
if (cond.composite && isPriceExtremeIndicator(ind)) {
|
||||
const period = cond.leftPeriod ?? cond.rightPeriod ?? 9;
|
||||
const priceFields = new Set(['CLOSE_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'OPEN_PRICE']);
|
||||
const leftField = cond.leftField && priceFields.has(cond.leftField)
|
||||
? cond.leftField
|
||||
: 'CLOSE_PRICE';
|
||||
return syncPriceExtremeSimpleFields({
|
||||
...cond,
|
||||
composite: false,
|
||||
leftField,
|
||||
period,
|
||||
conditionType: cond.conditionType === 'CROSS_UP' ? 'GTE'
|
||||
: cond.conditionType === 'CROSS_DOWN' ? 'LTE'
|
||||
: cond.conditionType,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPriceExtremeIndicator(ind)) return cond;
|
||||
|
||||
const fromRight = parsePriorExtremePeriod(cond.rightField);
|
||||
const fromLeft = parsePriorExtremePeriod(cond.leftField);
|
||||
const period = cond.period ?? fromRight ?? fromLeft ?? 9;
|
||||
let migrated = syncPriceExtremeSimpleFields({ ...cond, period });
|
||||
|
||||
if (migrated.rightField?.startsWith('DC_UPPER_') || migrated.rightField?.startsWith('DC_LOWER_')) {
|
||||
migrated = syncPriceExtremeSimpleFields({
|
||||
...migrated,
|
||||
period: parseDonchianPeriod(migrated.rightField) ?? period,
|
||||
});
|
||||
}
|
||||
|
||||
if (migrated.conditionType === 'CROSS_UP' && ind === 'NEW_HIGH') {
|
||||
migrated = { ...migrated, conditionType: 'GTE' };
|
||||
}
|
||||
if (migrated.conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW') {
|
||||
migrated = { ...migrated, conditionType: 'LTE' };
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
export function priceExtremePeriodLabel(ind: string, period: number): string {
|
||||
return ind === 'NEW_LOW' ? `${period}일 신저가선` : `${period}일 신고가선`;
|
||||
}
|
||||
@@ -9,9 +9,17 @@ import {
|
||||
compositeFieldLabel,
|
||||
makeCompositeCondition,
|
||||
normalizeCompositeCondition,
|
||||
parseDonchianPeriod,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
} from '../utils/compositeIndicators';
|
||||
import {
|
||||
isPriceExtremeIndicator,
|
||||
nhPriorField,
|
||||
nlPriorField,
|
||||
parsePriorExtremePeriod,
|
||||
syncPriceExtremeSimpleFields,
|
||||
} from '../utils/priceExtremeIndicators';
|
||||
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
||||
import {
|
||||
getCompositeFieldOpts,
|
||||
@@ -177,6 +185,8 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
||||
OBV: 'OBV',
|
||||
BOLLINGER: 'BollingerBands',
|
||||
DONCHIAN: 'DonchianChannels',
|
||||
NEW_HIGH: 'PriceExtreme',
|
||||
NEW_LOW: 'PriceExtreme',
|
||||
};
|
||||
|
||||
const extractThresh = (dslType: string): HLThresh => {
|
||||
@@ -469,13 +479,35 @@ export const getFieldOpts = (
|
||||
{ value:'HIGH_PRICE', label:'고가' },
|
||||
{ value:'LOW_PRICE', label:'저가' },
|
||||
]);
|
||||
case 'NEW_HIGH': {
|
||||
const p = cond?.period ?? 9;
|
||||
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
||||
return condOpts([
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'HIGH_PRICE', label: '고가' },
|
||||
{ value: 'OPEN_PRICE', label: '시가' },
|
||||
...periods.map(n => ({ value: nhPriorField(n), label: `${n}일 신고가선(직전 N봉)` })),
|
||||
]);
|
||||
}
|
||||
case 'NEW_LOW': {
|
||||
const p = cond?.period ?? 9;
|
||||
const periods = [...new Set([p, 5, 9, 10, 20, 55])].sort((a, b) => a - b);
|
||||
return condOpts([
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'LOW_PRICE', label: '저가' },
|
||||
{ value: 'OPEN_PRICE', label: '시가' },
|
||||
...periods.map(n => ({ value: nlPriorField(n), label: `${n}일 신저가선(직전 N봉)` })),
|
||||
]);
|
||||
}
|
||||
case 'DONCHIAN': return condOpts([
|
||||
{ value:'DC_UPPER_9', label:'상단(9일 최고가·신고가)' },
|
||||
{ value:'DC_UPPER_10', label:'상단(10일 최고가)' },
|
||||
{ value:'DC_UPPER_20', label:'상단(20일 최고가)' },
|
||||
{ value:'DC_UPPER_20', label:'상단(20일 최고가·신고가)' },
|
||||
{ value:'DC_UPPER_55', label:'상단(55일 최고가)' },
|
||||
{ value:'DC_MIDDLE_20', label:'중심(20일)' },
|
||||
{ value:'DC_LOWER_9', label:'하단(9일 최저가·신저가)' },
|
||||
{ value:'DC_LOWER_10', label:'하단(10일 최저가)' },
|
||||
{ value:'DC_LOWER_20', label:'하단(20일 최저가)' },
|
||||
{ value:'DC_LOWER_20', label:'하단(20일 최저가·신저가)' },
|
||||
{ value:'DC_LOWER_55', label:'하단(55일 최저가)' },
|
||||
{ value:'CLOSE_PRICE', label:'종가' },
|
||||
{ value:'LOW_PRICE', label:'저가' },
|
||||
@@ -526,6 +558,8 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
|
||||
DONCHIAN: signalType === 'buy'
|
||||
? { l:'CLOSE_PRICE', r:'DC_UPPER_20' }
|
||||
: { l:'CLOSE_PRICE', r:'DC_LOWER_20' },
|
||||
NEW_HIGH: { l:'CLOSE_PRICE', r: nhPriorField(9) },
|
||||
NEW_LOW: { l:'CLOSE_PRICE', r: nlPriorField(9) },
|
||||
ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' },
|
||||
};
|
||||
return map[ind] ?? { l:'NONE', r:'NONE' };
|
||||
@@ -569,6 +603,9 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def
|
||||
updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE';
|
||||
}
|
||||
}
|
||||
if (isPriceExtremeIndicator(updated.indicatorType)) {
|
||||
return syncPriceExtremeSimpleFields(updated);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
@@ -751,8 +788,12 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
|
||||
ZERO_LINE: 0,
|
||||
};
|
||||
const upd: ConditionDSL = { ...normalized, rightField: v };
|
||||
let upd: ConditionDSL = { ...normalized, rightField: v };
|
||||
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
|
||||
const priorP = parsePriorExtremePeriod(v);
|
||||
if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) {
|
||||
upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP });
|
||||
}
|
||||
onChange(upd);
|
||||
};
|
||||
|
||||
@@ -769,7 +810,8 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right');
|
||||
|
||||
const handleCompositeField = (side: 'left' | 'right', field: string) => {
|
||||
const p = parsePeriodFromCompositeField(normalized.indicatorType, field);
|
||||
const p = parseDonchianPeriod(field)
|
||||
?? parsePeriodFromCompositeField(normalized.indicatorType, field);
|
||||
if (p == null) return;
|
||||
if (side === 'left') {
|
||||
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field }));
|
||||
@@ -1035,16 +1077,22 @@ export const makeNode = (
|
||||
}
|
||||
const def = getDefaultFields(value, signalType, DEF);
|
||||
const period = options?.period ?? getDefaultIndicatorPeriod(value, DEF);
|
||||
let conditionType = signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN';
|
||||
if (value === 'NEW_HIGH' && signalType === 'buy') conditionType = 'CROSS_UP';
|
||||
if (value === 'NEW_LOW' && signalType === 'sell') conditionType = 'CROSS_DOWN';
|
||||
const baseCondition: ConditionDSL = {
|
||||
indicatorType: value,
|
||||
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||
conditionType,
|
||||
leftField: def.l,
|
||||
rightField: def.r,
|
||||
candleRange: 1,
|
||||
period,
|
||||
};
|
||||
const condition = initConditionPeriods(value, baseCondition, DEF);
|
||||
return {
|
||||
id: genId(), type: 'CONDITION',
|
||||
condition: initConditionPeriods(value, baseCondition, DEF),
|
||||
condition: isPriceExtremeIndicator(value)
|
||||
? syncPriceExtremeSimpleFields(condition)
|
||||
: condition,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', builtIn: true },
|
||||
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', builtIn: true },
|
||||
{ value: 'VOLUME', label: '거래량', desc: 'Volume', builtIn: true },
|
||||
{ value: 'NEW_HIGH', label: '신고가', desc: '직전 N봉 최고가 돌파 — 종가/고가 vs N일 신고가선 (9·20일)', builtIn: true, period: 9 },
|
||||
{ value: 'NEW_LOW', label: '신저가', desc: '직전 N봉 최저가 이탈 — 종가/저가 vs N일 신저가선 (9·20일)', builtIn: true, period: 9 },
|
||||
];
|
||||
|
||||
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] =
|
||||
@@ -85,13 +87,36 @@ function parseStored(raw: string | null): PaletteItem[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 복합지표 팔레트에서 단일 지표로 이전된 항목 제거 */
|
||||
const DEPRECATED_COMPOSITE_VALUES = new Set(['NEW_HIGH', 'NEW_LOW']);
|
||||
|
||||
function mergeMissingBuiltIns(
|
||||
stored: PaletteItem[],
|
||||
kind: PaletteItemKind,
|
||||
defaults: Omit<PaletteItem, 'id' | 'kind'>[],
|
||||
): PaletteItem[] {
|
||||
let base = stored;
|
||||
if (kind === 'composite') {
|
||||
base = stored.filter(i => !DEPRECATED_COMPOSITE_VALUES.has(i.value));
|
||||
}
|
||||
const existing = new Set(base.map(i => i.value));
|
||||
const added = defaults
|
||||
.filter(d => !existing.has(d.value))
|
||||
.map(d => ({
|
||||
...d,
|
||||
id: builtinId(kind, d.value),
|
||||
kind,
|
||||
}));
|
||||
return added.length > 0 ? [...base, ...added] : base;
|
||||
}
|
||||
|
||||
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
try {
|
||||
const stored = parseStored(localStorage.getItem(key));
|
||||
if (stored && stored.length > 0) {
|
||||
return stored.map(i => ({ ...i, kind }));
|
||||
return mergeMissingBuiltIns(stored.map(i => ({ ...i, kind })), kind, defaults);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return seedItems(kind, defaults);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import { genId, type DefType } from './strategyEditorShared';
|
||||
import { nhPriorField, nlPriorField } from './priceExtremeIndicators';
|
||||
|
||||
export type SimpleStrategyTemplate = {
|
||||
kind: 'simple';
|
||||
@@ -10,6 +11,7 @@ export type SimpleStrategyTemplate = {
|
||||
cond: string;
|
||||
l: string;
|
||||
r: string;
|
||||
period?: number;
|
||||
};
|
||||
|
||||
export type CompositeStrategyTemplate = {
|
||||
@@ -28,11 +30,19 @@ function condition(
|
||||
leftField: string,
|
||||
rightField: string,
|
||||
candleRange = 1,
|
||||
period?: number,
|
||||
): LogicNode {
|
||||
return {
|
||||
id: genId(),
|
||||
type: 'CONDITION',
|
||||
condition: { indicatorType, conditionType, leftField, rightField, candleRange },
|
||||
condition: {
|
||||
indicatorType,
|
||||
conditionType,
|
||||
leftField,
|
||||
rightField,
|
||||
candleRange,
|
||||
...(period != null ? { period } : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +60,16 @@ function donchianBreakdownSell(period: number): LogicNode {
|
||||
return condition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', `DC_LOWER_${period}`);
|
||||
}
|
||||
|
||||
/** N일 신고가 돌파 — 종가가 직전 N봉 최고가를 상향 돌파 */
|
||||
function newHighBreakoutBuy(period: number): LogicNode {
|
||||
return condition('NEW_HIGH', 'CROSS_UP', 'CLOSE_PRICE', nhPriorField(period), 1, period);
|
||||
}
|
||||
|
||||
/** N일 신저가 이탈 — 종가가 직전 N봉 최저가를 하향 이탈 */
|
||||
function newLowBreakdownSell(period: number): LogicNode {
|
||||
return condition('NEW_LOW', 'CROSS_DOWN', 'CLOSE_PRICE', nlPriorField(period), 1, period);
|
||||
}
|
||||
|
||||
/** 일목균형표 — 전환선(9일) / 기준선(26일) 골든·데드크로스 */
|
||||
function ichimokuTenkanKijunCross(signal: 'buy' | 'sell'): LogicNode {
|
||||
return condition(
|
||||
@@ -99,6 +119,50 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
|
||||
{ kind: 'simple', ind: 'PSYCHOLOGICAL', cond: 'CROSS_UP', l: 'PSY_VALUE', r: 'OVERBOUGHT_75', signal: 'buy', label: '심리도 상향 돌파' },
|
||||
{ kind: 'simple', ind: 'STOCHASTIC', cond: 'CROSS_UP', l: 'STOCH_K', r: 'STOCH_D', signal: 'buy', label: '스토캐스틱 골든크로스' },
|
||||
{ kind: 'simple', ind: 'ADX', cond: 'CROSS_UP', l: 'ADX_VALUE', r: 'ADX_25', signal: 'buy', label: 'ADX 중앙선 상향 돌파' },
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_HIGH',
|
||||
cond: 'CROSS_UP',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nhPriorField(9),
|
||||
period: 9,
|
||||
signal: 'buy',
|
||||
label: '9일 신고가 돌파',
|
||||
description: '종가가 직전 9봉 최고가 이상 — 단기 박스권 상단 돌파 매수',
|
||||
},
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_HIGH',
|
||||
cond: 'CROSS_UP',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nhPriorField(20),
|
||||
period: 20,
|
||||
signal: 'buy',
|
||||
label: '20일 신고가 돌파',
|
||||
description: '종가가 직전 20봉 최고가 이상 — 한 달 박스 돌파 매수',
|
||||
},
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_LOW',
|
||||
cond: 'CROSS_DOWN',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nlPriorField(9),
|
||||
period: 9,
|
||||
signal: 'sell',
|
||||
label: '9일 신저가 이탈',
|
||||
description: '종가가 직전 9봉 최저가 이하 — 단기 지지 붕괴 매도/손절',
|
||||
},
|
||||
{
|
||||
kind: 'simple',
|
||||
ind: 'NEW_LOW',
|
||||
cond: 'CROSS_DOWN',
|
||||
l: 'CLOSE_PRICE',
|
||||
r: nlPriorField(20),
|
||||
period: 20,
|
||||
signal: 'sell',
|
||||
label: '20일 신저가 이탈',
|
||||
description: '종가가 직전 20봉 최저가 이하 — 추세 이탈 청산',
|
||||
},
|
||||
{
|
||||
kind: 'composite',
|
||||
signal: 'buy',
|
||||
@@ -173,5 +237,5 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
|
||||
}
|
||||
|
||||
export function simpleTemplateToNode(tmpl: SimpleStrategyTemplate): LogicNode {
|
||||
return condition(tmpl.ind, tmpl.cond, tmpl.l, tmpl.r);
|
||||
return condition(tmpl.ind, tmpl.cond, tmpl.l, tmpl.r, 1, tmpl.period);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const DSL_TO_REGISTRY: Record<string, string> = {
|
||||
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
|
||||
BWI: 'BBBandWidth',
|
||||
DONCHIAN: 'DonchianChannels',
|
||||
NEW_HIGH: 'DonchianChannels',
|
||||
NEW_LOW: 'DonchianChannels',
|
||||
ICHIMOKU: 'IchimokuCloud',
|
||||
ATR: 'ATR',
|
||||
MFI: 'MFI',
|
||||
|
||||
@@ -72,6 +72,8 @@ export const INDICATOR_CONDITIONS: Record<string, string[]> = {
|
||||
MA: COMMON_CONDITIONS, EMA: COMMON_CONDITIONS,
|
||||
BOLLINGER: COMMON_CONDITIONS,
|
||||
DONCHIAN: COMMON_CONDITIONS,
|
||||
NEW_HIGH: COMMON_CONDITIONS,
|
||||
NEW_LOW: COMMON_CONDITIONS,
|
||||
ICHIMOKU: [
|
||||
...COMMON_CONDITIONS,
|
||||
'ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN',
|
||||
@@ -123,6 +125,14 @@ export const LEFT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
|
||||
{ value: 'LOW_PRICE', label: '저가' },
|
||||
{ value: 'HIGH_PRICE', label: '고가' },
|
||||
],
|
||||
NEW_HIGH: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'HIGH_PRICE', label: '고가' },
|
||||
],
|
||||
NEW_LOW: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'LOW_PRICE', label: '저가' },
|
||||
],
|
||||
ICHIMOKU: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
|
||||
@@ -194,15 +204,29 @@ export const RIGHT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
|
||||
{ value: 'LOWER', label: '하단밴드' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
DONCHIAN: [
|
||||
{ value: 'DC_UPPER_9', label: '상단(9일 최고가)' },
|
||||
{ value: 'DC_UPPER_10', label: '상단(10일 최고가)' },
|
||||
{ value: 'DC_UPPER_20', label: '상단(20일 최고가)' },
|
||||
{ value: 'DC_UPPER_55', label: '상단(55일 최고가)' },
|
||||
{ value: 'DC_MIDDLE_20', label: '중심(20일)' },
|
||||
{ value: 'DC_LOWER_9', label: '하단(9일 최저가)' },
|
||||
{ value: 'DC_LOWER_10', label: '하단(10일 최저가)' },
|
||||
{ value: 'DC_LOWER_20', label: '하단(20일 최저가)' },
|
||||
{ value: 'DC_LOWER_55', label: '하단(55일 최저가)' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
NEW_HIGH: [
|
||||
{ value: 'NH_PRIOR_9', label: '9일 신고가선(직전 N봉)' },
|
||||
{ value: 'NH_PRIOR_20', label: '20일 신고가선(직전 N봉)' },
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
NEW_LOW: [
|
||||
{ value: 'NL_PRIOR_9', label: '9일 신저가선(직전 N봉)' },
|
||||
{ value: 'NL_PRIOR_20', label: '20일 신저가선(직전 N봉)' },
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
ICHIMOKU: [
|
||||
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
|
||||
{ value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { saveLiveStrategySettings } from './backendApi';
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
|
||||
import { resolveTargetCandleType } from './virtualTradingStorage';
|
||||
|
||||
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */
|
||||
export async function syncVirtualTargetsToBackend(
|
||||
@@ -21,6 +23,7 @@ export async function syncVirtualTargetsToBackend(
|
||||
saveLiveStrategySettings({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
candleType: normalizeStartCandleType(resolveTargetCandleType(t)),
|
||||
...shared,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -234,3 +234,19 @@ export function formatUpdatedTime(ts: number | undefined): string {
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 카드 푸터용 전략 조건 한 줄 요약 */
|
||||
export function formatVirtualConditionBrief(
|
||||
row: VirtualConditionRow & { currentValue?: number | null },
|
||||
): string {
|
||||
const sideTag = row.side === 'buy' ? '[매수]' : row.side === 'sell' ? '[매도]' : '';
|
||||
const threshold = row.thresholdLabel
|
||||
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel);
|
||||
return [sideTag, row.displayName, threshold].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function formatVirtualCardConditionLines(
|
||||
rows: Array<VirtualConditionRow & { currentValue?: number | null }>,
|
||||
): string[] {
|
||||
return rows.map(formatVirtualConditionBrief);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
/** 가상투자 대상 종목·세션 설정 localStorage */
|
||||
|
||||
export interface VirtualTargetItem {
|
||||
market: string;
|
||||
strategyId: number | null;
|
||||
/** 종목별 전략 평가 분봉 (gc_live_strategy_settings.candle_type) */
|
||||
candleType?: string;
|
||||
koreanName?: string;
|
||||
englishName?: string;
|
||||
}
|
||||
@@ -92,3 +96,14 @@ export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||
localStorage.setItem(CARD_VIEW_KEY, mode);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
|
||||
export function resolveTargetCandleType(
|
||||
item: Pick<VirtualTargetItem, 'candleType'>,
|
||||
snapshotTimeframe?: string | null,
|
||||
): string {
|
||||
if (item.candleType) return normalizeStartCandleType(item.candleType);
|
||||
const raw = snapshotTimeframe?.split(',')[0]?.trim();
|
||||
if (raw) return normalizeStartCandleType(raw);
|
||||
return '1m';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
# 전략편집기 로직 및 동작흐름
|
||||
|
||||
goldenChart **전략편집기(Strategy Editor)** 의 화면 구성, 편집·저장 방식, 그리고 저장된 전략이 **백엔드에서 매매 시그널(BUY/SELL)을 감지**하기까지의 전체 흐름을 정리한 문서입니다.
|
||||
|
||||
> 관련 문서: [실시간 전략체크 구현.md](./실시간%20전략체크%20구현.md) — 실시간 ON/OFF, `CANDLE_CLOSE` / `REALTIME_TICK` 설정 및 STOMP 프로토콜
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
| 구분 | 설명 |
|
||||
|------|------|
|
||||
| **메뉴** | `strategy-editor` (`App.tsx` → `StrategyEditorPage`) |
|
||||
| **역할** | 매수·매도 조건을 **논리 트리(DSL)** 로 구성하고 `gc_strategy` 테이블에 저장 |
|
||||
| **평가 엔진** | 백엔드 **Ta4j** — `StrategyDslToTa4jAdapter`가 DSL JSON → `Rule` 변환 |
|
||||
| **실시간 시그널** | `LiveStrategyEvaluator` + `BarBuilder` / `LiveStrategyScheduler` |
|
||||
| **레거시 화면** | `StrategyPage` (`strategy`) — 동일 API·DSL, UI만 구형 트리 편집기 |
|
||||
|
||||
전략편집기에서 저장하는 것은 **조건 트리 JSON**(`buyCondition`, `sellCondition`)이며, 노드 좌표·팔레트 사용자 정의 항목 등은 **브라우저 localStorage**에만 둘 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 구성 (`StrategyEditorPage`)
|
||||
|
||||
### 2.1 레이아웃
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ BuilderPageShell — 제목: 전략편집기 / Strategy Builder │
|
||||
├──────────────┬──────────────────────────────────────────┬───────────────┤
|
||||
│ 좌측 패널 │ 중앙 편집 영역 │ 우측 패널 │
|
||||
│ (리사이즈) │ │ (리사이즈) │
|
||||
│ │ [매수] [매도] 탭 │ │
|
||||
│ 전략 목록 │ [그래프 | 목록] 편집 모드 │ 지표 | 템플릿 │
|
||||
│ 새 전략 │ ┌────────────────────────────────────┐ │ 팔레트 │
|
||||
│ 삭제 │ │ StrategyEditorCanvas (React Flow) │ │ START/AND/ │
|
||||
│ Import/ │ │ 또는 StrategyListEditor │ │ OR/NOT 칩 │
|
||||
│ Export │ └────────────────────────────────────┘ │ 보조/복합 │
|
||||
│ │ 선택 노드 설정 바 (CondEditor) │ 지표 탭 │
|
||||
├──────────────┴──────────────────────────────────────────┴───────────────┤
|
||||
│ 하단 터미널 — LogicExpressionPreview (논리 수식 미리보기) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**스타일:** `strategyEditor.css`, `strategyEditorTheme.css`
|
||||
|
||||
### 2.2 주요 UI 상태
|
||||
|
||||
| State | 타입 | 용도 |
|
||||
|-------|------|------|
|
||||
| `signalTab` | `'buy' \| 'sell'` | 매수/매도 조건 편집 대상 |
|
||||
| `editorMode` | `'graph' \| 'list'` | React Flow vs 목록 트리 (`gc_se_editor_mode`) |
|
||||
| `rightTab` | `'indicators' \| 'templates'` | 지표 팔레트 vs 프리셋 템플릿 |
|
||||
| `indicatorSubTab` | `'auxiliary' \| 'composite'` | 단일 보조지표 vs 복합(2기간 교차) |
|
||||
| `buyCondition` / `sellCondition` | `LogicNode \| null` | 저장 대상 DSL 루트 |
|
||||
| `buyLayout` / `sellLayout` | `SignalFlowLayoutSnapshot` | 노드 좌표, 엣지, START 메타 (로컬 전용) |
|
||||
| `buyOrphans` / `sellOrphans` | `LogicNode[]` | 그래프에서 분리된 고아 노드 (DB 미저장) |
|
||||
| `selectedId` | `number \| 'draft'` | 선택 전략 ID |
|
||||
|
||||
### 2.3 중앙 편집 영역 동작
|
||||
|
||||
#### 그래프 모드 (`StrategyEditorCanvas`)
|
||||
|
||||
- **React Flow** 기반: `StartNode`, `LogicGateNode`(AND/OR/NOT), `ConditionFlowNode`
|
||||
- 팔레트에서 드래그 앤 드롭 → `makeNode` / `mergeAtRoot`로 트리에 삽입
|
||||
- START 노드에서 **평가 분봉** 선택 (`1m` ~ `1d`)
|
||||
- START를 추가하면 **다중 분기** (`extraStartIds`) — 저장 시 `AND`/`OR` + 여러 `TIMEFRAME`으로 직렬화
|
||||
- 노드 이동·연결 변경 시 `saveStrategyFlowLayout` → localStorage `gc_se_flow_layout_v1`
|
||||
|
||||
#### 목록 모드 (`StrategyListEditor`)
|
||||
|
||||
- 동일 DSL을 트리/섹션 UI로 편집
|
||||
- START 섹션별 분봉·조건 트리 관리
|
||||
|
||||
#### 선택 노드 설정 (`CondEditor` — `strategyEditorShared.tsx`)
|
||||
|
||||
- `indicatorType`, `conditionType`, `leftField`, `rightField`, `period`, 복합지표 기간 등
|
||||
- `buildStrategyEditorDef()` — **차트 보조지표 설정과 분리된** 고정 기본 파라미터 사용
|
||||
|
||||
### 2.4 우측 팔레트
|
||||
|
||||
| 영역 | 내용 |
|
||||
|------|------|
|
||||
| **시작·논리** | START, AND, OR, NOT (`PaletteChip`) |
|
||||
| **밴드·추세** | MA, EMA, BOLLINGER, DONCHIAN, ICHIMOKU 등 |
|
||||
| **보조지표** | RSI, MACD, CCI, 신고가/신저가(NH_PRIOR/NL_PRIOR) 등 — `se-palette-auxiliary-v1` |
|
||||
| **복합지표** | RSI+RSI, MACD+MACD 등 2기간 교차 — `se-palette-composite-v1` |
|
||||
| **템플릿** | `strategyPresets.ts` — 터틀, 돈치안, 일목 등 원클릭 삽입 |
|
||||
|
||||
드래그 payload: `application/json` — `{ type, value, label, composite?, period?, ... }`
|
||||
|
||||
### 2.5 하단 Logic Expression
|
||||
|
||||
`LogicExpressionPreview` — `collectEditorBranches` / `collectDslBranches`로 분기별 수식 표시 예:
|
||||
|
||||
```
|
||||
[5m] (RSI(9) CROSS_UP K_70 AND MACD CROSS_UP)
|
||||
AND [1h] (ADX CROSS_UP K_25)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 전략 데이터 모델 (DSL)
|
||||
|
||||
### 3.1 핵심 타입 (`strategyTypes.ts`)
|
||||
|
||||
```typescript
|
||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||
|
||||
interface ConditionDSL {
|
||||
indicatorType: string; // RSI, MACD, DONCHIAN, NEW_HIGH, ...
|
||||
conditionType: string; // GT, CROSS_UP, CROSS_DOWN, ...
|
||||
leftField?: string; // RSI_VALUE, DC_UPPER_20, NH_PRIOR_9, ...
|
||||
rightField?: string; // K_70, SIGNAL_LINE, DC_LOWER_10, ...
|
||||
period?: number;
|
||||
composite?: boolean; // 복합지표 2기간 교차
|
||||
leftPeriod?, rightPeriod?;
|
||||
leftCandleType?, rightCandleType?;
|
||||
// ...
|
||||
}
|
||||
|
||||
interface LogicNode {
|
||||
id: string;
|
||||
type: LogicNodeType;
|
||||
children?: LogicNode[];
|
||||
condition?: ConditionDSL;
|
||||
candleType?: string; // TIMEFRAME 전용
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 DB/API 저장 형태 (`GcStrategy`)
|
||||
|
||||
| 컬럼 | 내용 |
|
||||
|------|------|
|
||||
| `buy_condition_json` | 매수(진입) 조건 루트 `LogicNode` |
|
||||
| `sell_condition_json` | 매도(청산) 조건 루트 `LogicNode` |
|
||||
| `name`, `description`, `enabled` | 메타 |
|
||||
|
||||
API: `POST /api/strategies` — `saveStrategy()` (`backendApi.ts`)
|
||||
|
||||
### 3.3 CONDITION 리프 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "n_abc123",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "RSI_VALUE",
|
||||
"rightField": "K_70",
|
||||
"period": 14
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 논리 노드 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "n_and1",
|
||||
"type": "AND",
|
||||
"children": [
|
||||
{ "type": "CONDITION", "condition": { "...": "RSI CROSS_UP" } },
|
||||
{ "type": "CONDITION", "condition": { "...": "MACD CROSS_UP" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. START · TIMEFRAME · 다중 분봉
|
||||
|
||||
편집기 UI의 **START 노드**는 DB에 그대로 저장되지 않습니다. `strategyConditionSerde.ts`가 저장/로드 시 변환합니다.
|
||||
|
||||
### 4.1 편집기 내부 상태 (`EditorConditionState`)
|
||||
|
||||
```typescript
|
||||
{
|
||||
root: LogicNode | null; // 기본 START 분기의 조건 트리
|
||||
startMeta: { [startId]: { candleType } };
|
||||
extraStartIds: string[]; // 추가 START
|
||||
extraRoots: { [startId]: LogicNode };
|
||||
startCombineOp?: 'AND' | 'OR'; // 다중 START 결합
|
||||
}
|
||||
```
|
||||
|
||||
- 기본 START ID: `__strategy_start__`
|
||||
- 추가 START: `__strategy_start_{uuid}__`
|
||||
|
||||
### 4.2 저장 시 인코딩 (`encodeConditionForSave`)
|
||||
|
||||
| 경우 | 저장 결과 |
|
||||
|------|-----------|
|
||||
| 조건 없음 | `null` |
|
||||
| 단일 분기, 분봉 `1m` | 루트 트리만 (TIMEFRAME 생략) |
|
||||
| 단일 분기, 분봉 `5m` 등 | `{ type: "TIMEFRAME", candleType: "5m", children: [root] }` |
|
||||
| 다중 START | `{ type: "AND"\|"OR", children: [ TIMEFRAME×N ] }` |
|
||||
|
||||
### 4.3 로드 시 디코딩 (`decodeConditionForEditor`)
|
||||
|
||||
- 최상위가 `AND`/`OR`이고 **자식이 전부 `TIMEFRAME`** → 다중 START로 복원
|
||||
- 최상위가 `TIMEFRAME` 단일 → 기본 START + 해당 분봉
|
||||
- 그 외 → 기본 `1m` START, 루트 = 전체 DSL
|
||||
|
||||
### 4.4 지원 분봉 (`strategyStartNodes.ts`)
|
||||
|
||||
`1m`, `3m`, `5m`, `10m`, `15m`, `30m`, `1h`, `4h`, `1d`
|
||||
|
||||
---
|
||||
|
||||
## 5. 지표·조건 종류
|
||||
|
||||
### 5.1 단일 보조지표
|
||||
|
||||
- 팔레트 또는 템플릿에서 `CONDITION` 노드 생성
|
||||
- `leftField` / `rightField` — 지표 플롯 vs 상수(`K_70`) vs 다른 플롯(`SIGNAL_LINE`)
|
||||
- 기간은 `period` 또는 필드명 접미사 (`RSI_VALUE_9`)
|
||||
|
||||
### 5.2 복합지표 (`compositeIndicators.ts`)
|
||||
|
||||
동일 지표 **두 기간** 비교 — 예: RSI(9) 상향 돌파 RSI(20)
|
||||
|
||||
```json
|
||||
{
|
||||
"composite": true,
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "RSI_VALUE_9",
|
||||
"rightField": "RSI_VALUE_20",
|
||||
"leftPeriod": 9,
|
||||
"rightPeriod": 20
|
||||
}
|
||||
```
|
||||
|
||||
요소별 **서로 다른 분봉**(`leftCandleType`, `rightCandleType`)은 백엔드 `buildCrossTimeframeCompositeRule`로 처리 (라이브 + storage 필요).
|
||||
|
||||
### 5.3 신고가·신저가 (단일 지표)
|
||||
|
||||
- `NEW_HIGH` / `NEW_LOW` + `NH_PRIOR_N` / `NL_PRIOR_N`
|
||||
- 의미: **직전 N봉** 최고가/최저가 대비 종가 돌파/이탈 (당일 봉 제외)
|
||||
- 백엔드: `PreviousValueIndicator(HighestValueIndicator(high, N), 1)` 등
|
||||
|
||||
### 5.4 돈치안·일목 등
|
||||
|
||||
- `DONCHIAN` → `DC_UPPER_N`, `DC_LOWER_N`
|
||||
- `ICHIMOKU` → 구름/선행스팬 전용 `conditionType` (`ABOVE_CLOUD`, `CLOUD_BREAK_UP`, …)
|
||||
|
||||
---
|
||||
|
||||
## 6. 저장·로드 프로세스 (프론트엔드)
|
||||
|
||||
### 6.1 저장 (`handleSave`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 사용자
|
||||
participant SE as StrategyEditorPage
|
||||
participant Serde as encodeConditionForSave
|
||||
participant API as POST /api/strategies
|
||||
participant DB as gc_strategy
|
||||
|
||||
U->>SE: 저장 클릭
|
||||
SE->>Serde: buyEditorState / sellEditorState
|
||||
Serde-->>SE: buyCondition, sellCondition (LogicNode)
|
||||
SE->>API: saveStrategy({ name, buyCondition, sellCondition })
|
||||
API->>DB: JSON 컬럼 persist
|
||||
SE->>SE: persistFlowLayout(id) — localStorage만
|
||||
```
|
||||
|
||||
검증:
|
||||
|
||||
- 전략명 필수
|
||||
- 매수·매도 중 **하나 이상** 조건 필요
|
||||
- `draft` 저장 시 → DB ID 발급 후 `migrateStrategyFlowLayout('draft' → id)`
|
||||
|
||||
### 6.2 로드
|
||||
|
||||
1. `loadStrategies()` — 서버 우선
|
||||
2. 실패 시 `loadStratsLocal()` (`gc_strat_v2`)
|
||||
3. 전략 선택 → `decodeConditionForEditor(buy/sell)` + `loadStrategyFlowLayout(id)`
|
||||
|
||||
### 6.3 localStorage vs DB
|
||||
|
||||
| 키 | 저장 내용 | DB |
|
||||
|----|-----------|-----|
|
||||
| `gc_strategy` (API) | buy/sell JSON | ✅ |
|
||||
| `gc_se_flow_layout_v1` | 노드 좌표, edge, startMeta, orphans | ❌ |
|
||||
| `gc_strat_v2` | 전략 목록 캐시 | ❌ (폴백) |
|
||||
| `se-palette-auxiliary-v1` | 사용자 보조지표 팔레트 | ❌ |
|
||||
| `se-palette-composite-v1` | 사용자 복합지표 팔레트 | ❌ |
|
||||
| `gc_se_editor_mode` | graph / list | ❌ |
|
||||
|
||||
Import/Export: `strategyImportExport.ts` — DSL + `flowLayout` + `editorMode` JSON 파일
|
||||
|
||||
---
|
||||
|
||||
## 7. 백엔드: DSL → Ta4j Rule
|
||||
|
||||
### 7.1 어댑터 (`StrategyDslToTa4jAdapter`)
|
||||
|
||||
**진입:** `toRule(JsonNode dsl, RuleBuildContext ctx)`
|
||||
|
||||
| `LogicNode.type` | 빌드 메서드 | Ta4j |
|
||||
|------------------|-------------|------|
|
||||
| `AND` | `buildAndRule` | `AndRule` |
|
||||
| `OR` | `buildOrRule` | `OrRule` |
|
||||
| `NOT` | `buildNotRule` | `NotRule` |
|
||||
| `TIMEFRAME` | `buildTimeframeRule` | 분기 `BarSeries` + `CrossSeriesRule` |
|
||||
| `CONDITION` | `buildConditionRule` | 비교/교차 Rule |
|
||||
|
||||
**CONDITION `conditionType` 매핑 (요약):**
|
||||
|
||||
| conditionType | Ta4j 개념 |
|
||||
|---------------|-----------|
|
||||
| GT, LT, GTE, LTE, EQ, NEQ | Over/Under/Equals |
|
||||
| CROSS_UP, CROSS_DOWN | `CrossedUpIndicatorRule`, `CrossedDownIndicatorRule` |
|
||||
| SLOPE_UP, SLOPE_DOWN | `IsRisingRule`, `IsFallingRule` |
|
||||
| HOLD_N_DAYS | N봉 연속 충족 |
|
||||
| 일목 전용 | 구름/선행스팬 규칙 |
|
||||
|
||||
**필드 해석:** `resolveField` — OHLCV, `K_*` 상수, RSI/CCI/MACD/Donchian/NH_PRIOR/NL_PRIOR 등
|
||||
|
||||
**지표 파라미터:** DB `indicator_settings` + `DSL_TO_REGISTRY` 매핑
|
||||
|
||||
### 7.2 멀티 타임프레임 (라이브)
|
||||
|
||||
`RuleBuildContext`에 `market`, `Ta4jStorage`가 있으면:
|
||||
|
||||
- `TIMEFRAME.candleType`에 맞는 시리즈를 storage에서 로드
|
||||
- 트리거 봉(예: 1m 마감) 인덱스에서 **다른 분봉 시리즈 최신 봉**과 `CrossSeriesRule`로 평가
|
||||
|
||||
백테스트는 단일 요청 `BarSeries`만 사용 → 교차 TF는 primary series fallback.
|
||||
|
||||
### 7.3 Strategy 조립 (`LiveStrategyEvaluator.buildStrategy`)
|
||||
|
||||
```java
|
||||
Rule entryRule = buildRule(buyConditionJson, series, params, market, storage);
|
||||
Rule exitRule = buildRule(sellConditionJson, series, params, market, storage);
|
||||
BaseStrategy strategy = new BaseStrategy(entryRule, exitRule);
|
||||
```
|
||||
|
||||
캐시 키: `market:candleType:strategyId`
|
||||
|
||||
---
|
||||
|
||||
## 8. 백엔드: 데이터 수집 → 평가 트리거
|
||||
|
||||
### 8.1 캔들 파이프라인
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UWS[Upbit WebSocket 틱]
|
||||
MTP[MarketTickProcessor]
|
||||
BB[BarBuilder]
|
||||
TS[Ta4jStorage BarSeries]
|
||||
|
||||
UWS --> MTP --> BB --> TS
|
||||
```
|
||||
|
||||
- 동일 분: `updateLastBar` (진행 중 봉 갱신)
|
||||
- 분 변경: `commitBar` → 확정봉 + 상위봉(3m~1d) 집계
|
||||
|
||||
### 8.2 어떤 분봉을 평가할지
|
||||
|
||||
`StrategyConditionTimeframeService`:
|
||||
|
||||
- 전략 DSL을 walk하여 **`TIMEFRAME` 노드의 candleType** 수집
|
||||
- `usesTimeframe(strategyId, candleType)` — 해당 봉 마감/스케줄에서만 평가
|
||||
|
||||
`LiveStrategyTimeframeService`는 UI/설정용 `gc_live_strategy_settings.candle_type` resolve (가상투자 카드 시간봉 드롭다운 등).
|
||||
|
||||
---
|
||||
|
||||
## 9. 시그널 감지 프로세스
|
||||
|
||||
### 9.1 전제: 실시간 전략 설정 (`GcLiveStrategySettings`)
|
||||
|
||||
| 필드 | 값 | 의미 |
|
||||
|------|-----|------|
|
||||
| `isLiveCheck` | true/false | 실시간 체크 ON |
|
||||
| `executionType` | `CANDLE_CLOSE` / `REALTIME_TICK` | 평가 타이밍 |
|
||||
| `strategyId` | FK | 사용할 `GcStrategy` |
|
||||
| `positionMode` | `LONG_ONLY` / `SIGNAL_ONLY` | 포지션 추적 방식 |
|
||||
| `candleType` | `1m`…`1d` | 설정/UI 기본 분봉 |
|
||||
|
||||
가상투자·설정 화면에서 `PUT /api/strategy/settings`로 반영.
|
||||
|
||||
### 9.2 시그널 판정 (`StrategySignalDeterminer`)
|
||||
|
||||
| positionMode | BUY | SELL |
|
||||
|--------------|-----|------|
|
||||
| **LONG_ONLY** | `strategy.shouldEnter(index, record)` | `strategy.shouldExit(index, record)` |
|
||||
| **SIGNAL_ONLY** | `entryRule.isSatisfied(index)` | `exitRule.isSatisfied(index)` |
|
||||
|
||||
`LONG_ONLY`는 evaluator가 **가상 TradingRecord**를 캐시해 중복 진입 방지.
|
||||
|
||||
결과: `"BUY"` | `"SELL"` | `"NONE"`
|
||||
|
||||
### 9.3 방식 A — 봉 마감 직후 (`CANDLE_CLOSE`)
|
||||
|
||||
**트리거:** `BarBuilder.commitBar()` → `evaluateStrategyOnBarClose()`
|
||||
|
||||
**조건 (각 설정 행):**
|
||||
|
||||
1. `isLiveCheck == true`
|
||||
2. `executionType == CANDLE_CLOSE`
|
||||
3. `usesTimeframe(strategyId, candleType)`
|
||||
4. `Ta4jStorage`에 해당 분봉 데이터 존재
|
||||
|
||||
**평가:** `LiveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex)`
|
||||
|
||||
- **확정된 직전 봉 인덱스**에서 1회 판정
|
||||
|
||||
### 9.4 방식 B — 실시간 틱 (`REALTIME_TICK`)
|
||||
|
||||
**트리거:** `LiveStrategyScheduler` — **3초 주기** (`@Scheduled`)
|
||||
|
||||
**조건:** `executionType == REALTIME_TICK` 등 동일
|
||||
|
||||
**평가:** `evaluateRealtimeTick(market, candleType)`
|
||||
|
||||
- `currentIndex = series.getEndIndex()` — **진행 중(provisional) 봉**
|
||||
- **중복 방지:** 동일 `(market, candleType)`에서 같은 index에 이미 시그널 냈으면 skip
|
||||
- 평가 전 **strategy 캐시 제거** — `updateLastBar` 후 지표 캐시 stale 방지
|
||||
|
||||
### 9.5 보완 — 마감 직후 REALTIME 재평가
|
||||
|
||||
`BarBuilder`: `CANDLE_CLOSE`가 `NONE`이면 → `evaluateRealtimeAtClose(maturedIndex)`
|
||||
|
||||
- 봉 마감 시점의 **교차(CROSS)** 를 3초 스케줄러가 놓치는 경우 대비
|
||||
|
||||
### 9.6 평가 흐름 (단일 종목·단일 시점)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[트리거: 봉 마감 또는 3초 틱]
|
||||
B{isLiveCheck?}
|
||||
C{usesTimeframe?}
|
||||
D[LiveStrategyEvaluator.evaluate]
|
||||
E[buildStrategy - DSL to Ta4j]
|
||||
F[StrategySignalDeterminer.determineSignal]
|
||||
G{결과}
|
||||
H[publishStrategySignal]
|
||||
I[NONE - 종료]
|
||||
|
||||
A --> B
|
||||
B -->|false| I
|
||||
B -->|true| C
|
||||
C -->|false| I
|
||||
C -->|true| D
|
||||
D --> E --> F --> G
|
||||
G -->|BUY/SELL| H
|
||||
G -->|NONE| I
|
||||
```
|
||||
|
||||
**주의:** 동일 마켓에 활성 설정이 여러 개면 evaluator는 **첫 non-NONE 시그널만** 반환할 수 있음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 시그널 발행·후처리
|
||||
|
||||
### 10.1 STOMP WebSocket
|
||||
|
||||
**토픽:** `/sub/charts/{market}/{candleType}`
|
||||
**엔드포인트:** `ws://host/ws/trading` (SockJS)
|
||||
|
||||
**페이로드 (`CandleBarDto`):** OHLCV + 지표값 + **`signal`**: `"BUY"` | `"SELL"` | null
|
||||
|
||||
```json
|
||||
{
|
||||
"t": 1779229260,
|
||||
"o": 92080000.0,
|
||||
"c": 92050000.0,
|
||||
"signal": "BUY"
|
||||
}
|
||||
```
|
||||
|
||||
별도 “시그널 전용” 토픽은 없음 — **차트 캔들 스트림에 시그널 필드**로 포함.
|
||||
|
||||
### 10.2 DB·알림·주문
|
||||
|
||||
| 단계 | 클래스 |
|
||||
|------|--------|
|
||||
| DB 저장 | `TradeSignalService.save` → `GcTradeSignal` |
|
||||
| FCM | `FcmPushService` |
|
||||
| 주문 큐 | `OrderExecutionQueue.submitSignal` (deviceId 있을 때) |
|
||||
|
||||
### 10.3 프론트 차트 반영
|
||||
|
||||
- STOMP 구독 → 캔들 수신 시 `signal` 필드로 **마커(setMarkers)** 표시
|
||||
- `LiveSignalNotifier`, 차트 슬롯 등
|
||||
|
||||
---
|
||||
|
||||
## 11. 조건 현황 API (시그널과 별개)
|
||||
|
||||
**용도:** 가상투자 카드, 모니터링 UI — “지금 각 조건이 얼마나 충족됐는지”
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| API | `GET /api/strategy/live-conditions?market=&strategyId=` |
|
||||
| 구현 | `LiveConditionStatusService` |
|
||||
| 방식 | buy/sell JSON의 **각 CONDITION 리프**를 개별 `Rule`로 평가 |
|
||||
| matchRate | 충족 리프 수 / 평가 가능 리프 수 (전체 AND/OR 트리 충족과 다름) |
|
||||
|
||||
실제 BUY/SELL 발화는 §9 경로만 사용.
|
||||
|
||||
---
|
||||
|
||||
## 12. 백테스트 vs 실시간
|
||||
|
||||
| 항목 | 백테스트 (`BacktestingService`) | 실시간 (`LiveStrategyEvaluator`) |
|
||||
|------|--------------------------------|----------------------------------|
|
||||
| BarSeries | 요청 `bars`로 일회성 생성 | `Ta4jStorage` 실시간 누적 |
|
||||
| adapter | `toRule(dsl, series, params)` | `toRule(..., market, storage)` |
|
||||
| 스캔 | **전 구간** 0..barCount-1 | **단일 index** (마감 or provisional) |
|
||||
| 청산 | DSL sell + **손절/익절/트레일링** OR 합성 | DSL sell (+ 리스크 모니터 별도) |
|
||||
| 출력 | `BacktestResponse.signals`, `gc_backtest_result` | STOMP + `gc_trade_signal` |
|
||||
| 멀티 TF | storage 없으면 단일 시리즈 fallback | `TIMEFRAME` / 교차 TF 지원 |
|
||||
|
||||
백테스트도 **동일 DSL·동일 어댑터**를 사용하므로, 편집기에서 저장한 전략이 백테스트와 라이브에서 같은 의미로 해석됩니다 (데이터·타이밍 차이는 있음).
|
||||
|
||||
---
|
||||
|
||||
## 13. 전략 사용 경로 (편집기 이후)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
SE[전략편집기 저장]
|
||||
DB[(gc_strategy)]
|
||||
SE --> DB
|
||||
|
||||
DB --> BT[백테스팅 화면]
|
||||
DB --> LIVE[실시간 전략 설정]
|
||||
DB --> VT[가상투자 카드]
|
||||
|
||||
LIVE --> LSS[gc_live_strategy_settings]
|
||||
LSS --> EVAL[LiveStrategyEvaluator]
|
||||
VT --> LC[live-conditions API]
|
||||
|
||||
EVAL --> STOMP[STOMP signal]
|
||||
EVAL --> TSIG[gc_trade_signal]
|
||||
```
|
||||
|
||||
1. **전략편집기** — DSL 작성·저장
|
||||
2. **설정 / 가상투자** — 종목별 `strategyId`, `executionType`, `positionMode`, 평가 분봉
|
||||
3. **백테스트** — 과거 봉으로 전 구간 시뮬레이션
|
||||
4. **라이브** — Upbit 틱 → Ta4j → 시그널 → STOMP·DB·(자동매매)
|
||||
|
||||
---
|
||||
|
||||
## 14. 주요 소스 파일
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
| 경로 | 역할 |
|
||||
|------|------|
|
||||
| `frontend/src/components/StrategyEditorPage.tsx` | 전략편집기 메인 |
|
||||
| `frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx` | React Flow 캔버스 |
|
||||
| `frontend/src/components/strategyEditor/StrategyListEditor.tsx` | 목록 편집기 |
|
||||
| `frontend/src/components/strategyEditor/LogicExpressionPreview.tsx` | 논리 수식 미리보기 |
|
||||
| `frontend/src/utils/strategyTypes.ts` | DSL 타입·조건 상수 |
|
||||
| `frontend/src/utils/strategyConditionSerde.ts` | START↔TIMEFRAME 직렬화 |
|
||||
| `frontend/src/utils/strategyEditorShared.tsx` | CondEditor, 트리 조작, DEF |
|
||||
| `frontend/src/utils/strategyEditorLayoutStorage.ts` | Flow 레이아웃 localStorage |
|
||||
| `frontend/src/utils/compositeIndicators.ts` | 복합지표 정의 |
|
||||
| `frontend/src/utils/strategyStartNodes.ts` | 분봉·START 상수 |
|
||||
| `frontend/src/utils/strategyPresets.ts` | 템플릿 |
|
||||
| `frontend/src/utils/backendApi.ts` | `saveStrategy`, `loadStrategies` |
|
||||
|
||||
### 백엔드
|
||||
|
||||
| 경로 | 역할 |
|
||||
|------|------|
|
||||
| `backend/.../entity/GcStrategy.java` | 전략 엔티티 |
|
||||
| `backend/.../entity/GcLiveStrategySettings.java` | 실시간 설정 |
|
||||
| `backend/.../service/StrategyDslToTa4jAdapter.java` | DSL → Rule |
|
||||
| `backend/.../service/LiveStrategyEvaluator.java` | 실시간 평가 |
|
||||
| `backend/.../service/StrategySignalDeterminer.java` | BUY/SELL 판정 |
|
||||
| `backend/.../service/BacktestingService.java` | 백테스트 |
|
||||
| `backend/.../service/LiveConditionStatusService.java` | 조건 현황 API |
|
||||
| `backend/.../service/StrategyConditionTimeframeService.java` | DSL 분봉 수집 |
|
||||
| `backend/.../websocket/BarBuilder.java` | 봉 조립·마감 평가 |
|
||||
| `backend/.../websocket/LiveStrategyScheduler.java` | 3초 REALTIME_TICK |
|
||||
| `backend/.../websocket/TradingWebSocketBroker.java` | STOMP 발행 |
|
||||
| `backend/.../storage/Ta4jStorage.java` | BarSeries 저장소 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 핵심 체크리스트 (디버깅용)
|
||||
|
||||
1. **저장 DSL** — `encodeConditionForSave` 후 JSON에 `TIMEFRAME` / `AND`+`TIMEFRAME` 구조가 의도대로인가?
|
||||
2. **분봉** — 조건 리프의 `timeframe` vs `TIMEFRAME` 래핑 `candleType` 일치 여부
|
||||
3. **실시간 ON** — `isLiveCheck`, `strategyId` 연결
|
||||
4. **실행 방식** — `CANDLE_CLOSE` vs `REALTIME_TICK`
|
||||
5. **데이터** — `Ta4jStorage.exists(market, candleType)`, warm-up(`pinCandleWatch`)
|
||||
6. **포지션 모드** — `LONG_ONLY`에서 이미 포지션일 때 BUY 무시
|
||||
7. **STOMP** — 구독 토픽 `/sub/charts/{market}/{candleType}` 와 평가 분봉 일치
|
||||
8. **UI 현황 vs 시그널** — `live-conditions` matchRate ≠ 실제 BUY/SELL 발화 조건
|
||||
|
||||
---
|
||||
|
||||
*문서 작성 기준: goldenChart 저장소 프론트/백엔드 소스 구조. API·클래스명은 변경될 수 있으므로 구현 확인 시 §14 파일 목록을 참고하세요.*
|
||||
Reference in New Issue
Block a user