매매시그널 실행조건 화면 공용 적용

This commit is contained in:
Macbook
2026-06-22 00:02:59 +09:00
parent 0d14fd4ad2
commit 977dd54e64
10 changed files with 168 additions and 94 deletions
@@ -76,6 +76,7 @@ public class LiveConditionController {
body.getBars(), body.getBars(),
body.getTimeframe(), body.getTimeframe(),
body.getIndicatorParams(), body.getIndicatorParams(),
body.getEvaluationBarCount())); body.getEvaluationBarCount(),
body.getPositionMode()));
} }
} }
@@ -18,4 +18,6 @@ public class LiveConditionScanSignalsRequest {
private String timeframe; private String timeframe;
/** 평가·표시 구간 봉 수 — null 이면 전체 bars */ /** 평가·표시 구간 봉 수 — null 이면 전체 bars */
private Integer evaluationBarCount; private Integer evaluationBarCount;
/** "LONG_ONLY" | "SIGNAL_ONLY" — 백테스트 설정 positionMode 와 동일 */
private String positionMode;
} }
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.ta4j.core.BarSeries; import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseTradingRecord;
import org.ta4j.core.Rule; import org.ta4j.core.Rule;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -759,7 +760,8 @@ public class LiveConditionStatusService {
List<OhlcvBar> chartBars, List<OhlcvBar> chartBars,
String chartTimeframe, String chartTimeframe,
Map<String, Map<String, Object>> indicatorParamsOverride, Map<String, Map<String, Object>> indicatorParamsOverride,
Integer evaluationBarCount) { Integer evaluationBarCount,
String positionMode) {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId); Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty() || chartBars == null || chartBars.isEmpty() if (opt.isEmpty() || chartBars == null || chartBars.isEmpty()
|| chartTimeframe == null || chartTimeframe.isBlank()) { || chartTimeframe == null || chartTimeframe.isBlank()) {
@@ -808,21 +810,53 @@ public class LiveConditionStatusService {
List<BacktestResponse.Signal> signals = new ArrayList<>(); List<BacktestResponse.Signal> signals = new ArrayList<>();
int buyHits = 0; int buyHits = 0;
int sellHits = 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++) { for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) {
long barTimeSec = barOpenEpochSec(primarySeries, i); long barTimeSec = barOpenEpochSec(primarySeries, i);
double close = primarySeries.getBar(i).getClosePrice().doubleValue(); double close = primarySeries.getBar(i).getClosePrice().doubleValue();
if (entryRule.isSatisfied(i, null)) { if (signalOnly) {
buyHits++; if (entryRule.isSatisfied(i, null)) {
signals.add(BacktestResponse.Signal.builder() buyHits++;
.time(barTimeSec) signals.add(BacktestResponse.Signal.builder()
.type("BUY") .time(barTimeSec)
.price(close) .type("BUY")
.barIndex(i) .price(close)
.quantity(0) .barIndex(i)
.build()); .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++; sellHits++;
signals.add(BacktestResponse.Signal.builder() signals.add(BacktestResponse.Signal.builder()
.time(barTimeSec) .time(barTimeSec)
@@ -831,16 +865,19 @@ public class LiveConditionStatusService {
.barIndex(i) .barIndex(i)
.quantity(0) .quantity(0)
.build()); .build());
record.exit(i, primarySeries.numFactory().numOf(close),
primarySeries.numFactory().numOf(1));
inPosition = false;
} }
} }
if (signals.isEmpty()) { 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, market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx,
primarySeries.getEndIndex(), summarizeBuyCondition(buyDsl), primarySeries.getEndIndex(), posMode, summarizeBuyCondition(buyDsl),
probeEntryRule(entryRule, buyDsl, ruleCtx, startIdx)); probeEntryRule(entryRule, buyDsl, ruleCtx, startIdx));
} else { } else {
log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}", log.debug("[LiveCondition:scan] market={} strategy={} tf={} mode={} buy={} sell={}",
market, strategyId, primaryTf, buyHits, sellHits); market, strategyId, primaryTf, posMode, buyHits, sellHits);
} }
return signals; return signals;
} catch (Exception e) { } catch (Exception e) {
@@ -3,11 +3,14 @@ package com.goldenchart.service;
import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.entity.GcStrategy; import com.goldenchart.entity.GcStrategy;
import com.goldenchart.entity.GcTradeSignal; import com.goldenchart.entity.GcTradeSignal;
import com.goldenchart.dto.BacktestSettingsDto;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.repository.GcPaperAccountRepository; import com.goldenchart.repository.GcPaperAccountRepository;
import com.goldenchart.repository.GcPaperPositionRepository; import com.goldenchart.repository.GcPaperPositionRepository;
import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.repository.GcStrategyRepository;
import com.goldenchart.repository.GcTradeSignalRepository; import com.goldenchart.repository.GcTradeSignalRepository;
import com.goldenchart.trading.TradingAccess;
import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -71,6 +74,8 @@ public class LiveStrategyEvaluator {
private final StrategyConditionTimeframeService conditionTimeframes; private final StrategyConditionTimeframeService conditionTimeframes;
private final StrategyTriggerBranchEvaluator triggerBranchEvaluator; private final StrategyTriggerBranchEvaluator triggerBranchEvaluator;
private final StrategyBranchStateCache branchStateCache; private final StrategyBranchStateCache branchStateCache;
private final AppSettingsService appSettingsService;
private final BacktestSettingsService backtestSettingsService;
/** /**
* 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule) * 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule)
@@ -158,12 +163,13 @@ public class LiveStrategyEvaluator {
if (!ta4jStorage.exists(market, candleType)) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE";
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE";
String posMode = resolvePositionMode(s);
String result = evaluate(market, candleType, s.getStrategyId(), String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex, posMode, maturedIndex,
s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */); s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */);
if (!"NONE".equals(result)) { if (!"NONE".equals(result)) {
log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}", 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; return result;
} }
@@ -213,13 +219,14 @@ public class LiveStrategyEvaluator {
Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey); Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey);
if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE"; if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE";
String posMode = resolvePositionMode(s);
// 캐시 완전 우회: 매번 신규 Rule 빌드 (CachedIndicator stale 방지 + 동시성 보장) // 캐시 완전 우회: 매번 신규 Rule 빌드 (CachedIndicator stale 방지 + 동시성 보장)
String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(), String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
s.getPositionMode(), currentIndex, posMode, currentIndex,
s.getDeviceId(), s.getUserId(), false /* useConfirmedOnly */); s.getDeviceId(), s.getUserId(), false /* useConfirmedOnly */);
if (!"NONE".equals(result)) { if (!"NONE".equals(result)) {
log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}", 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); realtimeSignaledIdx.put(dedupeKey, currentIndex);
} }
return result; return result;
@@ -257,13 +264,14 @@ public class LiveStrategyEvaluator {
if (!ta4jStorage.exists(market, candleType)) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE";
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE";
String posMode = resolvePositionMode(s);
// 확정봉 기준 평가: 캐시 우회 + useConfirmedOnly=true // 확정봉 기준 평가: 캐시 우회 + useConfirmedOnly=true
String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(), String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex, posMode, maturedIndex,
s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */); s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */);
if (!"NONE".equals(result)) { if (!"NONE".equals(result)) {
log.info("[Evaluator] REALTIME_TICK @candle_close strategyId={} {} {} idx={} mode={} → {}", 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); realtimeSignaledIdx.put(market + ":" + candleType + ":" + s.getStrategyId(), maturedIndex);
} }
return result; return result;
@@ -371,6 +379,31 @@ public class LiveStrategyEvaluator {
return "NONE"; 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, private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
String deviceId, Long userId, boolean useConfirmedOnly) { String deviceId, Long userId, boolean useConfirmedOnly) {
GcStrategy strategy = resolveStrategy(strategyId); GcStrategy strategy = resolveStrategy(strategyId);
@@ -882,6 +882,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
market={symbol} market={symbol}
strategies={liveStrategies} strategies={liveStrategies}
settings={liveStrategySettings} settings={liveStrategySettings}
backtestPositionMode={btSettings.positionMode ?? 'LONG_ONLY'}
watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length} watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length}
monitoredMarkets={monitoredMarkets} monitoredMarkets={monitoredMarkets}
virtualDriven={isVirtualActive} virtualDriven={isVirtualActive}
+4 -4
View File
@@ -797,10 +797,10 @@ export function useChartWorkspace({
strategyId: appDefaults.liveStrategyId ?? null, strategyId: appDefaults.liveStrategyId ?? null,
isLiveCheck: appDefaults.liveStrategyCheck ?? false, isLiveCheck: appDefaults.liveStrategyCheck ?? false,
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', positionMode: btSettings.positionMode ?? 'LONG_ONLY',
strategyCandleTypes: liveStrategyCandleTypes, strategyCandleTypes: liveStrategyCandleTypes,
}; };
}, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes]); }, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes, btSettings.positionMode]);
/** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */ /** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */
const monitoredMarkets = useMemo(() => { const monitoredMarkets = useMemo(() => {
@@ -856,7 +856,7 @@ export function useChartWorkspace({
liveStrategyCheck: saved.isLiveCheck, liveStrategyCheck: saved.isLiveCheck,
liveStrategyId: saved.strategyId ?? undefined, liveStrategyId: saved.strategyId ?? undefined,
liveExecutionType: saved.executionType, liveExecutionType: saved.executionType,
livePositionMode: saved.positionMode, livePositionMode: btSettings.positionMode ?? 'LONG_ONLY',
}); });
if (saved.strategyCandleTypes) { if (saved.strategyCandleTypes) {
setLiveStrategyCandleTypes(saved.strategyCandleTypes); setLiveStrategyCandleTypes(saved.strategyCandleTypes);
@@ -864,7 +864,7 @@ export function useChartWorkspace({
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) { if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
refreshActiveLiveSettings(); refreshActiveLiveSettings();
} }
}, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings]); }, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings, btSettings.positionMode]);
const prevFavoritesRef = useRef<string[]>(getFavorites()); const prevFavoritesRef = useRef<string[]>(getFavorites());
+33 -33
View File
@@ -29,6 +29,8 @@ interface LiveStrategyPanelProps {
market: string; market: string;
strategies: Strategy[]; strategies: Strategy[];
settings: LiveStrategySettingsDto; settings: LiveStrategySettingsDto;
/** 백테스트 설정 positionMode — 전역 실시간 전략에 적용 */
backtestPositionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY';
watchlistCount?: number; watchlistCount?: number;
monitoredMarkets?: string[]; monitoredMarkets?: string[];
/** 가상투자 세션에서 제어 — 차트 팝업은 읽기 전용 */ /** 가상투자 세션에서 제어 — 차트 팝업은 읽기 전용 */
@@ -54,6 +56,7 @@ const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
theme, market, strategies, settings, theme, market, strategies, settings,
backtestPositionMode = 'LONG_ONLY',
watchlistCount = 0, monitoredMarkets = [], virtualDriven = false, watchlistCount = 0, monitoredMarkets = [], virtualDriven = false,
onClearMarkers, onClose, onSettingsChange, onClearMarkers, onClose, onSettingsChange,
}) => { }) => {
@@ -73,7 +76,8 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => { const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
if (virtualDriven) return; if (virtualDriven) return;
const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; const positionMode = backtestPositionMode;
const next: LiveStrategySettingsDto = { ...settings, ...patch, market, positionMode };
const prev = settings; const prev = settings;
if (next.isLiveCheck && next.strategyId != null) { if (next.isLiveCheck && next.strategyId != null) {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId); const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
@@ -102,7 +106,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [settings, market, onClearMarkers, onSettingsChange, virtualDriven]); }, [settings, market, onClearMarkers, onSettingsChange, virtualDriven, backtestPositionMode]);
const isOn = settings.isLiveCheck; const isOn = settings.isLiveCheck;
const execType = settings.executionType; const execType = settings.executionType;
@@ -114,7 +118,10 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
? settings.strategyCandleTypes.join(', ') ? settings.strategyCandleTypes.join(', ')
: '1m'; : '1m';
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후'; 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<LiveStrategyPanelProps> = ({
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}> <div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
<span className="lsp-section-label"> </span> <span className="lsp-section-label"> </span>
<div className="lsp-option-group"> {virtualDriven ? (
<label className={`lsp-option${(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY' ? ' lsp-option--active' : ''}`}> <div className="lsp-option-group">
<input <label className={`lsp-option${effectivePosMode === 'LONG_ONLY' ? ' lsp-option--active' : ''}`}>
type="radio" <input type="radio" name="lsp-posMode" value="LONG_ONLY" checked={effectivePosMode === 'LONG_ONLY'} disabled />
name="lsp-posMode" <span className="lsp-option-text">
value="LONG_ONLY" <span className="lsp-option-title"> </span>
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'} <span className="lsp-option-desc"> </span>
disabled={virtualDriven || !isOn} </span>
onChange={() => persist({ positionMode: 'LONG_ONLY' })} </label>
/> <label className={`lsp-option${effectivePosMode === 'SIGNAL_ONLY' ? ' lsp-option--active' : ''}`}>
<span className="lsp-option-text"> <input type="radio" name="lsp-posMode" value="SIGNAL_ONLY" checked={effectivePosMode === 'SIGNAL_ONLY'} disabled />
<span className="lsp-option-title"> </span> <span className="lsp-option-text">
<span className="lsp-option-desc"> </span> <span className="lsp-option-title"> </span>
</span> <span className="lsp-option-desc"> , </span>
</label> </span>
<label className={`lsp-option${settings.positionMode === 'SIGNAL_ONLY' ? ' lsp-option--active' : ''}`}> </label>
<input </div>
type="radio" ) : (
name="lsp-posMode" <p className="lsp-hint" style={{ margin: 0 }}>
value="SIGNAL_ONLY" <strong>{posModeLabel}</strong>
checked={settings.positionMode === 'SIGNAL_ONLY'} {' '}( )
disabled={virtualDriven || !isOn} </p>
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })} )}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> </span>
<span className="lsp-option-desc"> , </span>
</span>
</label>
</div>
</div> </div>
{isOn && !stratId && ( {isOn && !stratId && (
+28 -33
View File
@@ -5,6 +5,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import type { Theme } from '../types'; import type { Theme } from '../types';
import { import {
loadBacktestSettings,
saveLiveStrategySettings, saveLiveStrategySettings,
type LiveStrategySettingsDto, type LiveStrategySettingsDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
@@ -1347,6 +1348,15 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
}, },
); );
const [liveSaving, setLiveSaving] = useState(false); 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(() => { useEffect(() => {
if (liveSettingsProp) setLiveSettings(liveSettingsProp); if (liveSettingsProp) setLiveSettings(liveSettingsProp);
@@ -1354,7 +1364,12 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => { const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
if (virtualDriven) return; 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) { if (next.isLiveCheck && next.strategyId != null) {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId); const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
if (!synced) return; if (!synced) return;
@@ -1370,7 +1385,7 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
} finally { } finally {
setLiveSaving(false); setLiveSaving(false);
} }
}, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven]); }, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven, backtestPositionMode]);
const isOn = liveSettings.isLiveCheck; const isOn = liveSettings.isLiveCheck;
const execType = liveSettings.executionType; const execType = liveSettings.executionType;
@@ -1502,38 +1517,18 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
<SettingRow <SettingRow
className="stg-row--block" className="stg-row--block"
label="포지션 종속성 모드" label="시그널 모드"
desc="매도 시그널 발생 조건을 선택합니다. LONG_ONLY: 매수 이력이 있을 때만 매도 허용. SIGNAL_ONLY: 포지션 없이도 지표 규칙 충족 시 즉시 매도 시그널." desc="백테스트 설정의 포지션 종속성 모드와 동일하게 적용됩니다. 변경은 설정 → 백테스트 설정에서 하세요."
> >
<div className={`stg-radio-row${!isOn ? ' stg-radio-row--disabled' : ''}`}> <div style={{
<label className="stg-radio-option stg-radio-option--blue"> padding: '8px 10px', borderRadius: 6,
<input background: 'rgba(63,126,245,0.08)', border: '1px solid rgba(63,126,245,0.2)',
type="radio" fontSize: 12, color: 'var(--text2)',
name="stg-pos-mode" opacity: isOn ? 1 : 0.55,
value="LONG_ONLY" }}>
checked={(liveSettings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'} {backtestPositionMode === 'LONG_ONLY'
disabled={!isOn} ? '보유 자산 기준 (Long-Only) — 매수 이력 있을 때만 매도 허용'
onChange={() => persistLive({ positionMode: 'LONG_ONLY' })} : '순수 지표 기준 (Signal-Only) — 포지션 무관, 지표 충족 시 즉시 매도'}
/>
<span className="stg-radio-option-text">
<strong> (Long-Only)</strong>
<span> </span>
</span>
</label>
<label className="stg-radio-option stg-radio-option--orange">
<input
type="radio"
name="stg-pos-mode"
value="SIGNAL_ONLY"
checked={liveSettings.positionMode === 'SIGNAL_ONLY'}
disabled={!isOn}
onChange={() => persistLive({ positionMode: 'SIGNAL_ONLY' })}
/>
<span className="stg-radio-option-text">
<strong> (Signal-Only)</strong>
<span> , </span>
</span>
</label>
</div> </div>
</SettingRow> </SettingRow>
</SettingSection> </SettingSection>
+2
View File
@@ -1709,6 +1709,7 @@ export async function fetchLiveConditionScanSignals(
chartTimeframe: string, chartTimeframe: string,
indicatorParams?: Record<string, Record<string, unknown>> | null, indicatorParams?: Record<string, Record<string, unknown>> | null,
evaluationBarCount?: number | null, evaluationBarCount?: number | null,
positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY' | null,
): Promise<BacktestSignal[]> { ): Promise<BacktestSignal[]> {
if (chartBars.length === 0 || !chartTimeframe) return []; if (chartBars.length === 0 || !chartTimeframe) return [];
const list = await requestOrThrow<BacktestSignal[]>('/strategy/live-conditions/scan-signals', { const list = await requestOrThrow<BacktestSignal[]>('/strategy/live-conditions/scan-signals', {
@@ -1728,6 +1729,7 @@ export async function fetchLiveConditionScanSignals(
})), })),
timeframe: chartTimeframe, timeframe: chartTimeframe,
evaluationBarCount: evaluationBarCount ?? undefined, evaluationBarCount: evaluationBarCount ?? undefined,
positionMode: positionMode ?? undefined,
}), }),
}); });
return list ?? []; return list ?? [];
@@ -53,6 +53,8 @@ export async function fetchStrategyEvaluationSignals(opts: {
const initialCapital = btSettings.initialCapital ?? 10_000_000; const initialCapital = btSettings.initialCapital ?? 10_000_000;
const commissionRate = resolveEvaluationCommissionRate(btSettings); const commissionRate = resolveEvaluationCommissionRate(btSettings);
const positionMode = btSettings.positionMode ?? 'LONG_ONLY';
if (isScanSignalsExecutionMode(btSettings)) { if (isScanSignalsExecutionMode(btSettings)) {
const signals = await fetchLiveConditionScanSignals( const signals = await fetchLiveConditionScanSignals(
opts.market, opts.market,
@@ -61,11 +63,12 @@ export async function fetchStrategyEvaluationSignals(opts: {
opts.timeframe, opts.timeframe,
indicatorParams, indicatorParams,
opts.evaluationBarCount, opts.evaluationBarCount,
positionMode,
); );
return { return {
signals, signals,
initialCapital, initialCapital,
settings: { ...btSettings, positionMode: 'SIGNAL_ONLY', tradeExecutionMode }, settings: { ...btSettings, positionMode, tradeExecutionMode },
commissionRate, commissionRate,
tradeExecutionMode, tradeExecutionMode,
}; };
@@ -73,7 +76,7 @@ export async function fetchStrategyEvaluationSignals(opts: {
const settings: BacktestSettingsDto = { const settings: BacktestSettingsDto = {
...btSettings, ...btSettings,
positionMode: 'SIGNAL_ONLY', positionMode,
tradeExecutionMode: 'BACKTEST_ENGINE', tradeExecutionMode: 'BACKTEST_ENGINE',
}; };