diff --git a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java index 1ce567c..dafc59b 100644 --- a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java +++ b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java @@ -76,6 +76,7 @@ public class LiveConditionController { body.getBars(), body.getTimeframe(), body.getIndicatorParams(), - body.getEvaluationBarCount())); + body.getEvaluationBarCount(), + body.getPositionMode())); } } diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java index bfe637d..9e6423a 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java @@ -18,4 +18,6 @@ public class LiveConditionScanSignalsRequest { private String timeframe; /** 평가·표시 구간 봉 수 — null 이면 전체 bars */ private Integer evaluationBarCount; + /** "LONG_ONLY" | "SIGNAL_ONLY" — 백테스트 설정 positionMode 와 동일 */ + private String positionMode; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 6bd7be4..e141f24 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseTradingRecord; import org.ta4j.core.Rule; import org.springframework.transaction.annotation.Transactional; @@ -759,7 +760,8 @@ public class LiveConditionStatusService { List chartBars, String chartTimeframe, Map> indicatorParamsOverride, - Integer evaluationBarCount) { + Integer evaluationBarCount, + String positionMode) { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty() || chartBars == null || chartBars.isEmpty() || chartTimeframe == null || chartTimeframe.isBlank()) { @@ -808,21 +810,53 @@ public class LiveConditionStatusService { List signals = new ArrayList<>(); int buyHits = 0; int sellHits = 0; + String posMode = "SIGNAL_ONLY".equals(positionMode) ? "SIGNAL_ONLY" : "LONG_ONLY"; + boolean signalOnly = "SIGNAL_ONLY".equals(posMode); + BaseTradingRecord record = signalOnly ? null : new BaseTradingRecord(); + boolean inPosition = false; for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) { long barTimeSec = barOpenEpochSec(primarySeries, i); double close = primarySeries.getBar(i).getClosePrice().doubleValue(); - if (entryRule.isSatisfied(i, null)) { - buyHits++; - signals.add(BacktestResponse.Signal.builder() - .time(barTimeSec) - .type("BUY") - .price(close) - .barIndex(i) - .quantity(0) - .build()); + if (signalOnly) { + if (entryRule.isSatisfied(i, null)) { + buyHits++; + signals.add(BacktestResponse.Signal.builder() + .time(barTimeSec) + .type("BUY") + .price(close) + .barIndex(i) + .quantity(0) + .build()); + } + if (exitRule.isSatisfied(i, null)) { + sellHits++; + signals.add(BacktestResponse.Signal.builder() + .time(barTimeSec) + .type("SELL") + .price(close) + .barIndex(i) + .quantity(0) + .build()); + } + continue; } - if (exitRule.isSatisfied(i, null)) { + + if (!inPosition) { + if (entryRule.isSatisfied(i, record)) { + buyHits++; + signals.add(BacktestResponse.Signal.builder() + .time(barTimeSec) + .type("BUY") + .price(close) + .barIndex(i) + .quantity(0) + .build()); + record.enter(i, primarySeries.numFactory().numOf(close), + primarySeries.numFactory().numOf(1)); + inPosition = true; + } + } else if (exitRule.isSatisfied(i, record)) { sellHits++; signals.add(BacktestResponse.Signal.builder() .time(barTimeSec) @@ -831,16 +865,19 @@ public class LiveConditionStatusService { .barIndex(i) .quantity(0) .build()); + record.exit(i, primarySeries.numFactory().numOf(close), + primarySeries.numFactory().numOf(1)); + inPosition = false; } } if (signals.isEmpty()) { - log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{} cond={} probe={}", + log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{} mode={} cond={} probe={}", market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx, - primarySeries.getEndIndex(), summarizeBuyCondition(buyDsl), + primarySeries.getEndIndex(), posMode, summarizeBuyCondition(buyDsl), probeEntryRule(entryRule, buyDsl, ruleCtx, startIdx)); } else { - log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}", - market, strategyId, primaryTf, buyHits, sellHits); + log.debug("[LiveCondition:scan] market={} strategy={} tf={} mode={} buy={} sell={}", + market, strategyId, primaryTf, posMode, buyHits, sellHits); } return signals; } catch (Exception e) { diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 6c948ca..56fbed3 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -3,11 +3,14 @@ package com.goldenchart.service; import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcStrategy; import com.goldenchart.entity.GcTradeSignal; +import com.goldenchart.dto.BacktestSettingsDto; +import com.goldenchart.entity.GcAppSettings; import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.repository.GcPaperAccountRepository; import com.goldenchart.repository.GcPaperPositionRepository; import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.repository.GcTradeSignalRepository; +import com.goldenchart.trading.TradingAccess; import com.goldenchart.storage.Ta4jStorage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -71,6 +74,8 @@ public class LiveStrategyEvaluator { private final StrategyConditionTimeframeService conditionTimeframes; private final StrategyTriggerBranchEvaluator triggerBranchEvaluator; private final StrategyBranchStateCache branchStateCache; + private final AppSettingsService appSettingsService; + private final BacktestSettingsService backtestSettingsService; /** * 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule) @@ -158,12 +163,13 @@ public class LiveStrategyEvaluator { if (!ta4jStorage.exists(market, candleType)) return "NONE"; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; + String posMode = resolvePositionMode(s); String result = evaluate(market, candleType, s.getStrategyId(), - s.getPositionMode(), maturedIndex, + posMode, maturedIndex, s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */); if (!"NONE".equals(result)) { log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}", - s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); + s.getStrategyId(), market, candleType, maturedIndex, posMode, result); } return result; } @@ -213,13 +219,14 @@ public class LiveStrategyEvaluator { Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey); if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE"; + String posMode = resolvePositionMode(s); // 캐시 완전 우회: 매번 신규 Rule 빌드 (CachedIndicator stale 방지 + 동시성 보장) String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(), - s.getPositionMode(), currentIndex, + posMode, currentIndex, s.getDeviceId(), s.getUserId(), false /* useConfirmedOnly */); if (!"NONE".equals(result)) { log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}", - s.getStrategyId(), market, candleType, currentIndex, s.getPositionMode(), result); + s.getStrategyId(), market, candleType, currentIndex, posMode, result); realtimeSignaledIdx.put(dedupeKey, currentIndex); } return result; @@ -257,13 +264,14 @@ public class LiveStrategyEvaluator { if (!ta4jStorage.exists(market, candleType)) return "NONE"; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; + String posMode = resolvePositionMode(s); // 확정봉 기준 평가: 캐시 우회 + useConfirmedOnly=true String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(), - s.getPositionMode(), maturedIndex, + posMode, maturedIndex, s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */); if (!"NONE".equals(result)) { log.info("[Evaluator] REALTIME_TICK @candle_close strategyId={} {} {} idx={} mode={} → {}", - s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); + s.getStrategyId(), market, candleType, maturedIndex, posMode, result); realtimeSignaledIdx.put(market + ":" + candleType + ":" + s.getStrategyId(), maturedIndex); } return result; @@ -371,6 +379,31 @@ public class LiveStrategyEvaluator { return "NONE"; } + /** + * 포지션 모드 해석 — 전역 실시간 전략은 백테스트 설정, 가상투자 per-market 은 live 행 값 사용. + */ + private String resolvePositionMode(GcLiveStrategySettings s) { + try { + GcAppSettings app = appSettingsService.getEntity(s.getUserId(), s.getDeviceId()); + boolean globalLive = Boolean.TRUE.equals(app.getLiveStrategyCheck()); + if (!globalLive && Boolean.TRUE.equals(s.getIsLiveCheck())) { + return s.getPositionMode() != null ? s.getPositionMode() : "LONG_ONLY"; + } + String deviceKey = s.getUserId() != null + ? TradingAccess.accountDeviceKey(s.getUserId()) + : s.getDeviceId(); + if (deviceKey != null && !deviceKey.isBlank()) { + BacktestSettingsDto bt = backtestSettingsService.get(deviceKey); + if (bt.getPositionMode() != null) { + return "SIGNAL_ONLY".equals(bt.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"; + } + } + } catch (Exception e) { + log.debug("[Evaluator] positionMode resolve fallback: {}", e.getMessage()); + } + return s.getPositionMode() != null ? s.getPositionMode() : "LONG_ONLY"; + } + private TriggerRules buildTriggerRules(String market, String candleType, long strategyId, String deviceId, Long userId, boolean useConfirmedOnly) { GcStrategy strategy = resolveStrategy(strategyId); diff --git a/frontend/src/chart/ChartWorkspaceView.tsx b/frontend/src/chart/ChartWorkspaceView.tsx index d84dd66..0b54787 100644 --- a/frontend/src/chart/ChartWorkspaceView.tsx +++ b/frontend/src/chart/ChartWorkspaceView.tsx @@ -882,6 +882,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { market={symbol} strategies={liveStrategies} settings={liveStrategySettings} + backtestPositionMode={btSettings.positionMode ?? 'LONG_ONLY'} watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length} monitoredMarkets={monitoredMarkets} virtualDriven={isVirtualActive} diff --git a/frontend/src/chart/useChartWorkspace.ts b/frontend/src/chart/useChartWorkspace.ts index dd32625..c4128fa 100644 --- a/frontend/src/chart/useChartWorkspace.ts +++ b/frontend/src/chart/useChartWorkspace.ts @@ -797,10 +797,10 @@ export function useChartWorkspace({ strategyId: appDefaults.liveStrategyId ?? null, isLiveCheck: appDefaults.liveStrategyCheck ?? false, executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', - positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', + positionMode: btSettings.positionMode ?? 'LONG_ONLY', strategyCandleTypes: liveStrategyCandleTypes, }; - }, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes]); + }, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes, btSettings.positionMode]); /** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */ const monitoredMarkets = useMemo(() => { @@ -856,7 +856,7 @@ export function useChartWorkspace({ liveStrategyCheck: saved.isLiveCheck, liveStrategyId: saved.strategyId ?? undefined, liveExecutionType: saved.executionType, - livePositionMode: saved.positionMode, + livePositionMode: btSettings.positionMode ?? 'LONG_ONLY', }); if (saved.strategyCandleTypes) { setLiveStrategyCandleTypes(saved.strategyCandleTypes); @@ -864,7 +864,7 @@ export function useChartWorkspace({ if (appDefaults.liveStrategyCheck && saved.isLiveCheck) { refreshActiveLiveSettings(); } - }, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings]); + }, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings, btSettings.positionMode]); const prevFavoritesRef = useRef(getFavorites()); diff --git a/frontend/src/components/LiveStrategyPanel.tsx b/frontend/src/components/LiveStrategyPanel.tsx index 633bf4d..6f837d5 100644 --- a/frontend/src/components/LiveStrategyPanel.tsx +++ b/frontend/src/components/LiveStrategyPanel.tsx @@ -29,6 +29,8 @@ interface LiveStrategyPanelProps { market: string; strategies: Strategy[]; settings: LiveStrategySettingsDto; + /** 백테스트 설정 positionMode — 전역 실시간 전략에 적용 */ + backtestPositionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY'; watchlistCount?: number; monitoredMarkets?: string[]; /** 가상투자 세션에서 제어 — 차트 팝업은 읽기 전용 */ @@ -54,6 +56,7 @@ const CandleIcon: React.FC<{ color: string }> = ({ color }) => ( const LiveStrategyPanel: React.FC = ({ theme, market, strategies, settings, + backtestPositionMode = 'LONG_ONLY', watchlistCount = 0, monitoredMarkets = [], virtualDriven = false, onClearMarkers, onClose, onSettingsChange, }) => { @@ -73,7 +76,8 @@ const LiveStrategyPanel: React.FC = ({ const persist = useCallback(async (patch: Partial) => { if (virtualDriven) return; - const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; + const positionMode = backtestPositionMode; + const next: LiveStrategySettingsDto = { ...settings, ...patch, market, positionMode }; const prev = settings; if (next.isLiveCheck && next.strategyId != null) { const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId); @@ -102,7 +106,7 @@ const LiveStrategyPanel: React.FC = ({ } finally { setSaving(false); } - }, [settings, market, onClearMarkers, onSettingsChange, virtualDriven]); + }, [settings, market, onClearMarkers, onSettingsChange, virtualDriven, backtestPositionMode]); const isOn = settings.isLiveCheck; const execType = settings.executionType; @@ -114,7 +118,10 @@ const LiveStrategyPanel: React.FC = ({ ? settings.strategyCandleTypes.join(', ') : '1m'; const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후'; - const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY' + const effectivePosMode = virtualDriven + ? (settings.positionMode ?? 'LONG_ONLY') + : backtestPositionMode; + const posModeLabel = effectivePosMode === 'LONG_ONLY' ? '보유 자산 기준' : '순수 지표 기준'; @@ -267,36 +274,29 @@ const LiveStrategyPanel: React.FC = ({
시그널 모드 -
- - -
+ {virtualDriven ? ( +
+ + +
+ ) : ( +

+ 백테스트 설정과 동일 — {posModeLabel} + {' '}(백테스트 설정 패널에서 변경) +

+ )}
{isOn && !stratId && ( diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 46d3101..b363b85 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -5,6 +5,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import type { Theme } from '../types'; import { + loadBacktestSettings, saveLiveStrategySettings, type LiveStrategySettingsDto, } from '../utils/backendApi'; @@ -1347,6 +1348,15 @@ const StrategyPanel: React.FC = ({ }, ); const [liveSaving, setLiveSaving] = useState(false); + const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY'); + + useEffect(() => { + void loadBacktestSettings().then(s => { + if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') { + setBacktestPositionMode(s.positionMode); + } + }); + }, []); useEffect(() => { if (liveSettingsProp) setLiveSettings(liveSettingsProp); @@ -1354,7 +1364,12 @@ const StrategyPanel: React.FC = ({ const persistLive = useCallback(async (patch: Partial) => { if (virtualDriven) return; - const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket }; + const next: LiveStrategySettingsDto = { + ...liveSettings, + ...patch, + market: liveMarket, + positionMode: backtestPositionMode, + }; if (next.isLiveCheck && next.strategyId != null) { const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId); if (!synced) return; @@ -1370,7 +1385,7 @@ const StrategyPanel: React.FC = ({ } finally { setLiveSaving(false); } - }, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven]); + }, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven, backtestPositionMode]); const isOn = liveSettings.isLiveCheck; const execType = liveSettings.executionType; @@ -1502,38 +1517,18 @@ const StrategyPanel: React.FC = ({ -
- - +
+ {backtestPositionMode === 'LONG_ONLY' + ? '보유 자산 기준 (Long-Only) — 매수 이력 있을 때만 매도 허용' + : '순수 지표 기준 (Signal-Only) — 포지션 무관, 지표 충족 시 즉시 매도'}
diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 4e42e8e..ca7768d 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1709,6 +1709,7 @@ export async function fetchLiveConditionScanSignals( chartTimeframe: string, indicatorParams?: Record> | null, evaluationBarCount?: number | null, + positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY' | null, ): Promise { if (chartBars.length === 0 || !chartTimeframe) return []; const list = await requestOrThrow('/strategy/live-conditions/scan-signals', { @@ -1728,6 +1729,7 @@ export async function fetchLiveConditionScanSignals( })), timeframe: chartTimeframe, evaluationBarCount: evaluationBarCount ?? undefined, + positionMode: positionMode ?? undefined, }), }); return list ?? []; diff --git a/frontend/src/utils/strategyEvaluationSignals.ts b/frontend/src/utils/strategyEvaluationSignals.ts index a601842..dbf67d3 100644 --- a/frontend/src/utils/strategyEvaluationSignals.ts +++ b/frontend/src/utils/strategyEvaluationSignals.ts @@ -53,6 +53,8 @@ export async function fetchStrategyEvaluationSignals(opts: { const initialCapital = btSettings.initialCapital ?? 10_000_000; const commissionRate = resolveEvaluationCommissionRate(btSettings); + const positionMode = btSettings.positionMode ?? 'LONG_ONLY'; + if (isScanSignalsExecutionMode(btSettings)) { const signals = await fetchLiveConditionScanSignals( opts.market, @@ -61,11 +63,12 @@ export async function fetchStrategyEvaluationSignals(opts: { opts.timeframe, indicatorParams, opts.evaluationBarCount, + positionMode, ); return { signals, initialCapital, - settings: { ...btSettings, positionMode: 'SIGNAL_ONLY', tradeExecutionMode }, + settings: { ...btSettings, positionMode, tradeExecutionMode }, commissionRate, tradeExecutionMode, }; @@ -73,7 +76,7 @@ export async function fetchStrategyEvaluationSignals(opts: { const settings: BacktestSettingsDto = { ...btSettings, - positionMode: 'SIGNAL_ONLY', + positionMode, tradeExecutionMode: 'BACKTEST_ENGINE', };