전략평가 MA 참조에러 수정
This commit is contained in:
@@ -500,7 +500,7 @@ public class LiveConditionStatusService {
|
|||||||
.displayName(indType)
|
.displayName(indType)
|
||||||
.conditionType(condType)
|
.conditionType(condType)
|
||||||
.conditionLabel(condLabel)
|
.conditionLabel(condLabel)
|
||||||
.thresholdLabel(formatThresholdStatic(c, target))
|
.thresholdLabel(formatThresholdStatic(c, target, visual))
|
||||||
.currentValue(null)
|
.currentValue(null)
|
||||||
.targetValue(target)
|
.targetValue(target)
|
||||||
.satisfied(null)
|
.satisfied(null)
|
||||||
@@ -510,7 +510,8 @@ public class LiveConditionStatusService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String formatThresholdStatic(JsonNode cond, Double target) {
|
private String formatThresholdStatic(JsonNode cond, Double target,
|
||||||
|
Map<String, Map<String, Object>> params) {
|
||||||
if (target != null && !target.isNaN()) {
|
if (target != null && !target.isNaN()) {
|
||||||
String ct = cond.path("conditionType").asText("GT");
|
String ct = cond.path("conditionType").asText("GT");
|
||||||
String v = formatNum(target);
|
String v = formatNum(target);
|
||||||
@@ -525,7 +526,7 @@ public class LiveConditionStatusService {
|
|||||||
}
|
}
|
||||||
String rf = cond.path("rightField").asText("");
|
String rf = cond.path("rightField").asText("");
|
||||||
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
|
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
|
||||||
return rf.replace('_', ' ');
|
return adapter.formatMovingAverageFieldLabel(rf, params);
|
||||||
}
|
}
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
@@ -576,16 +577,17 @@ public class LiveConditionStatusService {
|
|||||||
Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false);
|
Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false);
|
||||||
if (rightLive != null && !rightLive.isNaN()) {
|
if (rightLive != null && !rightLive.isNaN()) {
|
||||||
String v = formatNum(rightLive);
|
String v = formatNum(rightLive);
|
||||||
|
String fieldLabel = adapter.formatMovingAverageFieldLabel(rf, params);
|
||||||
return switch (ct) {
|
return switch (ct) {
|
||||||
case "LT", "CROSS_DOWN" -> "< " + v;
|
case "LT", "CROSS_DOWN" -> "< " + v;
|
||||||
case "GT", "CROSS_UP" -> "> " + v;
|
case "GT", "CROSS_UP" -> "> " + v;
|
||||||
case "GTE" -> "≥ " + v;
|
case "GTE" -> "≥ " + v;
|
||||||
case "LTE" -> "≤ " + v;
|
case "LTE" -> "≤ " + v;
|
||||||
case "EQ" -> "= " + v;
|
case "EQ" -> "= " + v;
|
||||||
default -> rf.replace('_', ' ') + " (" + v + ")";
|
default -> fieldLabel + " (" + v + ")";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return rf.replace('_', ' ');
|
return adapter.formatMovingAverageFieldLabel(rf, params);
|
||||||
}
|
}
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
market, storage, useConfirmedOnly, seriesOverrides);
|
market, storage, useConfirmedOnly, seriesOverrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */
|
||||||
|
public Map<String, Object> smaParams() {
|
||||||
|
if (indicatorParams == null) return Map.of();
|
||||||
|
Map<String, Object> sma = indicatorParams.get("SMA");
|
||||||
|
return sma != null ? sma : Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
/** 사용 중인 시리즈 오버라이드가 있으면 백테스트 모드 */
|
/** 사용 중인 시리즈 오버라이드가 있으면 백테스트 모드 */
|
||||||
public boolean isBacktest() {
|
public boolean isBacktest() {
|
||||||
return seriesOverrides != null && !seriesOverrides.isEmpty();
|
return seriesOverrides != null && !seriesOverrides.isEmpty();
|
||||||
@@ -233,7 +240,9 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
: Map.of();
|
: Map.of();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Indicator<Num> ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod);
|
RuleBuildContext ctx = new RuleBuildContext(series, params != null ? params : Map.of(),
|
||||||
|
Map.of(), null, null, false, Map.of());
|
||||||
|
Indicator<Num> ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod, cond, ctx);
|
||||||
Num v = ind.getValue(index);
|
Num v = ind.getValue(index);
|
||||||
return v != null ? v.doubleValue() : null;
|
return v != null ? v.doubleValue() : null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -722,7 +731,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
case "VOLUME_VALUE" -> new VolumeIndicator(s, 1);
|
case "VOLUME_VALUE" -> new VolumeIndicator(s, 1);
|
||||||
default -> {
|
default -> {
|
||||||
Double hlineVal = IndicatorHlineResolver.resolveThresholdField(
|
Double hlineVal = IndicatorHlineResolver.resolveThresholdField(
|
||||||
cond, field, indType, ctx.indicatorVisual());
|
cond, field, indType, ctx != null ? ctx.indicatorVisual() : Map.of());
|
||||||
if (hlineVal != null) {
|
if (hlineVal != null) {
|
||||||
yield new ConstantIndicator<>(s, s.numFactory().numOf(hlineVal));
|
yield new ConstantIndicator<>(s, s.numFactory().numOf(hlineVal));
|
||||||
}
|
}
|
||||||
@@ -730,7 +739,8 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
if (!Double.isNaN(constant)) {
|
if (!Double.isNaN(constant)) {
|
||||||
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
|
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
|
||||||
}
|
}
|
||||||
yield resolveIndicatorField(field, indType, p, s, condPeriod, sidePeriod);
|
Map<String, Object> sma = ctx != null ? ctx.smaParams() : Map.of();
|
||||||
|
yield resolveIndicatorField(field, indType, p, sma, s, condPeriod, sidePeriod);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -764,7 +774,9 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Indicator<Num> resolveIndicatorField(String field, String indType,
|
private Indicator<Num> resolveIndicatorField(String field, String indType,
|
||||||
Map<String, Object> p, BarSeries s,
|
Map<String, Object> p,
|
||||||
|
Map<String, Object> smaParams,
|
||||||
|
BarSeries s,
|
||||||
int condPeriod, int sidePeriod) {
|
int condPeriod, int sidePeriod) {
|
||||||
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
||||||
int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1);
|
int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1);
|
||||||
@@ -943,12 +955,12 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return new LowestValueIndicator(close, period);
|
return new LowestValueIndicator(close, period);
|
||||||
}
|
}
|
||||||
if (field.startsWith("MA") && !field.startsWith("MACD")) {
|
if (field.startsWith("MA") && !field.startsWith("MACD")) {
|
||||||
try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); }
|
int period = resolveMovingAveragePeriod(field, "MA", smaParams);
|
||||||
catch (NumberFormatException ignored) { /* fall */ }
|
if (period > 0) return new SMAIndicator(close, period);
|
||||||
}
|
}
|
||||||
if (field.startsWith("EMA")) {
|
if (field.startsWith("EMA")) {
|
||||||
try { return new EMAIndicator(close, Integer.parseInt(field.substring(3))); }
|
int period = resolveMovingAveragePeriod(field, "EMA", smaParams);
|
||||||
catch (NumberFormatException ignored) { /* fall */ }
|
if (period > 0) return new EMAIndicator(close, period);
|
||||||
}
|
}
|
||||||
// 일목균형표
|
// 일목균형표
|
||||||
if (field.equals("CONVERSION_LINE")) return ichimokuTenkan(s, p);
|
if (field.equals("CONVERSION_LINE")) return ichimokuTenkan(s, p);
|
||||||
@@ -1304,6 +1316,61 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return new PreviousValueIndicator(lowest, 1);
|
return new PreviousValueIndicator(lowest, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MA/EMA DSL (MA1~11 ↔ SMA period1~11) ─────────────────────────────────
|
||||||
|
|
||||||
|
/** SMA 차트 MA1~MA11 기본 기간 — IndicatorService 와 동일 */
|
||||||
|
private static final int[] SMA_DEFAULT_PERIODS =
|
||||||
|
{ 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 };
|
||||||
|
private static final int MA_DSL_SLOT_MAX = 11;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MA/EMA DSL 필드 → SMA/EMA 계산 기간.
|
||||||
|
* MA3·MA5(1~11): SMA 설정 period1~11. MA20·MA60(>11): 레거시 기간 인코딩.
|
||||||
|
*/
|
||||||
|
public int resolveMovingAveragePeriod(String field, String prefix, Map<String, Object> smaParams) {
|
||||||
|
if (field == null || !field.startsWith(prefix)) return -1;
|
||||||
|
if ("MA".equals(prefix) && field.startsWith("MACD")) return -1;
|
||||||
|
try {
|
||||||
|
int n = Integer.parseInt(field.substring(prefix.length()));
|
||||||
|
if (n >= 1 && n <= MA_DSL_SLOT_MAX) {
|
||||||
|
String periodKey = "period" + n;
|
||||||
|
int def = SMA_DEFAULT_PERIODS[n - 1];
|
||||||
|
return Math.max(1, intP(smaParams, periodKey, def));
|
||||||
|
}
|
||||||
|
if (n > MA_DSL_SLOT_MAX) return n;
|
||||||
|
} catch (NumberFormatException ignored) { /* fall */ }
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** live-conditions·해석 UI — MA5(60일) 형식 */
|
||||||
|
public String formatMovingAverageFieldLabel(String field, Map<String, Map<String, Object>> params) {
|
||||||
|
if (field == null || field.isBlank()) return field;
|
||||||
|
Map<String, Object> sma = params != null ? params.getOrDefault("SMA", Map.of()) : Map.of();
|
||||||
|
if (field.startsWith("MA") && !field.startsWith("MACD")) {
|
||||||
|
int period = resolveMovingAveragePeriod(field, "MA", sma);
|
||||||
|
if (period <= 0) return field;
|
||||||
|
try {
|
||||||
|
int n = Integer.parseInt(field.substring(2));
|
||||||
|
if (n >= 1 && n <= MA_DSL_SLOT_MAX) return "MA" + n + "(" + period + "일)";
|
||||||
|
return "MA(" + period + "일)";
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.startsWith("EMA")) {
|
||||||
|
int period = resolveMovingAveragePeriod(field, "EMA", sma);
|
||||||
|
if (period <= 0) return field;
|
||||||
|
try {
|
||||||
|
int n = Integer.parseInt(field.substring(3));
|
||||||
|
if (n >= 1 && n <= MA_DSL_SLOT_MAX) return "EMA" + n + "(" + period + "일)";
|
||||||
|
return "EMA(" + period + "일)";
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 파라미터 헬퍼 ─────────────────────────────────────────────────────────
|
// ── 파라미터 헬퍼 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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,59 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MA1~MA11 슬롯 ↔ SMA period1~11 매핑 및 레거시 MA20·MA60(기간 인코딩) 호환.
|
||||||
|
*/
|
||||||
|
class MaDslFieldAdapterTest {
|
||||||
|
|
||||||
|
private StrategyDslToTa4jAdapter adapter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new StrategyDslToTa4jAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveMovingAveragePeriod_slotUsesSmaParams() {
|
||||||
|
Map<String, Object> sma = Map.of("period3", 20, "period5", 60);
|
||||||
|
assertEquals(20, adapter.resolveMovingAveragePeriod("MA3", "MA", sma));
|
||||||
|
assertEquals(60, adapter.resolveMovingAveragePeriod("MA5", "MA", sma));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveMovingAveragePeriod_legacyPeriodEncoding() {
|
||||||
|
Map<String, Object> sma = Map.of("period3", 20, "period5", 60);
|
||||||
|
assertEquals(20, adapter.resolveMovingAveragePeriod("MA20", "MA", sma));
|
||||||
|
assertEquals(60, adapter.resolveMovingAveragePeriod("MA60", "MA", sma));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveMovingAveragePeriod_oldSlotBugWouldUseLiteralDays() {
|
||||||
|
Map<String, Object> sma = Map.of("period3", 20, "period5", 60);
|
||||||
|
// MA3 must NOT resolve to 3-day SMA when period3=20
|
||||||
|
assertEquals(20, adapter.resolveMovingAveragePeriod("MA3", "MA", sma));
|
||||||
|
assertEquals(60, adapter.resolveMovingAveragePeriod("MA5", "MA", sma));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatMovingAverageFieldLabel_slotLabels() {
|
||||||
|
Map<String, Map<String, Object>> params = Map.of(
|
||||||
|
"SMA", Map.of("period3", 20, "period5", 60));
|
||||||
|
assertEquals("MA3(20일)", adapter.formatMovingAverageFieldLabel("MA3", params));
|
||||||
|
assertEquals("MA5(60일)", adapter.formatMovingAverageFieldLabel("MA5", params));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatMovingAverageFieldLabel_legacyLabels() {
|
||||||
|
Map<String, Map<String, Object>> params = Map.of(
|
||||||
|
"SMA", Map.of("period3", 20, "period5", 60));
|
||||||
|
assertEquals("MA(20일)", adapter.formatMovingAverageFieldLabel("MA20", params));
|
||||||
|
assertEquals("MA(60일)", adapter.formatMovingAverageFieldLabel("MA60", params));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -463,6 +463,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
barSignalHighlight={barSignalHighlight}
|
barSignalHighlight={barSignalHighlight}
|
||||||
hasStrategy={selectedStrategyId != null}
|
hasStrategy={selectedStrategyId != null}
|
||||||
className="seval-signal-panel"
|
className="seval-signal-panel"
|
||||||
|
getParams={getEvalParams}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
candleRangeWindowBars,
|
candleRangeWindowBars,
|
||||||
formatCandleRangeClause,
|
formatCandleRangeClause,
|
||||||
} from '../utils/strategyTypes';
|
} from '../utils/strategyTypes';
|
||||||
|
import {
|
||||||
|
appendLegacyMaFieldOption,
|
||||||
|
buildEmaFieldOptions,
|
||||||
|
buildMaFieldOptions,
|
||||||
|
} from '../utils/maDslField';
|
||||||
|
|
||||||
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
||||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||||
@@ -298,7 +303,7 @@ const ICHIMOKU_CONDS = [
|
|||||||
type Opt = { value: string; label: string };
|
type Opt = { value: string; label: string };
|
||||||
const NONE: Opt = { value: 'NONE', label: '선택안함' };
|
const NONE: Opt = { value: 'NONE', label: '선택안함' };
|
||||||
|
|
||||||
const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => {
|
const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType, cond?: ConditionDSL): Opt[] => {
|
||||||
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
|
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
|
||||||
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
||||||
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
|
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
|
||||||
@@ -377,16 +382,26 @@ const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[
|
|||||||
{ value:'VOLUME_VALUE', label:'거래량' },
|
{ value:'VOLUME_VALUE', label:'거래량' },
|
||||||
{ value:'VOLUME_MA', label:'거래량 이동평균' },
|
{ value:'VOLUME_MA', label:'거래량 이동평균' },
|
||||||
]);
|
]);
|
||||||
case 'MA': return [
|
case 'MA': {
|
||||||
NONE,
|
let opts = [
|
||||||
...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })),
|
NONE,
|
||||||
{ value:'CLOSE_PRICE', label:'종가' },
|
...buildMaFieldOptions(DEF.maLines),
|
||||||
];
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
case 'EMA': return [
|
];
|
||||||
NONE,
|
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
|
||||||
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
|
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
|
||||||
{ value:'CLOSE_PRICE', label:'종가' },
|
return opts;
|
||||||
];
|
}
|
||||||
|
case 'EMA': {
|
||||||
|
let opts = [
|
||||||
|
NONE,
|
||||||
|
...buildEmaFieldOptions(DEF.maLines, 4),
|
||||||
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
|
];
|
||||||
|
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
|
||||||
|
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
case 'DISPARITY': return condOpts([
|
case 'DISPARITY': return condOpts([
|
||||||
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
|
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
|
||||||
{ value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
|
{ value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
|
||||||
@@ -515,8 +530,8 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
|
|||||||
DMI: { l:'PDI', r:'MDI' },
|
DMI: { l:'PDI', r:'MDI' },
|
||||||
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
||||||
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
||||||
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
|
MA: { l: 'MA1', r: 'MA2' },
|
||||||
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
|
EMA: { l: 'EMA1', r: 'EMA2' },
|
||||||
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) },
|
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) },
|
||||||
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
|
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
|
||||||
NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: over(50) },
|
NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: over(50) },
|
||||||
@@ -538,7 +553,7 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
|
|||||||
// conditionType 변경 시 자동 필드 조정
|
// conditionType 변경 시 자동 필드 조정
|
||||||
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
||||||
const updated = { ...cond, conditionType: newCondType };
|
const updated = { ...cond, conditionType: newCondType };
|
||||||
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF, cond);
|
||||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||||
const def = getDefaultFields(cond.indicatorType, 'buy', DEF);
|
const def = getDefaultFields(cond.indicatorType, 'buy', DEF);
|
||||||
|
|
||||||
@@ -575,7 +590,7 @@ const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
|
|||||||
if (!node) return '';
|
if (!node) return '';
|
||||||
if (node.type === 'CONDITION' && node.condition) {
|
if (node.type === 'CONDITION' && node.condition) {
|
||||||
const c = node.condition;
|
const c = node.condition;
|
||||||
const opts = getFieldOpts(c.indicatorType, 'buy', DEF);
|
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
|
||||||
const L = opts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType;
|
const L = opts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType;
|
||||||
const R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
|
const R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
|
||||||
const C = condLabel(c.conditionType);
|
const C = condLabel(c.conditionType);
|
||||||
@@ -680,7 +695,7 @@ interface CondEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
|
const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
|
||||||
const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def);
|
const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def, cond);
|
||||||
const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
||||||
|
|
||||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface Props {
|
|||||||
loadingMessage?: string;
|
loadingMessage?: string;
|
||||||
hasStrategy?: boolean;
|
hasStrategy?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 보조지표 SMA period1~11 — MA 슬롯 라벨·해석용 */
|
||||||
|
getParams?: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SideCardProps {
|
interface SideCardProps {
|
||||||
@@ -32,6 +34,7 @@ interface SideCardProps {
|
|||||||
signalActive?: boolean;
|
signalActive?: boolean;
|
||||||
strategy?: StrategyDto | null;
|
strategy?: StrategyDto | null;
|
||||||
barTimeSec?: number | null;
|
barTimeSec?: number | null;
|
||||||
|
getParams?: Props['getParams'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SideCard: React.FC<SideCardProps> = ({
|
const SideCard: React.FC<SideCardProps> = ({
|
||||||
@@ -41,6 +44,7 @@ const SideCard: React.FC<SideCardProps> = ({
|
|||||||
signalActive = false,
|
signalActive = false,
|
||||||
strategy,
|
strategy,
|
||||||
barTimeSec,
|
barTimeSec,
|
||||||
|
getParams,
|
||||||
}) => {
|
}) => {
|
||||||
const [interpretOpen, setInterpretOpen] = useState(false);
|
const [interpretOpen, setInterpretOpen] = useState(false);
|
||||||
const sideLabel = side === 'buy' ? '매수' : '매도';
|
const sideLabel = side === 'buy' ? '매수' : '매도';
|
||||||
@@ -48,7 +52,7 @@ const SideCard: React.FC<SideCardProps> = ({
|
|||||||
const overallLabel =
|
const overallLabel =
|
||||||
overallMet === true ? '충족' : overallMet === false ? '미충족' : null;
|
overallMet === true ? '충족' : overallMet === false ? '미충족' : null;
|
||||||
|
|
||||||
const contextMap = useMemo(() => buildConditionContextMap(strategy), [strategy]);
|
const contextMap = useMemo(() => buildConditionContextMap(strategy, getParams), [strategy, getParams]);
|
||||||
const interpretation = useMemo(
|
const interpretation = useMemo(
|
||||||
() => buildSideInterpretation(side, metrics, overallMet, contextMap),
|
() => buildSideInterpretation(side, metrics, overallMet, contextMap),
|
||||||
[side, metrics, overallMet, contextMap],
|
[side, metrics, overallMet, contextMap],
|
||||||
@@ -114,6 +118,7 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
|||||||
loadingMessage,
|
loadingMessage,
|
||||||
hasStrategy = true,
|
hasStrategy = true,
|
||||||
className = '',
|
className = '',
|
||||||
|
getParams,
|
||||||
}) => {
|
}) => {
|
||||||
const rows = snapshot?.rows ?? [];
|
const rows = snapshot?.rows ?? [];
|
||||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||||
@@ -143,6 +148,7 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
|||||||
signalActive={barSignalHighlight?.sell ?? false}
|
signalActive={barSignalHighlight?.sell ?? false}
|
||||||
strategy={strategy}
|
strategy={strategy}
|
||||||
barTimeSec={barTimeSec}
|
barTimeSec={barTimeSec}
|
||||||
|
getParams={getParams}
|
||||||
/>
|
/>
|
||||||
<SideCard
|
<SideCard
|
||||||
side="buy"
|
side="buy"
|
||||||
@@ -151,6 +157,7 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
|||||||
signalActive={barSignalHighlight?.buy ?? false}
|
signalActive={barSignalHighlight?.buy ?? false}
|
||||||
strategy={strategy}
|
strategy={strategy}
|
||||||
barTimeSec={barTimeSec}
|
barTimeSec={barTimeSec}
|
||||||
|
getParams={getParams}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1149,9 +1149,9 @@
|
|||||||
height: 26px;
|
height: 26px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--se-border) 85%, transparent);
|
border: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 85%, transparent);
|
||||||
background: color-mix(in srgb, var(--se-input-bg, #121826) 92%, transparent);
|
background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 92%, transparent);
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted, var(--text2));
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -1190,30 +1190,30 @@
|
|||||||
.seval-interpret-content {
|
.seval-interpret-content {
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: var(--se-text);
|
color: var(--se-text, var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-intro {
|
.seval-interpret-intro {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted, var(--text2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-overall {
|
.seval-interpret-overall {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
border: 1px solid var(--se-border, var(--border));
|
||||||
background: color-mix(in srgb, var(--se-input-bg, #121826) 88%, transparent);
|
background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-overall--met {
|
.seval-interpret-overall--met {
|
||||||
border-color: color-mix(in srgb, #3f7ef5 35%, transparent);
|
border-color: color-mix(in srgb, var(--se-accent, #1976d2) 35%, transparent);
|
||||||
background: color-mix(in srgb, #3f7ef5 8%, transparent);
|
background: color-mix(in srgb, var(--se-accent, #1976d2) 8%, var(--bg2, #fff));
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-overall--unmet {
|
.seval-interpret-overall--unmet {
|
||||||
border-color: color-mix(in srgb, var(--se-text-muted) 25%, transparent);
|
border-color: color-mix(in srgb, var(--se-text-muted, var(--text2)) 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-overall-badge {
|
.seval-interpret-overall-badge {
|
||||||
@@ -1227,13 +1227,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-overall--met .seval-interpret-overall-badge {
|
.seval-interpret-overall--met .seval-interpret-overall-badge {
|
||||||
color: #7eb6ff;
|
color: var(--se-accent, #7eb6ff);
|
||||||
background: color-mix(in srgb, #3f7ef5 18%, transparent);
|
background: color-mix(in srgb, var(--se-accent, #3f7ef5) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-overall--unmet .seval-interpret-overall-badge {
|
.seval-interpret-overall--unmet .seval-interpret-overall-badge {
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted, var(--text2));
|
||||||
background: color-mix(in srgb, var(--se-text-muted) 12%, transparent);
|
background: color-mix(in srgb, var(--se-text-muted, var(--text2)) 12%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-overall--unknown .seval-interpret-overall-badge {
|
.seval-interpret-overall--unknown .seval-interpret-overall-badge {
|
||||||
@@ -1248,7 +1248,7 @@
|
|||||||
|
|
||||||
.seval-interpret-empty {
|
.seval-interpret-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted, var(--text2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-list {
|
.seval-interpret-list {
|
||||||
@@ -1263,16 +1263,16 @@
|
|||||||
.seval-interpret-item {
|
.seval-interpret-item {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
border: 1px solid var(--se-border, var(--border));
|
||||||
background: color-mix(in srgb, var(--se-input-bg, #121826) 90%, transparent);
|
background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 90%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-item--match {
|
.seval-interpret-item--match {
|
||||||
border-left: 3px solid #3f7ef5;
|
border-left: 3px solid var(--se-accent, #3f7ef5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-item--nomatch {
|
.seval-interpret-item--nomatch {
|
||||||
border-left: 3px solid color-mix(in srgb, var(--se-text-muted) 55%, #ef5350);
|
border-left: 3px solid color-mix(in srgb, var(--se-text-muted, var(--text2)) 55%, var(--se-danger, #ef5350));
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-item--unknown {
|
.seval-interpret-item--unknown {
|
||||||
@@ -1296,13 +1296,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-status--match {
|
.seval-interpret-status--match {
|
||||||
color: #7eb6ff;
|
color: var(--se-accent, #7eb6ff);
|
||||||
background: color-mix(in srgb, #3f7ef5 16%, transparent);
|
background: color-mix(in srgb, var(--se-accent, #3f7ef5) 16%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-status--nomatch {
|
.seval-interpret-status--nomatch {
|
||||||
color: #ff8a80;
|
color: var(--se-danger, #ff8a80);
|
||||||
background: color-mix(in srgb, #ef5350 14%, transparent);
|
background: color-mix(in srgb, var(--se-danger, #ef5350) 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-status--unknown {
|
.seval-interpret-status--unknown {
|
||||||
@@ -1316,21 +1316,93 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
color: var(--se-text, var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-reason {
|
.seval-interpret-reason {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted, var(--text2));
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-interpret-footnote {
|
.seval-interpret-footnote {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
border-top: 1px solid var(--se-border, var(--border));
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted, var(--text2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* body 포털 모달 — 라이트 테마 (html.theme-light · .app.light) */
|
||||||
|
html.theme-light .seval-interpret-overall,
|
||||||
|
.app.light .seval-interpret-overall,
|
||||||
|
html.theme-light .seval-interpret-item,
|
||||||
|
.app.light .seval-interpret-item {
|
||||||
|
background: var(--bg2, #ffffff);
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-overall--met,
|
||||||
|
.app.light .seval-interpret-overall--met {
|
||||||
|
border-color: rgba(25, 118, 210, 0.35);
|
||||||
|
background: rgba(25, 118, 210, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-overall--met .seval-interpret-overall-badge,
|
||||||
|
.app.light .seval-interpret-overall--met .seval-interpret-overall-badge {
|
||||||
|
color: #1565c0;
|
||||||
|
background: rgba(25, 118, 210, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-overall--unmet .seval-interpret-overall-badge,
|
||||||
|
.app.light .seval-interpret-overall--unmet .seval-interpret-overall-badge {
|
||||||
|
color: #546e7a;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-status--match,
|
||||||
|
.app.light .seval-interpret-status--match {
|
||||||
|
color: #1565c0;
|
||||||
|
background: rgba(25, 118, 210, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-status--nomatch,
|
||||||
|
.app.light .seval-interpret-status--nomatch {
|
||||||
|
color: #c62828;
|
||||||
|
background: rgba(198, 40, 40, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-status--unknown,
|
||||||
|
.app.light .seval-interpret-status--unknown,
|
||||||
|
html.theme-light .seval-interpret-overall--unknown .seval-interpret-overall-badge,
|
||||||
|
.app.light .seval-interpret-overall--unknown .seval-interpret-overall-badge {
|
||||||
|
color: #e65100;
|
||||||
|
background: rgba(245, 127, 23, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-item--match,
|
||||||
|
.app.light .seval-interpret-item--match {
|
||||||
|
border-left-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-interpret-item--nomatch,
|
||||||
|
.app.light .seval-interpret-item--nomatch {
|
||||||
|
border-left-color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-analyze-btn,
|
||||||
|
.app.light .seval-analyze-btn {
|
||||||
|
background: var(--bg2, #ffffff);
|
||||||
|
border-color: rgba(0, 0, 0, 0.12);
|
||||||
|
color: var(--text2, #757575);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-light .seval-analyze-btn:hover:not(:disabled),
|
||||||
|
.app.light .seval-analyze-btn:hover:not(:disabled) {
|
||||||
|
color: #1565c0;
|
||||||
|
border-color: rgba(25, 118, 210, 0.45);
|
||||||
|
background: rgba(25, 118, 210, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* MA/EMA DSL 필드 — MA1~MA11 슬롯 ↔ SMA period1~11 (보조지표 설정과 동일)
|
||||||
|
*
|
||||||
|
* - MA3 → period3 (예: 20일)
|
||||||
|
* - MA20 → 레거시: 숫자>11 이면 기간 20일 그대로
|
||||||
|
*/
|
||||||
|
import { SMA_DEFAULT_PERIODS, SMA_MA_COUNT } from './smaConfig';
|
||||||
|
|
||||||
|
export const MA_DSL_SLOT_MAX = SMA_MA_COUNT;
|
||||||
|
|
||||||
|
export function buildMaLinesFromSmaParams(
|
||||||
|
params?: Record<string, number | string | boolean>,
|
||||||
|
): number[] {
|
||||||
|
const lines: number[] = [];
|
||||||
|
for (let slot = 1; slot <= SMA_MA_COUNT; slot++) {
|
||||||
|
const raw = params?.[`period${slot}`];
|
||||||
|
const p = typeof raw === 'number' && raw > 0
|
||||||
|
? Math.floor(raw)
|
||||||
|
: SMA_DEFAULT_PERIODS[slot - 1];
|
||||||
|
lines.push(p);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DSL MA/EMA 필드 → 실제 SMA/EMA 계산 기간 */
|
||||||
|
export function resolveMaDslFieldPeriod(
|
||||||
|
field: string | undefined,
|
||||||
|
smaParams?: Record<string, number | string | boolean>,
|
||||||
|
): number | null {
|
||||||
|
if (!field) return null;
|
||||||
|
const ma = /^MA(\d+)$/.exec(field);
|
||||||
|
if (ma && !field.startsWith('MACD')) {
|
||||||
|
const n = parseInt(ma[1], 10);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
|
if (n <= MA_DSL_SLOT_MAX) return buildMaLinesFromSmaParams(smaParams)[n - 1];
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
const ema = /^EMA(\d+)$/.exec(field);
|
||||||
|
if (ema) {
|
||||||
|
const n = parseInt(ema[1], 10);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
|
if (n <= MA_DSL_SLOT_MAX) return buildMaLinesFromSmaParams(smaParams)[n - 1];
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI·해석용 라벨 — MA5(60일) */
|
||||||
|
export function formatMaDslFieldLabel(
|
||||||
|
field: string | undefined,
|
||||||
|
smaParams?: Record<string, number | string | boolean>,
|
||||||
|
): string {
|
||||||
|
if (!field) return '';
|
||||||
|
const period = resolveMaDslFieldPeriod(field, smaParams);
|
||||||
|
if (period == null) return field;
|
||||||
|
const ma = /^MA(\d+)$/.exec(field);
|
||||||
|
if (ma && !field.startsWith('MACD')) {
|
||||||
|
const n = parseInt(ma[1], 10);
|
||||||
|
if (n >= 1 && n <= MA_DSL_SLOT_MAX) return `MA${n}(${period}일)`;
|
||||||
|
return `MA(${period}일)`;
|
||||||
|
}
|
||||||
|
const ema = /^EMA(\d+)$/.exec(field);
|
||||||
|
if (ema) {
|
||||||
|
const n = parseInt(ema[1], 10);
|
||||||
|
if (n >= 1 && n <= MA_DSL_SLOT_MAX) return `EMA${n}(${period}일)`;
|
||||||
|
return `EMA(${period}일)`;
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMaFieldOptions(
|
||||||
|
maLines: number[],
|
||||||
|
): { value: string; label: string }[] {
|
||||||
|
return Array.from({ length: SMA_MA_COUNT }, (_, i) => {
|
||||||
|
const slot = i + 1;
|
||||||
|
const p = maLines[i] ?? SMA_DEFAULT_PERIODS[i];
|
||||||
|
return { value: `MA${slot}`, label: `MA${slot}(${p}일)` };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmaFieldOptions(
|
||||||
|
maLines: number[],
|
||||||
|
maxSlots = 4,
|
||||||
|
): { value: string; label: string }[] {
|
||||||
|
const n = Math.min(maxSlots, SMA_MA_COUNT);
|
||||||
|
return Array.from({ length: n }, (_, i) => {
|
||||||
|
const slot = i + 1;
|
||||||
|
const p = maLines[i] ?? SMA_DEFAULT_PERIODS[i];
|
||||||
|
return { value: `EMA${slot}`, label: `EMA${slot}(${p}일)` };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레거시 MA20·MA60(기간 인코딩) 옵션 유지 */
|
||||||
|
export function appendLegacyMaFieldOption(
|
||||||
|
opts: { value: string; label: string }[],
|
||||||
|
field: string | undefined,
|
||||||
|
): { value: string; label: string }[] {
|
||||||
|
if (!field || opts.some(o => o.value === field)) return opts;
|
||||||
|
const period = resolveMaDslFieldPeriod(field);
|
||||||
|
if (period == null) return opts;
|
||||||
|
const ma = /^MA(\d+)$/.exec(field);
|
||||||
|
if (ma && !field.startsWith('MACD')) {
|
||||||
|
const n = parseInt(ma[1], 10);
|
||||||
|
if (n > MA_DSL_SLOT_MAX) {
|
||||||
|
return [...opts, { value: field, label: `MA(${period}일)` }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ema = /^EMA(\d+)$/.exec(field);
|
||||||
|
if (ema) {
|
||||||
|
const n = parseInt(ema[1], 10);
|
||||||
|
if (n > MA_DSL_SLOT_MAX) {
|
||||||
|
return [...opts, { value: field, label: `EMA(${period}일)` }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
candleRangeWindowBars,
|
candleRangeWindowBars,
|
||||||
formatCandleRangeClause,
|
formatCandleRangeClause,
|
||||||
} from '../utils/strategyTypes';
|
} from '../utils/strategyTypes';
|
||||||
|
import {
|
||||||
|
appendLegacyMaFieldOption,
|
||||||
|
buildEmaFieldOptions,
|
||||||
|
buildMaFieldOptions,
|
||||||
|
} from './maDslField';
|
||||||
import {
|
import {
|
||||||
COMPOSITE_INDICATOR_ITEMS,
|
COMPOSITE_INDICATOR_ITEMS,
|
||||||
compositeDisplayName,
|
compositeDisplayName,
|
||||||
@@ -136,7 +141,7 @@ export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ };
|
|||||||
interface HLThresh { over?: number; mid?: number; under?: number; }
|
interface HLThresh { over?: number; mid?: number; under?: number; }
|
||||||
|
|
||||||
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
|
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
|
||||||
const DEF_DEFAULTS = {
|
export const DEF_DEFAULTS = {
|
||||||
rsiPeriod: 9,
|
rsiPeriod: 9,
|
||||||
macdFast: 12, macdSlow: 26, macdSignal: 9,
|
macdFast: 12, macdSlow: 26, macdSignal: 9,
|
||||||
cciPeriod: 13,
|
cciPeriod: 13,
|
||||||
@@ -347,6 +352,23 @@ export function buildStrategyEditorDefFromSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated buildStrategyEditorDefFromSettings 사용 — 하드코딩 폴백 */
|
/** @deprecated buildStrategyEditorDefFromSettings 사용 — 하드코딩 폴백 */
|
||||||
|
export function buildDefFromGetParams(
|
||||||
|
getParams: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||||
|
): DefType {
|
||||||
|
const types = [
|
||||||
|
'RSI', 'MACD', 'CCI', 'Stochastic', 'ADX', 'TRIX', 'DMI', 'BollingerBands',
|
||||||
|
'IchimokuCloud', 'WilliamsPercentRange', 'VolumeOscillator', 'VR', 'Disparity',
|
||||||
|
'Psychological', 'NewPsychological', 'InvestPsychological', 'OBV', 'SMA', 'EMA',
|
||||||
|
] as const;
|
||||||
|
const configs: IndicatorConfig[] = types.map(type => ({
|
||||||
|
id: `eval-def-${type}`,
|
||||||
|
type,
|
||||||
|
params: getParams(type, getIndicatorDef(type)?.defaultParams as Record<string, number | string | boolean>),
|
||||||
|
plots: getIndicatorDef(type)?.plots ?? [],
|
||||||
|
}));
|
||||||
|
return buildDef(configs);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStrategyEditorDef(): DefType {
|
export function buildStrategyEditorDef(): DefType {
|
||||||
return buildDef([]);
|
return buildDef([]);
|
||||||
}
|
}
|
||||||
@@ -497,16 +519,26 @@ export const getFieldOpts = (
|
|||||||
{ value:'VOLUME_VALUE', label:'거래량' },
|
{ value:'VOLUME_VALUE', label:'거래량' },
|
||||||
{ value:'VOLUME_MA', label:'거래량 이동평균' },
|
{ value:'VOLUME_MA', label:'거래량 이동평균' },
|
||||||
]);
|
]);
|
||||||
case 'MA': return [
|
case 'MA': {
|
||||||
NONE,
|
let opts = [
|
||||||
...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })),
|
NONE,
|
||||||
{ value:'CLOSE_PRICE', label:'종가' },
|
...buildMaFieldOptions(DEF.maLines),
|
||||||
];
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
case 'EMA': return [
|
];
|
||||||
NONE,
|
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
|
||||||
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
|
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
|
||||||
{ value:'CLOSE_PRICE', label:'종가' },
|
return opts;
|
||||||
];
|
}
|
||||||
|
case 'EMA': {
|
||||||
|
let opts = [
|
||||||
|
NONE,
|
||||||
|
...buildEmaFieldOptions(DEF.maLines, 4),
|
||||||
|
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||||
|
];
|
||||||
|
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
|
||||||
|
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
case 'DISPARITY': {
|
case 'DISPARITY': {
|
||||||
const { mid } = th({ mid: 100 });
|
const { mid } = th({ mid: 100 });
|
||||||
return condOpts([
|
return condOpts([
|
||||||
@@ -667,8 +699,8 @@ const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: D
|
|||||||
DMI: { l:'PDI', r:'MDI' },
|
DMI: { l:'PDI', r:'MDI' },
|
||||||
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
||||||
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
||||||
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
|
MA: { l: 'MA1', r: 'MA2' },
|
||||||
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
|
EMA: { l: 'EMA1', r: 'EMA2' },
|
||||||
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID },
|
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID },
|
||||||
PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
|
PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
|
||||||
NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: THRESHOLD_HL_OVER },
|
NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: THRESHOLD_HL_OVER },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
formatStrategyThreshold,
|
formatStrategyThreshold,
|
||||||
type ConditionMetric,
|
type ConditionMetric,
|
||||||
} from './virtualSignalMetrics';
|
} from './virtualSignalMetrics';
|
||||||
import { nodeToText } from './strategyEditorShared';
|
import { nodeToText, buildDefFromGetParams, type DefType, DEF_DEFAULTS } from './strategyEditorShared';
|
||||||
|
|
||||||
export interface ConditionInterpretContext {
|
export interface ConditionInterpretContext {
|
||||||
condition: ConditionDSL;
|
condition: ConditionDSL;
|
||||||
@@ -49,23 +49,24 @@ function walkContext(
|
|||||||
side: 'buy' | 'sell',
|
side: 'buy' | 'sell',
|
||||||
out: Map<string, ConditionInterpretContext>,
|
out: Map<string, ConditionInterpretContext>,
|
||||||
seen: Set<string>,
|
seen: Set<string>,
|
||||||
|
def: DefType,
|
||||||
): void {
|
): void {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
if (node.type === 'TIMEFRAME') {
|
if (node.type === 'TIMEFRAME') {
|
||||||
const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe);
|
const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe);
|
||||||
node.children?.forEach(c => walkContext(c, tf, side, out, seen));
|
node.children?.forEach(c => walkContext(c, tf, side, out, seen, def));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'NOT') {
|
if (node.type === 'NOT') {
|
||||||
const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
|
const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
|
||||||
if (child) walkContext(child, timeframe, side, out, seen);
|
if (child) walkContext(child, timeframe, side, out, seen, def);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'AND' || node.type === 'OR') {
|
if (node.type === 'AND' || node.type === 'OR') {
|
||||||
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen));
|
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,22 +78,24 @@ function walkContext(
|
|||||||
seen.add(rowId);
|
seen.add(rowId);
|
||||||
out.set(rowId, {
|
out.set(rowId, {
|
||||||
condition: c,
|
condition: c,
|
||||||
summary: nodeToText(node),
|
summary: nodeToText(node, def),
|
||||||
timeframe: rowTf,
|
timeframe: rowTf,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen));
|
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildConditionContextMap(
|
export function buildConditionContextMap(
|
||||||
strategy: StrategyDto | null | undefined,
|
strategy: StrategyDto | null | undefined,
|
||||||
|
getParams?: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||||
): Map<string, ConditionInterpretContext> {
|
): Map<string, ConditionInterpretContext> {
|
||||||
|
const def = getParams ? buildDefFromGetParams(getParams) : DEF_DEFAULTS;
|
||||||
const out = new Map<string, ConditionInterpretContext>();
|
const out = new Map<string, ConditionInterpretContext>();
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen);
|
walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen, def);
|
||||||
walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen);
|
walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen, def);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user