전략평가 MA 참조에러 수정
This commit is contained in:
@@ -500,7 +500,7 @@ public class LiveConditionStatusService {
|
||||
.displayName(indType)
|
||||
.conditionType(condType)
|
||||
.conditionLabel(condLabel)
|
||||
.thresholdLabel(formatThresholdStatic(c, target))
|
||||
.thresholdLabel(formatThresholdStatic(c, target, visual))
|
||||
.currentValue(null)
|
||||
.targetValue(target)
|
||||
.satisfied(null)
|
||||
@@ -510,7 +510,8 @@ public class LiveConditionStatusService {
|
||||
.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()) {
|
||||
String ct = cond.path("conditionType").asText("GT");
|
||||
String v = formatNum(target);
|
||||
@@ -525,7 +526,7 @@ public class LiveConditionStatusService {
|
||||
}
|
||||
String rf = cond.path("rightField").asText("");
|
||||
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
|
||||
return rf.replace('_', ' ');
|
||||
return adapter.formatMovingAverageFieldLabel(rf, params);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
@@ -576,16 +577,17 @@ public class LiveConditionStatusService {
|
||||
Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false);
|
||||
if (rightLive != null && !rightLive.isNaN()) {
|
||||
String v = formatNum(rightLive);
|
||||
String fieldLabel = adapter.formatMovingAverageFieldLabel(rf, params);
|
||||
return switch (ct) {
|
||||
case "LT", "CROSS_DOWN" -> "< " + v;
|
||||
case "GT", "CROSS_UP" -> "> " + v;
|
||||
case "GTE" -> "≥ " + v;
|
||||
case "LTE" -> "≤ " + v;
|
||||
case "EQ" -> "= " + v;
|
||||
default -> rf.replace('_', ' ') + " (" + v + ")";
|
||||
default -> fieldLabel + " (" + v + ")";
|
||||
};
|
||||
}
|
||||
return rf.replace('_', ' ');
|
||||
return adapter.formatMovingAverageFieldLabel(rf, params);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
@@ -138,6 +138,13 @@ public class StrategyDslToTa4jAdapter {
|
||||
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() {
|
||||
return seriesOverrides != null && !seriesOverrides.isEmpty();
|
||||
@@ -233,7 +240,9 @@ public class StrategyDslToTa4jAdapter {
|
||||
: Map.of();
|
||||
|
||||
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);
|
||||
return v != null ? v.doubleValue() : null;
|
||||
} catch (Exception e) {
|
||||
@@ -722,7 +731,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
case "VOLUME_VALUE" -> new VolumeIndicator(s, 1);
|
||||
default -> {
|
||||
Double hlineVal = IndicatorHlineResolver.resolveThresholdField(
|
||||
cond, field, indType, ctx.indicatorVisual());
|
||||
cond, field, indType, ctx != null ? ctx.indicatorVisual() : Map.of());
|
||||
if (hlineVal != null) {
|
||||
yield new ConstantIndicator<>(s, s.numFactory().numOf(hlineVal));
|
||||
}
|
||||
@@ -730,7 +739,8 @@ public class StrategyDslToTa4jAdapter {
|
||||
if (!Double.isNaN(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,
|
||||
Map<String, Object> p, BarSeries s,
|
||||
Map<String, Object> p,
|
||||
Map<String, Object> smaParams,
|
||||
BarSeries s,
|
||||
int condPeriod, int sidePeriod) {
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
||||
int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1);
|
||||
@@ -943,12 +955,12 @@ public class StrategyDslToTa4jAdapter {
|
||||
return new LowestValueIndicator(close, period);
|
||||
}
|
||||
if (field.startsWith("MA") && !field.startsWith("MACD")) {
|
||||
try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); }
|
||||
catch (NumberFormatException ignored) { /* fall */ }
|
||||
int period = resolveMovingAveragePeriod(field, "MA", smaParams);
|
||||
if (period > 0) return new SMAIndicator(close, period);
|
||||
}
|
||||
if (field.startsWith("EMA")) {
|
||||
try { return new EMAIndicator(close, Integer.parseInt(field.substring(3))); }
|
||||
catch (NumberFormatException ignored) { /* fall */ }
|
||||
int period = resolveMovingAveragePeriod(field, "EMA", smaParams);
|
||||
if (period > 0) return new EMAIndicator(close, period);
|
||||
}
|
||||
// 일목균형표
|
||||
if (field.equals("CONVERSION_LINE")) return ichimokuTenkan(s, p);
|
||||
@@ -1304,6 +1316,61 @@ public class StrategyDslToTa4jAdapter {
|
||||
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) {
|
||||
|
||||
@@ -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}
|
||||
hasStrategy={selectedStrategyId != null}
|
||||
className="seval-signal-panel"
|
||||
getParams={getEvalParams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
candleRangeWindowBars,
|
||||
formatCandleRangeClause,
|
||||
} from '../utils/strategyTypes';
|
||||
import {
|
||||
appendLegacyMaFieldOption,
|
||||
buildEmaFieldOptions,
|
||||
buildMaFieldOptions,
|
||||
} from '../utils/maDslField';
|
||||
|
||||
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||
@@ -298,7 +303,7 @@ const ICHIMOKU_CONDS = [
|
||||
type Opt = { value: string; label: string };
|
||||
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 underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
||||
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_MA', label:'거래량 이동평균' },
|
||||
]);
|
||||
case 'MA': return [
|
||||
case 'MA': {
|
||||
let opts = [
|
||||
NONE,
|
||||
...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })),
|
||||
...buildMaFieldOptions(DEF.maLines),
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
];
|
||||
case 'EMA': return [
|
||||
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
|
||||
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
|
||||
return opts;
|
||||
}
|
||||
case 'EMA': {
|
||||
let opts = [
|
||||
NONE,
|
||||
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
|
||||
...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([
|
||||
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
|
||||
{ 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' },
|
||||
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
||||
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
||||
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
|
||||
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
|
||||
MA: { l: 'MA1', r: 'MA2' },
|
||||
EMA: { l: 'EMA1', r: 'EMA2' },
|
||||
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) },
|
||||
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
|
||||
NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: over(50) },
|
||||
@@ -538,7 +553,7 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
|
||||
// conditionType 변경 시 자동 필드 조정
|
||||
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
||||
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 def = getDefaultFields(cond.indicatorType, 'buy', DEF);
|
||||
|
||||
@@ -575,7 +590,7 @@ const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
|
||||
if (!node) return '';
|
||||
if (node.type === 'CONDITION' && 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 R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
|
||||
const C = condLabel(c.conditionType);
|
||||
@@ -680,7 +695,7 @@ interface CondEditorProps {
|
||||
}
|
||||
|
||||
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 isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||
|
||||
@@ -23,6 +23,8 @@ interface Props {
|
||||
loadingMessage?: string;
|
||||
hasStrategy?: boolean;
|
||||
className?: string;
|
||||
/** 보조지표 SMA period1~11 — MA 슬롯 라벨·해석용 */
|
||||
getParams?: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
||||
}
|
||||
|
||||
interface SideCardProps {
|
||||
@@ -32,6 +34,7 @@ interface SideCardProps {
|
||||
signalActive?: boolean;
|
||||
strategy?: StrategyDto | null;
|
||||
barTimeSec?: number | null;
|
||||
getParams?: Props['getParams'];
|
||||
}
|
||||
|
||||
const SideCard: React.FC<SideCardProps> = ({
|
||||
@@ -41,6 +44,7 @@ const SideCard: React.FC<SideCardProps> = ({
|
||||
signalActive = false,
|
||||
strategy,
|
||||
barTimeSec,
|
||||
getParams,
|
||||
}) => {
|
||||
const [interpretOpen, setInterpretOpen] = useState(false);
|
||||
const sideLabel = side === 'buy' ? '매수' : '매도';
|
||||
@@ -48,7 +52,7 @@ const SideCard: React.FC<SideCardProps> = ({
|
||||
const overallLabel =
|
||||
overallMet === true ? '충족' : overallMet === false ? '미충족' : null;
|
||||
|
||||
const contextMap = useMemo(() => buildConditionContextMap(strategy), [strategy]);
|
||||
const contextMap = useMemo(() => buildConditionContextMap(strategy, getParams), [strategy, getParams]);
|
||||
const interpretation = useMemo(
|
||||
() => buildSideInterpretation(side, metrics, overallMet, contextMap),
|
||||
[side, metrics, overallMet, contextMap],
|
||||
@@ -114,6 +118,7 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
||||
loadingMessage,
|
||||
hasStrategy = true,
|
||||
className = '',
|
||||
getParams,
|
||||
}) => {
|
||||
const rows = snapshot?.rows ?? [];
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
@@ -143,6 +148,7 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
||||
signalActive={barSignalHighlight?.sell ?? false}
|
||||
strategy={strategy}
|
||||
barTimeSec={barTimeSec}
|
||||
getParams={getParams}
|
||||
/>
|
||||
<SideCard
|
||||
side="buy"
|
||||
@@ -151,6 +157,7 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
||||
signalActive={barSignalHighlight?.buy ?? false}
|
||||
strategy={strategy}
|
||||
barTimeSec={barTimeSec}
|
||||
getParams={getParams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1149,9 +1149,9 @@
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-border) 85%, transparent);
|
||||
background: color-mix(in srgb, var(--se-input-bg, #121826) 92%, transparent);
|
||||
color: var(--se-text-muted);
|
||||
border: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 85%, transparent);
|
||||
background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 92%, transparent);
|
||||
color: var(--se-text-muted, var(--text2));
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -1190,30 +1190,30 @@
|
||||
.seval-interpret-content {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.65;
|
||||
color: var(--se-text);
|
||||
color: var(--se-text, var(--text));
|
||||
}
|
||||
|
||||
.seval-interpret-intro {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--se-text-muted);
|
||||
color: var(--se-text-muted, var(--text2));
|
||||
}
|
||||
|
||||
.seval-interpret-overall {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--se-input-bg, #121826) 88%, transparent);
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 88%, transparent);
|
||||
}
|
||||
|
||||
.seval-interpret-overall--met {
|
||||
border-color: color-mix(in srgb, #3f7ef5 35%, transparent);
|
||||
background: color-mix(in srgb, #3f7ef5 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--se-accent, #1976d2) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--se-accent, #1976d2) 8%, var(--bg2, #fff));
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -1227,13 +1227,13 @@
|
||||
}
|
||||
|
||||
.seval-interpret-overall--met .seval-interpret-overall-badge {
|
||||
color: #7eb6ff;
|
||||
background: color-mix(in srgb, #3f7ef5 18%, transparent);
|
||||
color: var(--se-accent, #7eb6ff);
|
||||
background: color-mix(in srgb, var(--se-accent, #3f7ef5) 18%, transparent);
|
||||
}
|
||||
|
||||
.seval-interpret-overall--unmet .seval-interpret-overall-badge {
|
||||
color: var(--se-text-muted);
|
||||
background: color-mix(in srgb, var(--se-text-muted) 12%, transparent);
|
||||
color: var(--se-text-muted, var(--text2));
|
||||
background: color-mix(in srgb, var(--se-text-muted, var(--text2)) 12%, transparent);
|
||||
}
|
||||
|
||||
.seval-interpret-overall--unknown .seval-interpret-overall-badge {
|
||||
@@ -1248,7 +1248,7 @@
|
||||
|
||||
.seval-interpret-empty {
|
||||
margin: 0;
|
||||
color: var(--se-text-muted);
|
||||
color: var(--se-text-muted, var(--text2));
|
||||
}
|
||||
|
||||
.seval-interpret-list {
|
||||
@@ -1263,16 +1263,16 @@
|
||||
.seval-interpret-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--se-input-bg, #121826) 90%, transparent);
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--se-input-bg, var(--input-bg, var(--bg2))) 90%, transparent);
|
||||
}
|
||||
|
||||
.seval-interpret-item--match {
|
||||
border-left: 3px solid #3f7ef5;
|
||||
border-left: 3px solid var(--se-accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -1296,13 +1296,13 @@
|
||||
}
|
||||
|
||||
.seval-interpret-status--match {
|
||||
color: #7eb6ff;
|
||||
background: color-mix(in srgb, #3f7ef5 16%, transparent);
|
||||
color: var(--se-accent, #7eb6ff);
|
||||
background: color-mix(in srgb, var(--se-accent, #3f7ef5) 16%, transparent);
|
||||
}
|
||||
|
||||
.seval-interpret-status--nomatch {
|
||||
color: #ff8a80;
|
||||
background: color-mix(in srgb, #ef5350 14%, transparent);
|
||||
color: var(--se-danger, #ff8a80);
|
||||
background: color-mix(in srgb, var(--se-danger, #ef5350) 14%, transparent);
|
||||
}
|
||||
|
||||
.seval-interpret-status--unknown {
|
||||
@@ -1316,21 +1316,93 @@
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
color: var(--se-text, var(--text));
|
||||
}
|
||||
|
||||
.seval-interpret-reason {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--se-text-muted);
|
||||
color: var(--se-text-muted, var(--text2));
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.seval-interpret-footnote {
|
||||
margin: 14px 0 0;
|
||||
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;
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
formatCandleRangeClause,
|
||||
} from '../utils/strategyTypes';
|
||||
import {
|
||||
appendLegacyMaFieldOption,
|
||||
buildEmaFieldOptions,
|
||||
buildMaFieldOptions,
|
||||
} from './maDslField';
|
||||
import {
|
||||
COMPOSITE_INDICATOR_ITEMS,
|
||||
compositeDisplayName,
|
||||
@@ -136,7 +141,7 @@ export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ };
|
||||
interface HLThresh { over?: number; mid?: number; under?: number; }
|
||||
|
||||
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
|
||||
const DEF_DEFAULTS = {
|
||||
export const DEF_DEFAULTS = {
|
||||
rsiPeriod: 9,
|
||||
macdFast: 12, macdSlow: 26, macdSignal: 9,
|
||||
cciPeriod: 13,
|
||||
@@ -347,6 +352,23 @@ export function 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 {
|
||||
return buildDef([]);
|
||||
}
|
||||
@@ -497,16 +519,26 @@ export const getFieldOpts = (
|
||||
{ value:'VOLUME_VALUE', label:'거래량' },
|
||||
{ value:'VOLUME_MA', label:'거래량 이동평균' },
|
||||
]);
|
||||
case 'MA': return [
|
||||
case 'MA': {
|
||||
let opts = [
|
||||
NONE,
|
||||
...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })),
|
||||
...buildMaFieldOptions(DEF.maLines),
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
];
|
||||
case 'EMA': return [
|
||||
if (cond?.leftField) opts = appendLegacyMaFieldOption(opts, cond.leftField);
|
||||
if (cond?.rightField) opts = appendLegacyMaFieldOption(opts, cond.rightField);
|
||||
return opts;
|
||||
}
|
||||
case 'EMA': {
|
||||
let opts = [
|
||||
NONE,
|
||||
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
|
||||
...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': {
|
||||
const { mid } = th({ mid: 100 });
|
||||
return condOpts([
|
||||
@@ -667,8 +699,8 @@ const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: D
|
||||
DMI: { l:'PDI', r:'MDI' },
|
||||
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
||||
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
||||
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
|
||||
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
|
||||
MA: { l: 'MA1', r: 'MA2' },
|
||||
EMA: { l: 'EMA1', r: 'EMA2' },
|
||||
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID },
|
||||
PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
|
||||
NEW_PSYCHOLOGICAL: { l:'NEW_PSY_VALUE', r: THRESHOLD_HL_OVER },
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
formatStrategyThreshold,
|
||||
type ConditionMetric,
|
||||
} from './virtualSignalMetrics';
|
||||
import { nodeToText } from './strategyEditorShared';
|
||||
import { nodeToText, buildDefFromGetParams, type DefType, DEF_DEFAULTS } from './strategyEditorShared';
|
||||
|
||||
export interface ConditionInterpretContext {
|
||||
condition: ConditionDSL;
|
||||
@@ -49,23 +49,24 @@ function walkContext(
|
||||
side: 'buy' | 'sell',
|
||||
out: Map<string, ConditionInterpretContext>,
|
||||
seen: Set<string>,
|
||||
def: DefType,
|
||||
): void {
|
||||
if (!node) return;
|
||||
|
||||
if (node.type === '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;
|
||||
}
|
||||
|
||||
if (node.type === 'NOT') {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -77,22 +78,24 @@ function walkContext(
|
||||
seen.add(rowId);
|
||||
out.set(rowId, {
|
||||
condition: c,
|
||||
summary: nodeToText(node),
|
||||
summary: nodeToText(node, def),
|
||||
timeframe: rowTf,
|
||||
});
|
||||
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(
|
||||
strategy: StrategyDto | null | undefined,
|
||||
getParams?: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
): Map<string, ConditionInterpretContext> {
|
||||
const def = getParams ? buildDefFromGetParams(getParams) : DEF_DEFAULTS;
|
||||
const out = new Map<string, ConditionInterpretContext>();
|
||||
const seen = new Set<string>();
|
||||
walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen);
|
||||
walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen);
|
||||
walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen, def);
|
||||
walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen, def);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user