전략평가 오류 수정

This commit is contained in:
Macbook
2026-06-16 00:30:25 +09:00
parent db3513a63b
commit 133e3d0413
8 changed files with 176 additions and 54 deletions
@@ -49,21 +49,27 @@ public class BacktestingController {
} }
long userId = TradingControllerSupport.requireRegisteredUser(headers); long userId = TradingControllerSupport.requireRegisteredUser(headers);
String deviceId = headers.get("x-device-id");
if (deviceId == null || deviceId.isBlank()) {
deviceId = req.getDeviceId();
}
req.setUserId(userId);
req.setDeviceId(deviceId);
java.util.Map<String, java.util.Map<String, Object>> dbParams = java.util.Map<String, java.util.Map<String, Object>> dbParams =
indicatorSettingsService.getAll(userId, null); indicatorSettingsService.getAll(userId, deviceId);
if (!dbParams.isEmpty()) { java.util.Map<String, java.util.Map<String, Object>> merged =
java.util.Map<String, java.util.Map<String, Object>> merged = new java.util.HashMap<>(dbParams);
new java.util.HashMap<>(dbParams); if (req.getIndicatorParams() != null) {
if (req.getIndicatorParams() != null) { req.getIndicatorParams().forEach((type, params) ->
req.getIndicatorParams().forEach((type, params) -> merged.merge(type, params, (dbP, reqP) -> {
merged.merge(type, params, (dbP, reqP) -> { java.util.Map<String, Object> m = new java.util.HashMap<>(dbP);
java.util.Map<String, Object> m = new java.util.HashMap<>(dbP); m.putAll(reqP);
m.putAll(reqP); return m;
return m; })
}) );
); }
} if (!merged.isEmpty()) {
req.setIndicatorParams(merged); req.setIndicatorParams(merged);
} }
@@ -63,4 +63,7 @@ public class BacktestRequest {
/** 디바이스 ID (결과 저장용) */ /** 디바이스 ID (결과 저장용) */
private String deviceId; private String deviceId;
/** 인증 사용자 ID — 컨트롤러에서 설정, 지표 설정 조회용 */
private Long userId;
} }
@@ -77,13 +77,16 @@ public class BacktestingService {
} }
} }
// DB 저장 인디케이터 설정을 기본으로 로드 (디바이스별 저장값 → 하드코딩 기본값 방지) // DB 저장 인디케이터 설정 — live-conditions/evaluate 와 동일 (userId + deviceId)
Map<String, Map<String, Object>> dbParams = Map.of(); Map<String, Map<String, Object>> dbParams = Map.of();
Map<String, Map<String, Object>> visual = Map.of();
if (req.getDeviceId() != null && !req.getDeviceId().isBlank()) { if (req.getDeviceId() != null && !req.getDeviceId().isBlank()) {
try { try {
dbParams = indicatorSettingsService.getAll(null, req.getDeviceId()); dbParams = indicatorSettingsService.getAll(req.getUserId(), req.getDeviceId());
visual = indicatorSettingsService.getAllVisual(req.getUserId(), req.getDeviceId());
} catch (Exception e) { } catch (Exception e) {
log.warn("[Backtest] 인디케이터 설정 DB 로드 실패 (deviceId={}): {}", req.getDeviceId(), e.getMessage()); log.warn("[Backtest] 인디케이터 설정 DB 로드 실패 (userId={}, deviceId={}): {}",
req.getUserId(), req.getDeviceId(), e.getMessage());
} }
} }
// 요청에 포함된 파라미터가 있으면 DB 값 위에 덮어씀 (요청값 우선) // 요청에 포함된 파라미터가 있으면 DB 값 위에 덮어씀 (요청값 우선)
@@ -111,9 +114,12 @@ public class BacktestingService {
Map<String, BarSeries> seriesOverrides = buildSeriesOverrides( Map<String, BarSeries> seriesOverrides = buildSeriesOverrides(
series, primaryTf, buyDsl, sellDsl); series, primaryTf, buyDsl, sellDsl);
Rule entryRule = adapter.toRule(buyDsl, series, params, seriesOverrides); StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, null, null, false, seriesOverrides);
Rule entryRule = adapter.toRule(buyDsl, ruleCtx);
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
? adapter.toRule(sellDsl, series, params, seriesOverrides) ? adapter.toRule(sellDsl, ruleCtx)
: new BooleanRule(false); : new BooleanRule(false);
Rule exitRule = buildExitRule(baseExitRule, series, cfg); Rule exitRule = buildExitRule(baseExitRule, series, cfg);
@@ -199,23 +205,21 @@ public class BacktestingService {
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
long time = barStartEpoch(series, i); long time = barStartEpoch(series, i);
// ── SIGNAL_ONLY: 조건 충족 '시작' 봉만 시그널 (GTE 유지형 연속 중복 방지) ── // ── SIGNAL_ONLY: 조건 충족 봉마다 시그널 (live-conditions 충족과 동일 기준) ──
if (signalOnly) { if (signalOnly) {
boolean entrySatisfied = entryRule.isSatisfied(i); boolean entrySatisfied = entryRule.isSatisfied(i);
boolean exitSatisfied = exitRule.isSatisfied(i); boolean exitSatisfied = exitRule.isSatisfied(i);
boolean entryEdge = entrySatisfied && (i <= loopStart || !entryRule.isSatisfied(i - 1)); if (entrySatisfied) {
boolean exitEdge = exitSatisfied && (i <= loopStart || !exitRule.isSatisfied(i - 1));
if (entryEdge) {
double effEntry = applySlippage(closePrice, cfg, true); double effEntry = applySlippage(closePrice, cfg, true);
String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
signals.add(buildFillSignal(time, buyType, effEntry, i, 1.0)); signals.add(buildFillSignal(time, buyType, effEntry, i, 1.0));
log.debug("[Backtest:SIGNAL_ONLY] BUY(edge) @ bar={} price={}", i, effEntry); log.debug("[Backtest:SIGNAL_ONLY] BUY @ bar={} price={}", i, effEntry);
} }
if (exitEdge) { if (exitSatisfied) {
double effExit = applySlippage(exitPrice, cfg, false); double effExit = applySlippage(exitPrice, cfg, false);
String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(buildFillSignal(time, sellType, effExit, i, 1.0)); signals.add(buildFillSignal(time, sellType, effExit, i, 1.0));
log.debug("[Backtest:SIGNAL_ONLY] SELL(edge) @ bar={} price={}", i, effExit); log.debug("[Backtest:SIGNAL_ONLY] SELL @ bar={} price={}", i, effExit);
} }
continue; continue;
} }
@@ -57,8 +57,8 @@ public class StrategySignalDeterminer {
int index, String positionMode) { int index, String positionMode) {
try { try {
if ("SIGNAL_ONLY".equals(positionMode)) { if ("SIGNAL_ONLY".equals(positionMode)) {
boolean enterOk = entryRule != null && isRisingEdge(entryRule, index); boolean enterOk = entryRule != null && entryRule.isSatisfied(index);
boolean exitOk = exitRule != null && isRisingEdge(exitRule, index); boolean exitOk = exitRule != null && exitRule.isSatisfied(index);
if (enterOk) return "BUY"; if (enterOk) return "BUY";
if (exitOk) return "SELL"; if (exitOk) return "SELL";
return "NONE"; return "NONE";
@@ -84,15 +84,10 @@ public class StrategySignalDeterminer {
} }
private String determineSignalOnly(Strategy strategy, int index) { private String determineSignalOnly(Strategy strategy, int index) {
if (isRisingEdge(strategy.getEntryRule(), index)) return "BUY"; Rule entry = strategy.getEntryRule();
if (isRisingEdge(strategy.getExitRule(), index)) return "SELL"; Rule exit = strategy.getExitRule();
if (entry != null && entry.isSatisfied(index)) return "BUY";
if (exit != null && exit.isSatisfied(index)) return "SELL";
return "NONE"; return "NONE";
} }
/** GTE 등 유지형 조건 — 충족 시작 봉만 true (연속 시그널 방지) */
private boolean isRisingEdge(Rule rule, int index) {
if (rule == null || !rule.isSatisfied(index)) return false;
if (index <= 0) return true;
return !rule.isSatisfied(index - 1);
}
} }
+57
View File
@@ -1919,12 +1919,69 @@ html.desktop-client .tmb-logo-version {
} }
.ind-tab-editor-footer { .ind-tab-editor-footer {
display: flex; display: flex;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 10px 12px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
} }
.ind-tab-editor-error {
margin-right: auto;
font-size: 12px;
color: #ff6b6b;
}
/* 인앱 확인 모달 (데스크톱 웹뷰에서 window.confirm 미동작 대체) */
.gc-confirm-overlay {
position: fixed;
inset: 0;
z-index: 2200;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.55);
}
.gc-confirm-box {
width: min(360px, calc(100vw - 24px));
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
padding: 18px 18px 14px;
}
.gc-confirm-msg {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.55;
color: var(--text);
white-space: pre-line;
}
.gc-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.gc-confirm-btn {
padding: 7px 16px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
}
.gc-confirm-btn.cancel {
background: var(--bg3);
color: var(--text2);
}
.gc-confirm-btn.cancel:hover { color: var(--text); }
.gc-confirm-btn.ok {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.gc-confirm-btn.ok:hover { filter: brightness(1.08); }
.ind-tab-editor-btn { .ind-tab-editor-btn {
padding: 7px 16px; padding: 7px 16px;
border-radius: 4px; border-radius: 4px;
@@ -26,6 +26,7 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [selected, setSelected] = useState<Set<string>>(() => new Set(initialTypes)); const [selected, setSelected] = useState<Set<string>>(() => new Set(initialTypes));
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -64,13 +65,14 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
const handleSave = () => { const handleSave = () => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
window.alert('탭 이름을 입력해 주세요.'); setError('탭 이름을 입력해 주세요.');
return; return;
} }
if (selected.size === 0) { if (selected.size === 0) {
window.alert('탭에 표시할 지표를 하나 이상 선택해 주세요.'); setError('탭에 표시할 지표를 하나 이상 선택해 주세요.');
return; return;
} }
setError('');
const ordered = INDICATOR_REGISTRY const ordered = INDICATOR_REGISTRY
.filter(d => selected.has(d.type)) .filter(d => selected.has(d.type))
.map(d => d.type); .map(d => d.type);
@@ -96,7 +98,7 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
<input <input
className="ind-tab-editor-input" className="ind-tab-editor-input"
value={name} value={name}
onChange={e => setName(e.target.value)} onChange={e => { setName(e.target.value); if (error) setError(''); }}
placeholder="예: 내 지표" placeholder="예: 내 지표"
autoFocus autoFocus
/> />
@@ -156,6 +158,7 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
</div> </div>
<div className="ind-tab-editor-footer"> <div className="ind-tab-editor-footer">
{error && <span className="ind-tab-editor-error">{error}</span>}
<button type="button" className="ind-tab-editor-btn cancel" onClick={onClose}> <button type="button" className="ind-tab-editor-btn cancel" onClick={onClose}>
</button> </button>
@@ -2,6 +2,7 @@
* 실시간 차트 툴바와 동일한 보조지표 추가·제거 패널 (ind-panel) * 실시간 차트 툴바와 동일한 보조지표 추가·제거 패널 (ind-panel)
*/ */
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry'; import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry';
import { import {
@@ -12,7 +13,12 @@ import {
parseCustomTabKey, parseCustomTabKey,
type IndicatorTabKey, type IndicatorTabKey,
} from '../utils/indicatorMainTab'; } from '../utils/indicatorMainTab';
import { confirmAndApplyIndicatorTab } from '../utils/indicatorTabApply'; import {
applyIndicatorTab,
resolveIndicatorTabApplyTypes,
resolveIndicatorTabApplyLabel,
type IndicatorTabApplySource,
} from '../utils/indicatorTabApply';
import { import {
getOrderedMainIndicatorTypes, getOrderedMainIndicatorTypes,
saveMainTabOrder, saveMainTabOrder,
@@ -85,6 +91,7 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
const [dragOverType, setDragOverType] = useState<string | null>(null); const [dragOverType, setDragOverType] = useState<string | null>(null);
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create'); const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
const [confirmState, setConfirmState] = useState<{ message: string; onConfirm: () => void } | null>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const searchFocusedRef = useRef(false); const searchFocusedRef = useRef(false);
@@ -117,10 +124,15 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
const handleDeleteTab = () => { const handleDeleteTab = () => {
if (!selectedCustomTab) return; if (!selectedCustomTab) return;
if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return; const tab = selectedCustomTab;
deleteCustomTab(selectedCustomTab.id); setConfirmState({
refreshCustomTabs(); message: `"${tab.name}" 탭을 삭제할까요?`,
setCategory('All'); onConfirm: () => {
deleteCustomTab(tab.id);
refreshCustomTabs();
setCategory('All');
},
});
}; };
const handleEditorSave = (name: string, indicatorTypes: string[]) => { const handleEditorSave = (name: string, indicatorTypes: string[]) => {
@@ -205,14 +217,24 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0; const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0;
const requestApplyTab = (source: IndicatorTabApplySource) => {
const validTypes = resolveIndicatorTabApplyTypes(source);
if (validTypes.length === 0) return;
const label = resolveIndicatorTabApplyLabel(source);
setConfirmState({
message: `기존 보조지표를 모두 제거하고 ${label} 탭의 지표 ${validTypes.length}개로 교체하시겠습니까?`,
onConfirm: () => applyIndicatorTab(source, onAddMany, onAdd),
});
};
const handleAddAllInTab = () => { const handleAddAllInTab = () => {
if (!addAllContext || addAllContext.types.length === 0) return; if (!addAllContext || addAllContext.types.length === 0) return;
if (category === 'Main') { if (category === 'Main') {
confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd); requestApplyTab({ kind: 'main' });
return; return;
} }
if (isCustomTabKey(category) && selectedCustomTab) { if (isCustomTabKey(category) && selectedCustomTab) {
confirmAndApplyIndicatorTab({ kind: 'custom', tab: selectedCustomTab }, onAddMany, onAdd); requestApplyTab({ kind: 'custom', tab: selectedCustomTab });
} }
}; };
@@ -229,6 +251,10 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (confirmState) {
setConfirmState(null);
return;
}
if (editorOpen) { if (editorOpen) {
setEditorOpen(false); setEditorOpen(false);
return; return;
@@ -237,7 +263,7 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [onClose, editorOpen]); }, [onClose, editorOpen, confirmState]);
const isActive = (type: string) => activeIndicators.some(a => a.type === type); const isActive = (type: string) => activeIndicators.some(a => a.type === type);
@@ -535,6 +561,38 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
/> />
)} )}
</div> </div>
{confirmState && ReactDOM.createPortal(
<div
className="gc-confirm-overlay"
onMouseDown={e => { if (e.target === e.currentTarget) setConfirmState(null); }}
>
<div className="gc-confirm-box" onMouseDown={e => e.stopPropagation()}>
<p className="gc-confirm-msg">{confirmState.message}</p>
<div className="gc-confirm-actions">
<button
type="button"
className="gc-confirm-btn cancel"
onClick={() => setConfirmState(null)}
>
</button>
<button
type="button"
className="gc-confirm-btn ok"
onClick={() => {
const fn = confirmState.onConfirm;
setConfirmState(null);
fn();
}}
>
</button>
</div>
</div>
</div>,
document.body,
)}
</div> </div>
); );
}; };
@@ -139,14 +139,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
return { candle: flex.candle, aux: flex.aux }; return { candle: flex.candle, aux: flex.aux };
}, [auxPaneCount]); }, [auxPaneCount]);
/** 저장된 오버라이드가 있을 때만 백테스트에 전달 — 미설정 시 live-conditions 와 같이 서버 DB 사용 */ /** 전략 DSL 지표 파라미터 — live-conditions/evaluate·차트와 동일 소스 */
const indicatorParams = useMemo( const indicatorParams = useMemo(
() => ( () => (strategy ? buildEvalParamsFromStrategy(strategy, getParams) : undefined),
strategy && appliedIndicatorParams [strategy, getParams],
? buildEvalParamsFromStrategy(strategy, getParams)
: undefined
),
[strategy, getParams, appliedIndicatorParams],
); );
const resolveInitialDisplayCount = useCallback( const resolveInitialDisplayCount = useCallback(