전략평가 오류 수정
This commit is contained in:
@@ -49,10 +49,15 @@ public class BacktestingController {
|
||||
}
|
||||
|
||||
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 =
|
||||
indicatorSettingsService.getAll(userId, null);
|
||||
if (!dbParams.isEmpty()) {
|
||||
indicatorSettingsService.getAll(userId, deviceId);
|
||||
java.util.Map<String, java.util.Map<String, Object>> merged =
|
||||
new java.util.HashMap<>(dbParams);
|
||||
if (req.getIndicatorParams() != null) {
|
||||
@@ -64,6 +69,7 @@ public class BacktestingController {
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!merged.isEmpty()) {
|
||||
req.setIndicatorParams(merged);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,4 +63,7 @@ public class BacktestRequest {
|
||||
|
||||
/** 디바이스 ID (결과 저장용) */
|
||||
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>> visual = Map.of();
|
||||
if (req.getDeviceId() != null && !req.getDeviceId().isBlank()) {
|
||||
try {
|
||||
dbParams = indicatorSettingsService.getAll(null, req.getDeviceId());
|
||||
dbParams = indicatorSettingsService.getAll(req.getUserId(), req.getDeviceId());
|
||||
visual = indicatorSettingsService.getAllVisual(req.getUserId(), req.getDeviceId());
|
||||
} 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 값 위에 덮어씀 (요청값 우선)
|
||||
@@ -111,9 +114,12 @@ public class BacktestingService {
|
||||
Map<String, BarSeries> seriesOverrides = buildSeriesOverrides(
|
||||
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())
|
||||
? adapter.toRule(sellDsl, series, params, seriesOverrides)
|
||||
? adapter.toRule(sellDsl, ruleCtx)
|
||||
: new BooleanRule(false);
|
||||
Rule exitRule = buildExitRule(baseExitRule, series, cfg);
|
||||
|
||||
@@ -199,23 +205,21 @@ public class BacktestingService {
|
||||
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
|
||||
long time = barStartEpoch(series, i);
|
||||
|
||||
// ── SIGNAL_ONLY: 조건 충족 '시작' 봉만 시그널 (GTE 유지형 연속 중복 방지) ──
|
||||
// ── SIGNAL_ONLY: 조건 충족 봉마다 시그널 (live-conditions 충족과 동일 기준) ──
|
||||
if (signalOnly) {
|
||||
boolean entrySatisfied = entryRule.isSatisfied(i);
|
||||
boolean exitSatisfied = exitRule.isSatisfied(i);
|
||||
boolean entryEdge = entrySatisfied && (i <= loopStart || !entryRule.isSatisfied(i - 1));
|
||||
boolean exitEdge = exitSatisfied && (i <= loopStart || !exitRule.isSatisfied(i - 1));
|
||||
if (entryEdge) {
|
||||
if (entrySatisfied) {
|
||||
double effEntry = applySlippage(closePrice, cfg, true);
|
||||
String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||
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);
|
||||
String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ public class StrategySignalDeterminer {
|
||||
int index, String positionMode) {
|
||||
try {
|
||||
if ("SIGNAL_ONLY".equals(positionMode)) {
|
||||
boolean enterOk = entryRule != null && isRisingEdge(entryRule, index);
|
||||
boolean exitOk = exitRule != null && isRisingEdge(exitRule, index);
|
||||
boolean enterOk = entryRule != null && entryRule.isSatisfied(index);
|
||||
boolean exitOk = exitRule != null && exitRule.isSatisfied(index);
|
||||
if (enterOk) return "BUY";
|
||||
if (exitOk) return "SELL";
|
||||
return "NONE";
|
||||
@@ -84,15 +84,10 @@ public class StrategySignalDeterminer {
|
||||
}
|
||||
|
||||
private String determineSignalOnly(Strategy strategy, int index) {
|
||||
if (isRisingEdge(strategy.getEntryRule(), index)) return "BUY";
|
||||
if (isRisingEdge(strategy.getExitRule(), index)) return "SELL";
|
||||
Rule entry = strategy.getEntryRule();
|
||||
Rule exit = strategy.getExitRule();
|
||||
if (entry != null && entry.isSatisfied(index)) return "BUY";
|
||||
if (exit != null && exit.isSatisfied(index)) return "SELL";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1919,12 +1919,69 @@ html.desktop-client .tmb-logo-version {
|
||||
}
|
||||
.ind-tab-editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
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 {
|
||||
padding: 7px 16px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -26,6 +26,7 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
|
||||
const [name, setName] = useState(initialName);
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set(initialTypes));
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -64,13 +65,14 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
|
||||
const handleSave = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
window.alert('탭 이름을 입력해 주세요.');
|
||||
setError('탭 이름을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
window.alert('탭에 표시할 지표를 하나 이상 선택해 주세요.');
|
||||
setError('탭에 표시할 지표를 하나 이상 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
const ordered = INDICATOR_REGISTRY
|
||||
.filter(d => selected.has(d.type))
|
||||
.map(d => d.type);
|
||||
@@ -96,7 +98,7 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
|
||||
<input
|
||||
className="ind-tab-editor-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onChange={e => { setName(e.target.value); if (error) setError(''); }}
|
||||
placeholder="예: 내 지표"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -156,6 +158,7 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 실시간 차트 툴바와 동일한 보조지표 추가·제거 패널 (ind-panel)
|
||||
*/
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry';
|
||||
import {
|
||||
@@ -12,7 +13,12 @@ import {
|
||||
parseCustomTabKey,
|
||||
type IndicatorTabKey,
|
||||
} from '../utils/indicatorMainTab';
|
||||
import { confirmAndApplyIndicatorTab } from '../utils/indicatorTabApply';
|
||||
import {
|
||||
applyIndicatorTab,
|
||||
resolveIndicatorTabApplyTypes,
|
||||
resolveIndicatorTabApplyLabel,
|
||||
type IndicatorTabApplySource,
|
||||
} from '../utils/indicatorTabApply';
|
||||
import {
|
||||
getOrderedMainIndicatorTypes,
|
||||
saveMainTabOrder,
|
||||
@@ -85,6 +91,7 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
|
||||
const [dragOverType, setDragOverType] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
||||
const [confirmState, setConfirmState] = useState<{ message: string; onConfirm: () => void } | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const searchFocusedRef = useRef(false);
|
||||
|
||||
@@ -117,10 +124,15 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
|
||||
|
||||
const handleDeleteTab = () => {
|
||||
if (!selectedCustomTab) return;
|
||||
if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return;
|
||||
deleteCustomTab(selectedCustomTab.id);
|
||||
const tab = selectedCustomTab;
|
||||
setConfirmState({
|
||||
message: `"${tab.name}" 탭을 삭제할까요?`,
|
||||
onConfirm: () => {
|
||||
deleteCustomTab(tab.id);
|
||||
refreshCustomTabs();
|
||||
setCategory('All');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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 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 = () => {
|
||||
if (!addAllContext || addAllContext.types.length === 0) return;
|
||||
if (category === 'Main') {
|
||||
confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd);
|
||||
requestApplyTab({ kind: 'main' });
|
||||
return;
|
||||
}
|
||||
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(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (confirmState) {
|
||||
setConfirmState(null);
|
||||
return;
|
||||
}
|
||||
if (editorOpen) {
|
||||
setEditorOpen(false);
|
||||
return;
|
||||
@@ -237,7 +263,7 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, editorOpen]);
|
||||
}, [onClose, editorOpen, confirmState]);
|
||||
|
||||
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
|
||||
|
||||
@@ -535,6 +561,38 @@ const IndicatorDropdownPanel: React.FC<IndicatorDropdownPanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -139,14 +139,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
return { candle: flex.candle, aux: flex.aux };
|
||||
}, [auxPaneCount]);
|
||||
|
||||
/** 저장된 오버라이드가 있을 때만 백테스트에 전달 — 미설정 시 live-conditions 와 같이 서버 DB 사용 */
|
||||
/** 전략 DSL 지표 파라미터 — live-conditions/evaluate·차트와 동일 소스 */
|
||||
const indicatorParams = useMemo(
|
||||
() => (
|
||||
strategy && appliedIndicatorParams
|
||||
? buildEvalParamsFromStrategy(strategy, getParams)
|
||||
: undefined
|
||||
),
|
||||
[strategy, getParams, appliedIndicatorParams],
|
||||
() => (strategy ? buildEvalParamsFromStrategy(strategy, getParams) : undefined),
|
||||
[strategy, getParams],
|
||||
);
|
||||
|
||||
const resolveInitialDisplayCount = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user