가상투자 종목카드박스 레이아웃 수정

This commit is contained in:
Macbook
2026-05-26 16:42:57 +09:00
parent 7cf43609dc
commit c0d21ac825
25 changed files with 1632 additions and 61 deletions
@@ -86,6 +86,8 @@ public class StrategyDslToTa4jAdapter {
Map.entry("VR", "VR"), Map.entry("VR", "VR"),
Map.entry("DISPARITY", "Disparity"), Map.entry("DISPARITY", "Disparity"),
Map.entry("DONCHIAN", "DonchianChannels"), Map.entry("DONCHIAN", "DonchianChannels"),
Map.entry("NEW_HIGH", "PriceExtreme"),
Map.entry("NEW_LOW", "PriceExtreme"),
Map.entry("VOLUME", "VOLUME") Map.entry("VOLUME", "VOLUME")
); );
@@ -281,6 +283,15 @@ public class StrategyDslToTa4jAdapter {
return buildCrossTimeframeCompositeRule(cond, ctx); 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 등) 변환 후 파라미터 조회 // DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회
String registryKey = toRegistryKey(indType); String registryKey = toRegistryKey(indType);
Map<String, Object> indParams = params != null 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> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod);
Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod); Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod);
return switch (condType) { Rule core = switch (condType) {
case "GT" -> new OverIndicatorRule(left, right); case "GT" -> new OverIndicatorRule(left, right);
case "LT" -> new UnderIndicatorRule(left, right); case "LT" -> new UnderIndicatorRule(left, right);
// GTE/LTE: OverIndicator OR 동일 (epsilon 비교) // GTE/LTE: OverIndicator OR 동일 (epsilon 비교)
@@ -336,12 +347,72 @@ public class StrategyDslToTa4jAdapter {
yield new BooleanRule(false); yield new BooleanRule(false);
} }
}; };
if (px != null) {
return nanSafeCompareRule(core, left, right);
}
return core;
} catch (Exception e) { } catch (Exception e) {
log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage()); log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage());
return new BooleanRule(false); 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 이 서로 다를 때 교차 시간봉 평가 */ /** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */
private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) { private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) {
if (!cond.path("composite").asBoolean(false)) return false; if (!cond.path("composite").asBoolean(false)) return false;
@@ -611,6 +682,15 @@ public class StrategyDslToTa4jAdapter {
return new WilliamsRIndicator(s, period); return new WilliamsRIndicator(s, period);
} }
if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14)); 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 등 기간 접미사 // Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사
if (field.startsWith("DC_UPPER_")) { if (field.startsWith("DC_UPPER_")) {
int period = parseTrailingInt(field, "DC_UPPER_", intP(p, "length", 20)); 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) { 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) {}
}
+63 -1
View File
@@ -7,6 +7,8 @@ import {
loadPaperTrades, loadPaperTrades,
loadStrategies, loadStrategies,
saveLiveStrategySettings, saveLiveStrategySettings,
pinCandleWatch,
loadLiveStrategySettings,
type PaperSummaryDto, type PaperSummaryDto,
type PaperTradeDto, type PaperTradeDto,
type StrategyDto, type StrategyDto,
@@ -39,7 +41,9 @@ import {
type VirtualSessionConfig, type VirtualSessionConfig,
type VirtualTargetItem, type VirtualTargetItem,
type VirtualCardViewMode, type VirtualCardViewMode,
resolveTargetCandleType,
} from '../utils/virtualTradingStorage'; } from '../utils/virtualTradingStorage';
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings'; import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
import { coerceFiniteNumber } from '../utils/safeFormat'; import { coerceFiniteNumber } from '../utils/safeFormat';
import { import {
@@ -231,10 +235,15 @@ const VirtualTradingPage: React.FC<Props> = ({
t.market === market ? { ...t, strategyId } : t, t.market === market ? { ...t, strategyId } : t,
)); ));
if (!session.running || !strategyId) return; if (!session.running || !strategyId) return;
const target = targets.find(t => t.market === market);
const candleType = normalizeStartCandleType(
resolveTargetCandleType(target ?? { candleType: undefined }, snapshots[market]?.timeframe),
);
try { try {
await saveLiveStrategySettings({ await saveLiveStrategySettings({
market, market,
strategyId, strategyId,
candleType,
isLiveCheck: true, isLiveCheck: true,
executionType: session.executionType, executionType: session.executionType,
positionMode: session.positionMode, positionMode: session.positionMode,
@@ -243,7 +252,59 @@ const VirtualTradingPage: React.FC<Props> = ({
} catch { } catch {
window.alert('종목 전략 변경 저장에 실패했습니다.'); 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) => { const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
if (!session.running || targets.length === 0) return; if (!session.running || targets.length === 0) return;
@@ -334,6 +395,7 @@ const VirtualTradingPage: React.FC<Props> = ({
snapshots={snapshots} snapshots={snapshots}
session={session} session={session}
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)} onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
onTargetCandleTypeChange={(market, ct) => void handleTargetCandleTypeChange(market, ct)}
liveStatusByMarket={mergedLiveStatus} liveStatusByMarket={mergedLiveStatus}
lastTickAtByMarket={lastTickAtByMarket} lastTickAtByMarket={lastTickAtByMarket}
selectedMarket={selectedMarket} selectedMarket={selectedMarket}
@@ -9,6 +9,7 @@ import {
getConditionValuePeriod, getConditionValuePeriod,
getCompositePeriodPresetOptions, getCompositePeriodPresetOptions,
getPeriodPresetOptions, getPeriodPresetOptions,
getPeriodSettingsLabel,
getThresholdBounds, getThresholdBounds,
getThresholdPresetOptions, getThresholdPresetOptions,
hasEditableThreshold, hasEditableThreshold,
@@ -121,7 +122,7 @@ export default function ConditionNodeSettings({
</> </>
) : usesValuePeriodField(condition) ? ( ) : usesValuePeriodField(condition) ? (
<label className="se-flow-settings-field"> <label className="se-flow-settings-field">
<span>{condition.indicatorType} ()</span> <span>{getPeriodSettingsLabel(condition) ?? `${condition.indicatorType} 기간 (일)`}</span>
<ComboNumberInput <ComboNumberInput
value={getConditionValuePeriod(condition, def)} value={getConditionValuePeriod(condition, def)}
options={periodPresets} options={periodPresets}
@@ -1,6 +1,6 @@
/** 팔레트·Logic Expression 공통 — 지표 카테고리 */ /** 팔레트·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'; export type PaletteIndicatorCategory = 'band' | 'ind';
@@ -5,6 +5,9 @@ import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSn
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { buildConditionMetrics, resolveVirtualTradeTiming } from '../../utils/virtualSignalMetrics'; 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 VirtualLiveBadge from './VirtualLiveBadge';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage'; import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
@@ -13,6 +16,7 @@ import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
import type { Theme } from '../../types'; import type { Theme } from '../../types';
import type { TickerData } from '../../hooks/useMarketTicker'; import type { TickerData } from '../../hooks/useMarketTicker';
import VirtualTargetQuote from './VirtualTargetQuote'; import VirtualTargetQuote from './VirtualTargetQuote';
import VirtualTargetCardFoot from './VirtualTargetCardFoot';
export type VirtualCardDisplayMode = 'signal' | 'chart'; export type VirtualCardDisplayMode = 'signal' | 'chart';
@@ -23,6 +27,8 @@ interface Props {
strategyId: number | null; strategyId: number | null;
globalStrategyId: number | null; globalStrategyId: number | null;
onStrategyChange?: (strategyId: number | null) => void; onStrategyChange?: (strategyId: number | null) => void;
candleType: string;
onCandleTypeChange?: (candleType: string) => void;
snapshot: VirtualIndicatorSnapshot | undefined; snapshot: VirtualIndicatorSnapshot | undefined;
running: boolean; running: boolean;
liveStatus?: VirtualLiveStatus; liveStatus?: VirtualLiveStatus;
@@ -47,6 +53,8 @@ const VirtualTargetCard: React.FC<Props> = ({
strategyId, strategyId,
globalStrategyId, globalStrategyId,
onStrategyChange, onStrategyChange,
candleType,
onCandleTypeChange,
snapshot, snapshot,
running, running,
liveStatus = 'idle', liveStatus = 'idle',
@@ -65,7 +73,6 @@ const VirtualTargetCard: React.FC<Props> = ({
}) => { }) => {
const ko = getKoreanName(market); const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, ''); const sym = market.replace(/^KRW-/, '');
const tf = snapshot?.timeframe ?? '—';
const rows = snapshot?.rows ?? []; const rows = snapshot?.rows ?? [];
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
@@ -75,6 +82,8 @@ const VirtualTargetCard: React.FC<Props> = ({
const isDetail = viewMode === 'detail'; const isDetail = viewMode === 'detail';
const isChart = displayMode === 'chart'; const isChart = displayMode === 'chart';
const evalCandleType = normalizeStartCandleType(candleType);
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
const receiveSignal = useMemo(() => { const receiveSignal = useMemo(() => {
if (!running) return null; if (!running) return null;
@@ -113,23 +122,6 @@ const VirtualTargetCard: React.FC<Props> = ({
<span className="vtd-card-ko">{ko}</span> <span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span> <span className="vtd-card-sym">{sym}</span>
</div> </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>
<div className="vtd-card-head-actions"> <div className="vtd-card-head-actions">
{onEnterFocus && ( {onEnterFocus && (
@@ -185,6 +177,7 @@ const VirtualTargetCard: React.FC<Props> = ({
running={running} running={running}
chartRealtimeSource={chartRealtimeSource} chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels} chartSeriesPriceLabels={chartSeriesPriceLabels}
chartTimeframe={chartTimeframe}
/> />
) : ( ) : (
<VirtualTargetSignalPanel <VirtualTargetSignalPanel
@@ -193,6 +186,16 @@ const VirtualTargetCard: React.FC<Props> = ({
receiving={highlightReceiving} receiving={highlightReceiving}
/> />
)} )}
<VirtualTargetCardFoot
snapshot={snapshot}
strategies={strategies}
strategyId={strategyId}
globalStrategyId={globalStrategyId}
candleType={evalCandleType}
onStrategyChange={onStrategyChange}
onCandleTypeChange={onCandleTypeChange}
/>
</div> </div>
); );
}; };
@@ -33,6 +33,8 @@ interface Props {
chartSeriesPriceLabels?: boolean; chartSeriesPriceLabels?: boolean;
/** 전체보기 분할 pane — 캔버스 높이 100% */ /** 전체보기 분할 pane — 캔버스 높이 100% */
fillHeight?: boolean; fillHeight?: boolean;
/** 카드 푸터 평가 분봉과 동기화 */
chartTimeframe?: Timeframe;
} }
const noop = () => {}; const noop = () => {};
@@ -45,14 +47,21 @@ const VirtualTargetCardChart: React.FC<Props> = ({
chartRealtimeSource = 'BACKEND_STOMP', chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true, chartSeriesPriceLabels = true,
fillHeight = false, fillHeight = false,
chartTimeframe,
}) => { }) => {
const { getParams, getVisualConfig } = useIndicatorSettings(); const { getParams, getVisualConfig } = useIndicatorSettings();
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]); const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy)); const [timeframe, setTimeframe] = useState<Timeframe>(
() => chartTimeframe ?? resolveStrategyPrimaryTimeframe(strategy),
);
useEffect(() => { useEffect(() => {
if (chartTimeframe) {
setTimeframe(chartTimeframe);
return;
}
setTimeframe(resolveStrategyPrimaryTimeframe(strategy)); setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
}, [strategy, market]); }, [strategy, market, chartTimeframe]);
const indicators = useMemo( const indicators = useMemo(
() => buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig), () => 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 VirtualTargetQuote from './VirtualTargetQuote';
import VirtualTargetCardChart from './VirtualTargetCardChart'; import VirtualTargetCardChart from './VirtualTargetCardChart';
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel'; 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 { interface Props {
market: string; market: string;
@@ -20,6 +24,8 @@ interface Props {
strategyId: number | null; strategyId: number | null;
globalStrategyId: number | null; globalStrategyId: number | null;
onStrategyChange?: (strategyId: number | null) => void; onStrategyChange?: (strategyId: number | null) => void;
candleType: string;
onCandleTypeChange?: (candleType: string) => void;
snapshot: VirtualIndicatorSnapshot | undefined; snapshot: VirtualIndicatorSnapshot | undefined;
running: boolean; running: boolean;
liveStatus?: VirtualLiveStatus; liveStatus?: VirtualLiveStatus;
@@ -41,6 +47,8 @@ const VirtualTargetFocusView: React.FC<Props> = ({
strategyId, strategyId,
globalStrategyId, globalStrategyId,
onStrategyChange, onStrategyChange,
candleType,
onCandleTypeChange,
snapshot, snapshot,
running, running,
liveStatus = 'idle', liveStatus = 'idle',
@@ -55,7 +63,6 @@ const VirtualTargetFocusView: React.FC<Props> = ({
}) => { }) => {
const ko = getKoreanName(market); const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, ''); const sym = market.replace(/^KRW-/, '');
const tf = snapshot?.timeframe ?? '—';
const receiveSignal = useMemo(() => { const receiveSignal = useMemo(() => {
if (!running) return null; if (!running) return null;
@@ -63,6 +70,8 @@ const VirtualTargetFocusView: React.FC<Props> = ({
}, [running, lastTickAt, snapshot?.updatedAt]); }, [running, lastTickAt, snapshot?.updatedAt]);
const receiving = useLiveReceiveFlash(receiveSignal, running); const receiving = useLiveReceiveFlash(receiveSignal, running);
const highlightReceiving = chartLiveReceiveHighlight && receiving; const highlightReceiving = chartLiveReceiveHighlight && receiving;
const evalCandleType = normalizeStartCandleType(candleType);
const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe;
return ( return (
<div className={`vtd-focus-wrap${highlightReceiving ? ' vtd-focus-wrap--receiving' : ''}`}> <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-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span> <span className="vtd-card-sym">{sym}</span>
</div> </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} />} {running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
</div> </div>
<button <button
@@ -123,6 +115,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
chartRealtimeSource={chartRealtimeSource} chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels} chartSeriesPriceLabels={chartSeriesPriceLabels}
fillHeight fillHeight
chartTimeframe={chartTimeframe}
/> />
</div> </div>
</section> </section>
@@ -137,6 +130,16 @@ const VirtualTargetFocusView: React.FC<Props> = ({
</div> </div>
</section> </section>
</div> </div>
<VirtualTargetCardFoot
snapshot={snapshot}
strategies={strategies}
strategyId={strategyId}
globalStrategyId={globalStrategyId}
candleType={evalCandleType}
onStrategyChange={onStrategyChange}
onCandleTypeChange={onCandleTypeChange}
/>
</div> </div>
); );
}; };
@@ -4,7 +4,12 @@ import VirtualTargetFocusView from './VirtualTargetFocusView';
import type { StrategyDto } from '../../utils/backendApi'; import type { StrategyDto } from '../../utils/backendApi';
import type { Theme } from '../../types'; import type { Theme } from '../../types';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; 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 { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import type { TickerData } from '../../hooks/useMarketTicker'; import type { TickerData } from '../../hooks/useMarketTicker';
@@ -15,6 +20,7 @@ interface Props {
snapshots: Record<string, VirtualIndicatorSnapshot>; snapshots: Record<string, VirtualIndicatorSnapshot>;
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>; session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
onTargetStrategyChange: (market: string, strategyId: number | null) => void; onTargetStrategyChange: (market: string, strategyId: number | null) => void;
onTargetCandleTypeChange: (market: string, candleType: string) => void;
liveStatusByMarket?: Record<string, VirtualLiveStatus>; liveStatusByMarket?: Record<string, VirtualLiveStatus>;
lastTickAtByMarket?: Record<string, number>; lastTickAtByMarket?: Record<string, number>;
selectedMarket?: string; selectedMarket?: string;
@@ -32,6 +38,7 @@ interface Props {
const VirtualTargetGrid: React.FC<Props> = ({ const VirtualTargetGrid: React.FC<Props> = ({
targets, strategies, snapshots, session, targets, strategies, snapshots, session,
onTargetStrategyChange, onTargetStrategyChange,
onTargetCandleTypeChange,
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket, liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
onSelectMarket, onSelectMarket,
theme = 'dark', theme = 'dark',
@@ -109,6 +116,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
strategyId={focusTarget.strategyId} strategyId={focusTarget.strategyId}
globalStrategyId={session.globalStrategyId} globalStrategyId={session.globalStrategyId}
onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)} onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)}
candleType={resolveTargetCandleType(focusTarget, snapshots[focusTarget.market]?.timeframe)}
onCandleTypeChange={ct => onTargetCandleTypeChange(focusTarget.market, ct)}
snapshot={snapshots[focusTarget.market]} snapshot={snapshots[focusTarget.market]}
running={session.running} running={session.running}
liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')} liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')}
@@ -134,6 +143,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
strategyId={t.strategyId} strategyId={t.strategyId}
globalStrategyId={session.globalStrategyId} globalStrategyId={session.globalStrategyId}
onStrategyChange={id => onTargetStrategyChange(t.market, id)} onStrategyChange={id => onTargetStrategyChange(t.market, id)}
candleType={resolveTargetCandleType(t, snapshots[t.market]?.timeframe)}
onCandleTypeChange={ct => onTargetCandleTypeChange(t.market, ct)}
snapshot={snapshots[t.market]} snapshot={snapshots[t.market]}
running={session.running} running={session.running}
liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')} liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
@@ -3,7 +3,6 @@ import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSn
import { import {
buildConditionMetrics, buildConditionMetrics,
computeMatchRate, computeMatchRate,
formatUpdatedTime,
getTrafficLightState, getTrafficLightState,
} from '../../utils/virtualSignalMetrics'; } from '../../utils/virtualSignalMetrics';
import VirtualSignalEqualizer from './VirtualSignalEqualizer'; import VirtualSignalEqualizer from './VirtualSignalEqualizer';
@@ -67,10 +66,6 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
<VirtualIndicatorCompareTable metrics={metrics} /> <VirtualIndicatorCompareTable metrics={metrics} />
</div> </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> </div>
); );
}; };
@@ -1431,6 +1431,37 @@
color: var(--accent, #3f7ef5); 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 { .vtd-sig-panel-title {
font-size: 10px; font-size: 10px;
font-weight: 800; font-weight: 800;
+116 -4
View File
@@ -1,6 +1,7 @@
/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */ /** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
import type { ConditionDSL } from './strategyTypes'; import type { ConditionDSL } from './strategyTypes';
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes'; import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
import { migratePriceExtremeCondition } from './priceExtremeIndicators';
export interface CompositePeriodDef { export interface CompositePeriodDef {
rsiPeriod: number; rsiPeriod: number;
@@ -21,8 +22,40 @@ export interface CompositeIndicatorItem {
desc: string; 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[] = [ export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [
{ value: 'DONCHIAN', label: '돈치안 채널', desc: '단기·장기 고가/저가 채널 비교 (9·20일)' },
{ value: 'RSI', label: 'RSI', desc: 'RSI 기간 교차' }, { value: 'RSI', label: 'RSI', desc: 'RSI 기간 교차' },
{ value: 'CCI', label: 'CCI', desc: 'CCI 기간 교차' }, { value: 'CCI', label: 'CCI', desc: 'CCI 기간 교차' },
{ value: 'ADX', label: 'ADX', desc: 'ADX 기간 교차' }, { 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 } { export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef): { short: number; long: number } {
switch (ind) { switch (ind) {
case 'DONCHIAN':
return { short: 9, long: 20 };
case 'RSI': return { short: def.rsiPeriod, long: Math.max(def.rsiPeriod * 2, 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 '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) }; 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 { export function compositeValueField(ind: string, period: number): string {
switch (ind) { switch (ind) {
case 'DONCHIAN':
return donchianUpperField(period);
case 'RSI': return `RSI_VALUE_${period}`; case 'RSI': return `RSI_VALUE_${period}`;
case 'CCI': return `CCI_VALUE_${period}`; case 'CCI': return `CCI_VALUE_${period}`;
case 'ADX': return `ADX_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 */ /** 복합지표용 값 라인 필드(CCI_VALUE_9 등)인지 — K_100 임계값은 false */
export function isCompositeValueField(ind: string, field?: string): boolean { export function isCompositeValueField(ind: string, field?: string): boolean {
if (!field || isThresholdField(field)) return false; if (!field || isThresholdField(field)) return false;
if (isDonchianComposite(ind)) {
return parseDonchianPeriod(field) != null;
}
if (ind === 'DISPARITY') { if (ind === 'DISPARITY') {
return field.startsWith('DISPARITY') && field.length > 'DISPARITY'.length return field.startsWith('DISPARITY') && field.length > 'DISPARITY'.length
&& /^\d+$/.test(field.slice('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 { export function parsePeriodFromCompositeField(ind: string, field?: string): number | null {
if (!field || isThresholdField(field)) return null; if (!field || isThresholdField(field)) return null;
if (isDonchianComposite(ind)) {
return parseDonchianPeriod(field);
}
if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) { if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) {
const n = parseInt(field.slice('DISPARITY'.length), 10); const n = parseInt(field.slice('DISPARITY'.length), 10);
return Number.isFinite(n) && n > 0 ? n : null; 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; 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 { export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
if (!cond.composite) return cond; if (!cond.composite) return cond;
if (isDonchianComposite(cond.indicatorType)) {
return syncDonchianCompositeFields(cond);
}
const leftP = cond.leftPeriod const leftP = cond.leftPeriod
?? parsePeriodFromCompositeField(cond.indicatorType, cond.leftField) ?? parsePeriodFromCompositeField(cond.indicatorType, cond.leftField)
?? undefined; ?? undefined;
@@ -139,6 +206,9 @@ export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
} }
export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL { export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
const migrated = migratePriceExtremeCondition(cond);
if (migrated !== cond) return migrated;
if (cond.composite) { if (cond.composite) {
if (isThresholdField(cond.leftField) || isThresholdField(cond.rightField)) { if (isThresholdField(cond.leftField) || isThresholdField(cond.rightField)) {
return { return {
@@ -151,8 +221,21 @@ export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
return syncCompositeFields(cond); return syncCompositeFields(cond);
} }
const ind = cond.indicatorType; 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)) { if (!isCompositeValueField(ind, cond.leftField) || !isCompositeValueField(ind, cond.rightField)) {
return cond; return migratePriceExtremeCondition(cond);
} }
const leftP = parsePeriodFromCompositeField(ind, cond.leftField); const leftP = parsePeriodFromCompositeField(ind, cond.leftField);
const rightP = parsePeriodFromCompositeField(ind, cond.rightField); const rightP = parsePeriodFromCompositeField(ind, cond.rightField);
@@ -164,7 +247,7 @@ export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
rightPeriod: rightP, rightPeriod: rightP,
}); });
} }
return cond; return migratePriceExtremeCondition(cond);
} }
export function makeCompositeCondition( export function makeCompositeCondition(
@@ -173,6 +256,18 @@ export function makeCompositeCondition(
def: CompositePeriodDef, def: CompositePeriodDef,
): ConditionDSL { ): ConditionDSL {
const { short, long } = getCompositeDefaultPeriods(ind, def); 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({ return syncCompositeFields({
indicatorType: ind, indicatorType: ind,
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', 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 { export function compositePeriodLabel(ind: string, def: CompositePeriodDef): string {
const { short, long } = getCompositeDefaultPeriods(ind, def); const { short, long } = getCompositeDefaultPeriods(ind, def);
return `${short} / ${long}`; return `${short} / ${long}`;
@@ -192,17 +297,24 @@ export function compositePeriodLabel(ind: string, def: CompositePeriodDef): stri
export function compositeDisplayName(ind: string): string { export function compositeDisplayName(ind: string): string {
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind); const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
if (ind === 'DONCHIAN') return '돈치안 채널 (9·20일)';
if (item) return `${item.label} + ${item.label}`; if (item) return `${item.label} + ${item.label}`;
return `${ind} + ${ind}`; return `${ind} + ${ind}`;
} }
export function compositeElementLabel(ind: string, slot: 1 | 2): string { 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 item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
const name = item?.label ?? ind; const name = item?.label ?? ind;
return `${name} ${slot}`; return `${name} ${slot}`;
} }
/** 복합지표 조건대상 라벨 — 예: CCI 1 라인(9일) */ /** 복합지표 조건대상 라벨 */
export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string { export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string {
if (ind === 'DONCHIAN') {
return `${period}${slot === 1 ? '단기' : '장기'} 고가선`;
}
return `${compositeElementLabel(ind, slot)} 라인(${period}일)`; return `${compositeElementLabel(ind, slot)} 라인(${period}일)`;
} }
+48
View File
@@ -4,11 +4,20 @@ import {
compositeFieldLabel, compositeFieldLabel,
compositeValueField, compositeValueField,
getCompositeDefaultPeriods, getCompositeDefaultPeriods,
getDonchianFieldOpts,
isDonchianComposite,
isThresholdField, isThresholdField,
parseDonchianPeriod,
parsePeriodFromCompositeField, parsePeriodFromCompositeField,
syncCompositeFields, syncCompositeFields,
type CompositePeriodDef, type CompositePeriodDef,
} from './compositeIndicators'; } from './compositeIndicators';
import {
isPriceExtremeIndicator,
migratePriceExtremeCondition,
parsePriorExtremePeriod,
syncPriceExtremeSimpleFields,
} from './priceExtremeIndicators';
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes'; import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
export interface IndicatorPeriodDef { export interface IndicatorPeriodDef {
@@ -36,6 +45,9 @@ const VALUE_FIELD_PREFIX: Record<string, string> = {
export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number { export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number {
switch (indicatorType) { switch (indicatorType) {
case 'NEW_HIGH':
case 'NEW_LOW':
return 9;
case 'RSI': return def.rsiPeriod; case 'RSI': return def.rsiPeriod;
case 'CCI': return def.cciPeriod; case 'CCI': return def.cciPeriod;
case 'ADX': return def.adxPeriod; case 'ADX': return def.adxPeriod;
@@ -59,6 +71,12 @@ function parseFieldPeriod(field?: string): number | null {
/** 조건 노드의 RSI 등 값(좌측) 기간 */ /** 조건 노드의 RSI 등 값(좌측) 기간 */
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number { 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.composite) {
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod; if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField); const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
@@ -100,6 +118,7 @@ export function isValuePeriodFieldStored(indicatorType: string, field?: string):
} }
export function usesValuePeriodField(cond: ConditionDSL): boolean { export function usesValuePeriodField(cond: ConditionDSL): boolean {
if (isPriceExtremeIndicator(cond.indicatorType)) return true;
if (cond.composite) return true; if (cond.composite) return true;
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType]; const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
if (!prefix) return false; if (!prefix) return false;
@@ -133,6 +152,9 @@ export function setConditionValuePeriod(
_def: IndicatorPeriodDef, _def: IndicatorPeriodDef,
): ConditionDSL { ): ConditionDSL {
const p = Math.max(1, Math.min(500, Math.round(period))); const p = Math.max(1, Math.min(500, Math.round(period)));
if (isPriceExtremeIndicator(cond.indicatorType)) {
return syncPriceExtremeSimpleFields({ ...cond, period: p });
}
if (cond.composite) { if (cond.composite) {
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p }); return syncCompositeFields({ ...cond, composite: true, leftPeriod: p });
} }
@@ -170,6 +192,10 @@ export function initConditionPeriods(
condition: ConditionDSL, condition: ConditionDSL,
def: IndicatorPeriodDef, def: IndicatorPeriodDef,
): ConditionDSL { ): ConditionDSL {
if (isPriceExtremeIndicator(indicatorType)) {
const period = condition.period ?? getDefaultIndicatorPeriod(indicatorType, def);
return syncPriceExtremeSimpleFields({ ...condition, period });
}
if (condition.composite) return condition; if (condition.composite) return condition;
const prefix = VALUE_FIELD_PREFIX[indicatorType]; const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix) return condition; 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[] { export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] {
const base = getDefaultIndicatorPeriod(indicatorType, def); 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); 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(장기) 기간 프리셋 */ /** 복합지표 요소1(단기)·요소2(장기) 기간 프리셋 */
export function getCompositePeriodPresetOptions( export function getCompositePeriodPresetOptions(
indicatorType: string, indicatorType: string,
@@ -242,6 +278,18 @@ export function getCompositeFieldOpts(
: getConditionRightPeriod(cond, def); : getConditionRightPeriod(cond, def);
const presets = getCompositePeriodPresetOptions(indicatorType, def, side); const presets = getCompositePeriodPresetOptions(indicatorType, def, side);
const periods = [...new Set([...presets, current])].sort((a, b) => a - b); 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 => ({ return periods.map(p => ({
value: compositeValueField(indicatorType, p), value: compositeValueField(indicatorType, p),
label: compositeFieldLabel(indicatorType, slot, p), label: compositeFieldLabel(indicatorType, slot, p),
+10
View File
@@ -384,8 +384,18 @@ export function getIndicatorDef(type: string): IndicatorDef | undefined {
return INDICATOR_REGISTRY.find(d => d.type === type); 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) */ /** 가상투자·조건 목록 — 한글명 (영문약어) 예: 상품채널지수 (CCI) */
export function formatIndicatorDisplayLabel(indicatorType: string): string { export function formatIndicatorDisplayLabel(indicatorType: string): string {
const alias = DSL_DISPLAY_ALIASES[indicatorType];
if (alias) {
return `${alias.koreanName} (${alias.shortName})`;
}
const def = getIndicatorDef(indicatorType); const def = getIndicatorDef(indicatorType);
const abbr = def?.shortName ?? indicatorType; const abbr = def?.shortName ?? indicatorType;
const ko = def?.koreanName ?? abbr; 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}일 신고가선`;
}
+54 -6
View File
@@ -9,9 +9,17 @@ import {
compositeFieldLabel, compositeFieldLabel,
makeCompositeCondition, makeCompositeCondition,
normalizeCompositeCondition, normalizeCompositeCondition,
parseDonchianPeriod,
parsePeriodFromCompositeField, parsePeriodFromCompositeField,
syncCompositeFields, syncCompositeFields,
} from '../utils/compositeIndicators'; } from '../utils/compositeIndicators';
import {
isPriceExtremeIndicator,
nhPriorField,
nlPriorField,
parsePriorExtremePeriod,
syncPriceExtremeSimpleFields,
} from '../utils/priceExtremeIndicators';
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect'; import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
import { import {
getCompositeFieldOpts, getCompositeFieldOpts,
@@ -177,6 +185,8 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
OBV: 'OBV', OBV: 'OBV',
BOLLINGER: 'BollingerBands', BOLLINGER: 'BollingerBands',
DONCHIAN: 'DonchianChannels', DONCHIAN: 'DonchianChannels',
NEW_HIGH: 'PriceExtreme',
NEW_LOW: 'PriceExtreme',
}; };
const extractThresh = (dslType: string): HLThresh => { const extractThresh = (dslType: string): HLThresh => {
@@ -469,13 +479,35 @@ export const getFieldOpts = (
{ value:'HIGH_PRICE', label:'고가' }, { value:'HIGH_PRICE', label:'고가' },
{ value:'LOW_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([ case 'DONCHIAN': return condOpts([
{ value:'DC_UPPER_9', label:'상단(9일 최고가·신고가)' },
{ value:'DC_UPPER_10', label:'상단(10일 최고가)' }, { 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_UPPER_55', label:'상단(55일 최고가)' },
{ value:'DC_MIDDLE_20', label:'중심(20일)' }, { value:'DC_MIDDLE_20', label:'중심(20일)' },
{ value:'DC_LOWER_9', label:'하단(9일 최저가·신저가)' },
{ value:'DC_LOWER_10', label:'하단(10일 최저가)' }, { 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:'DC_LOWER_55', label:'하단(55일 최저가)' },
{ value:'CLOSE_PRICE', label:'종가' }, { value:'CLOSE_PRICE', label:'종가' },
{ value:'LOW_PRICE', label:'저가' }, { value:'LOW_PRICE', label:'저가' },
@@ -526,6 +558,8 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
DONCHIAN: signalType === 'buy' DONCHIAN: signalType === 'buy'
? { l:'CLOSE_PRICE', r:'DC_UPPER_20' } ? { l:'CLOSE_PRICE', r:'DC_UPPER_20' }
: { l:'CLOSE_PRICE', r:'DC_LOWER_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' }, ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' },
}; };
return map[ind] ?? { l:'NONE', r:'NONE' }; 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'; updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE';
} }
} }
if (isPriceExtremeIndicator(updated.indicatorType)) {
return syncPriceExtremeSimpleFields(updated);
}
return 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, OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
ZERO_LINE: 0, ZERO_LINE: 0,
}; };
const upd: ConditionDSL = { ...normalized, rightField: v }; let upd: ConditionDSL = { ...normalized, rightField: v };
if (thresholds[v] !== undefined) upd.targetValue = thresholds[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); onChange(upd);
}; };
@@ -769,7 +810,8 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right'); const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right');
const handleCompositeField = (side: 'left' | 'right', field: string) => { 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 (p == null) return;
if (side === 'left') { if (side === 'left') {
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field })); onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field }));
@@ -1035,16 +1077,22 @@ export const makeNode = (
} }
const def = getDefaultFields(value, signalType, DEF); const def = getDefaultFields(value, signalType, DEF);
const period = options?.period ?? getDefaultIndicatorPeriod(value, 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 = { const baseCondition: ConditionDSL = {
indicatorType: value, indicatorType: value,
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', conditionType,
leftField: def.l, leftField: def.l,
rightField: def.r, rightField: def.r,
candleRange: 1, candleRange: 1,
period, period,
}; };
const condition = initConditionPeriods(value, baseCondition, DEF);
return { return {
id: genId(), type: 'CONDITION', id: genId(), type: 'CONDITION',
condition: initConditionPeriods(value, baseCondition, DEF), condition: isPriceExtremeIndicator(value)
? syncPriceExtremeSimpleFields(condition)
: condition,
}; };
}; };
+26 -1
View File
@@ -37,6 +37,8 @@ export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', builtIn: true }, { value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', builtIn: true },
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', builtIn: true }, { value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', builtIn: true },
{ value: 'VOLUME', label: '거래량', desc: 'Volume', 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'>[] = 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[] { export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP; const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS; const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
try { try {
const stored = parseStored(localStorage.getItem(key)); const stored = parseStored(localStorage.getItem(key));
if (stored && stored.length > 0) { if (stored && stored.length > 0) {
return stored.map(i => ({ ...i, kind })); return mergeMissingBuiltIns(stored.map(i => ({ ...i, kind })), kind, defaults);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
return seedItems(kind, defaults); return seedItems(kind, defaults);
+66 -2
View File
@@ -1,5 +1,6 @@
import type { LogicNode } from './strategyTypes'; import type { LogicNode } from './strategyTypes';
import { genId, type DefType } from './strategyEditorShared'; import { genId, type DefType } from './strategyEditorShared';
import { nhPriorField, nlPriorField } from './priceExtremeIndicators';
export type SimpleStrategyTemplate = { export type SimpleStrategyTemplate = {
kind: 'simple'; kind: 'simple';
@@ -10,6 +11,7 @@ export type SimpleStrategyTemplate = {
cond: string; cond: string;
l: string; l: string;
r: string; r: string;
period?: number;
}; };
export type CompositeStrategyTemplate = { export type CompositeStrategyTemplate = {
@@ -28,11 +30,19 @@ function condition(
leftField: string, leftField: string,
rightField: string, rightField: string,
candleRange = 1, candleRange = 1,
period?: number,
): LogicNode { ): LogicNode {
return { return {
id: genId(), id: genId(),
type: 'CONDITION', 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}`); 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일) 골든·데드크로스 */ /** 일목균형표 — 전환선(9일) / 기준선(26일) 골든·데드크로스 */
function ichimokuTenkanKijunCross(signal: 'buy' | 'sell'): LogicNode { function ichimokuTenkanKijunCross(signal: 'buy' | 'sell'): LogicNode {
return condition( 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: '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: '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: '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', kind: 'composite',
signal: 'buy', signal: 'buy',
@@ -173,5 +237,5 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
} }
export function simpleTemplateToNode(tmpl: SimpleStrategyTemplate): LogicNode { 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', INVEST_PSYCHOLOGICAL: 'InvestPsychological',
BWI: 'BBBandWidth', BWI: 'BBBandWidth',
DONCHIAN: 'DonchianChannels', DONCHIAN: 'DonchianChannels',
NEW_HIGH: 'DonchianChannels',
NEW_LOW: 'DonchianChannels',
ICHIMOKU: 'IchimokuCloud', ICHIMOKU: 'IchimokuCloud',
ATR: 'ATR', ATR: 'ATR',
MFI: 'MFI', MFI: 'MFI',
+24
View File
@@ -72,6 +72,8 @@ export const INDICATOR_CONDITIONS: Record<string, string[]> = {
MA: COMMON_CONDITIONS, EMA: COMMON_CONDITIONS, MA: COMMON_CONDITIONS, EMA: COMMON_CONDITIONS,
BOLLINGER: COMMON_CONDITIONS, BOLLINGER: COMMON_CONDITIONS,
DONCHIAN: COMMON_CONDITIONS, DONCHIAN: COMMON_CONDITIONS,
NEW_HIGH: COMMON_CONDITIONS,
NEW_LOW: COMMON_CONDITIONS,
ICHIMOKU: [ ICHIMOKU: [
...COMMON_CONDITIONS, ...COMMON_CONDITIONS,
'ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN', '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: 'LOW_PRICE', label: '저가' },
{ value: 'HIGH_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: [ ICHIMOKU: [
{ value: 'CLOSE_PRICE', label: '종가' }, { value: 'CLOSE_PRICE', label: '종가' },
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', 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: '직접 입력' }, { value: 'LOWER', label: '하단밴드' }, { value: 'CUSTOM', label: '직접 입력' },
], ],
DONCHIAN: [ DONCHIAN: [
{ value: 'DC_UPPER_9', label: '상단(9일 최고가)' },
{ value: 'DC_UPPER_10', label: '상단(10일 최고가)' }, { 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_UPPER_55', label: '상단(55일 최고가)' },
{ value: 'DC_MIDDLE_20', label: '중심(20일)' }, { value: 'DC_MIDDLE_20', label: '중심(20일)' },
{ value: 'DC_LOWER_9', label: '하단(9일 최저가)' },
{ value: 'DC_LOWER_10', label: '하단(10일 최저가)' }, { 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: 'DC_LOWER_55', label: '하단(55일 최저가)' },
{ value: 'CUSTOM', label: '직접 입력' }, { 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: [ ICHIMOKU: [
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' }, { value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
{ value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' }, { value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' },
@@ -1,5 +1,7 @@
import { saveLiveStrategySettings } from './backendApi'; import { saveLiveStrategySettings } from './backendApi';
import { normalizeStartCandleType } from './strategyStartNodes';
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage'; import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
import { resolveTargetCandleType } from './virtualTradingStorage';
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */ /** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */
export async function syncVirtualTargetsToBackend( export async function syncVirtualTargetsToBackend(
@@ -21,6 +23,7 @@ export async function syncVirtualTargetsToBackend(
saveLiveStrategySettings({ saveLiveStrategySettings({
market: t.market, market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId, strategyId: t.strategyId ?? session.globalStrategyId,
candleType: normalizeStartCandleType(resolveTargetCandleType(t)),
...shared, ...shared,
}), }),
), ),
@@ -234,3 +234,19 @@ export function formatUpdatedTime(ts: number | undefined): string {
hour12: false, 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 */ /** 가상투자 대상 종목·세션 설정 localStorage */
export interface VirtualTargetItem { export interface VirtualTargetItem {
market: string; market: string;
strategyId: number | null; strategyId: number | null;
/** 종목별 전략 평가 분봉 (gc_live_strategy_settings.candle_type) */
candleType?: string;
koreanName?: string; koreanName?: string;
englishName?: string; englishName?: string;
} }
@@ -92,3 +96,14 @@ export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
localStorage.setItem(CARD_VIEW_KEY, mode); localStorage.setItem(CARD_VIEW_KEY, mode);
} catch { /* ignore */ } } 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';
}
+604
View File
@@ -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 파일 목록을 참고하세요.*