전략평가 메뉴 추가

This commit is contained in:
Macbook
2026-06-12 14:39:17 +09:00
parent cb1bde2563
commit ae9266bd28
23 changed files with 1977 additions and 19 deletions
@@ -7,7 +7,7 @@ public final class MenuIds {
private MenuIds() {} private MenuIds() {}
public static final List<String> ALL = List.of( public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "analysis-report", "notifications", "settings", "dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "strategy-evaluation", "backtest", "analysis-report", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live", "settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live",
"settings_trend-search", "settings_trend-search",
@@ -1,5 +1,6 @@
package com.goldenchart.controller; package com.goldenchart.controller;
import com.goldenchart.dto.LiveConditionEvaluateRequest;
import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.service.LiveConditionStatusService; import com.goldenchart.service.LiveConditionStatusService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -12,6 +13,7 @@ import java.util.Map;
* 가상투자 — 실시간 조건 충족 현황 API. * 가상투자 — 실시간 조건 충족 현황 API.
* *
* GET /api/strategy/live-conditions?market=KRW-BTC&amp;strategyId=1 * GET /api/strategy/live-conditions?market=KRW-BTC&amp;strategyId=1
* POST /api/strategy/live-conditions/evaluate — 지표 파라미터 오버라이드 (전략 평가)
*/ */
@RestController @RestController
@RequestMapping("/strategy/live-conditions") @RequestMapping("/strategy/live-conditions")
@@ -24,9 +26,28 @@ public class LiveConditionController {
public ResponseEntity<LiveConditionStatusDto> get( public ResponseEntity<LiveConditionStatusDto> get(
@RequestParam String market, @RequestParam String market,
@RequestParam long strategyId, @RequestParam long strategyId,
@RequestParam(required = false) Long barTimeSec,
@RequestHeader Map<String, String> headers) { @RequestHeader Map<String, String> headers) {
long uid = TradingControllerSupport.requireRegisteredUser(headers); long uid = TradingControllerSupport.requireRegisteredUser(headers);
String deviceId = headers.get("x-device-id"); String deviceId = headers.get("x-device-id");
return ResponseEntity.ok(service.evaluate(uid, deviceId, market, strategyId)); return ResponseEntity.ok(service.evaluate(uid, deviceId, market, strategyId, barTimeSec));
}
@PostMapping("/evaluate")
public ResponseEntity<LiveConditionStatusDto> evaluateAtBar(
@RequestBody LiveConditionEvaluateRequest body,
@RequestHeader Map<String, String> headers) {
long uid = TradingControllerSupport.requireRegisteredUser(headers);
String deviceId = headers.get("x-device-id");
if (body == null || body.getMarket() == null || body.getMarket().isBlank()) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(service.evaluate(
uid,
deviceId,
body.getMarket(),
body.getStrategyId(),
body.getBarTimeSec(),
body.getIndicatorParams()));
} }
} }
@@ -0,0 +1,15 @@
package com.goldenchart.dto;
import lombok.Data;
import java.util.Map;
/** 전략 평가 — 특정 봉 + 지표 파라미터 오버라이드 조건 평가 */
@Data
public class LiveConditionEvaluateRequest {
private String market;
private long strategyId;
private Long barTimeSec;
/** indicatorType → param map (DB 저장값 위에 덮어씀) */
private Map<String, Map<String, Object>> indicatorParams;
}
@@ -41,4 +41,9 @@ public class LiveConditionStatusDto {
private List<LiveConditionRowDto> rows; private List<LiveConditionRowDto> rows;
private long updatedAt; private long updatedAt;
/** 평가 기준 봉 시각(초) — null 이면 최신 봉 */
private Long barTimeSec;
/** barTimeSec 에 대응하는 시리즈 내 bar index */
private Integer evalBarIndex;
} }
@@ -53,6 +53,21 @@ public class LiveConditionStatusService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId, public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId) { String market, long strategyId) {
return evaluate(userId, deviceId, market, strategyId, null);
}
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId,
Long barTimeSec) {
return evaluate(userId, deviceId, market, strategyId, barTimeSec, null);
}
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId,
Long barTimeSec,
Map<String, Map<String, Object>> indicatorParamsOverride) {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId); Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty()) { if (opt.isEmpty()) {
return empty(market, strategyId); return empty(market, strategyId);
@@ -60,7 +75,9 @@ public class LiveConditionStatusService {
GcStrategy strategy = opt.get(); GcStrategy strategy = opt.get();
Map<String, Map<String, Object>> params = Map<String, Map<String, Object>> params =
indicatorSettingsService.getAll(userId, deviceId); mergeIndicatorParams(
indicatorSettingsService.getAll(userId, deviceId),
indicatorParamsOverride);
Map<String, Map<String, Object>> visual = Map<String, Map<String, Object>> visual =
indicatorSettingsService.getAllVisual(userId, deviceId); indicatorSettingsService.getAllVisual(userId, deviceId);
@@ -72,18 +89,20 @@ public class LiveConditionStatusService {
return empty(market, strategyId); return empty(market, strategyId);
} }
// 마켓 구독 + 비동기 warm-up 시작 — 현재 봉 유무에 따라 즉시 or 수집 후 평가 // 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보
int warmupBars = barTimeSec != null ? 500 : 60;
Set<String> timeframes = new LinkedHashSet<>(); Set<String> timeframes = new LinkedHashSet<>();
for (PendingCond p : pending) { for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
timeframes.add(tf); timeframes.add(tf);
subscriptionManager.ensureBarsReadySync(market, tf, 60); subscriptionManager.ensureBarsReadySync(market, tf, warmupBars);
} }
subscriptionManager.ensureBarsReadySync(market, "1m", 60); subscriptionManager.ensureBarsReadySync(market, "1m", warmupBars);
List<LiveConditionRowDto> rows = new ArrayList<>(); List<LiveConditionRowDto> rows = new ArrayList<>();
int met = 0; int met = 0;
int evaluable = 0; int evaluable = 0;
Integer primaryEvalIndex = null;
for (PendingCond p : pending) { for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
@@ -96,7 +115,8 @@ public class LiveConditionStatusService {
rows.add(toRowUnevaluated(p, visual)); rows.add(toRowUnevaluated(p, visual));
continue; continue;
} }
int index = series.getEndIndex(); int index = resolveBarIndex(series, barTimeSec);
if (primaryEvalIndex == null) primaryEvalIndex = index;
try { try {
ObjectNode wrapper = objectMapper.createObjectNode(); ObjectNode wrapper = objectMapper.createObjectNode();
wrapper.put("type", "CONDITION"); wrapper.put("type", "CONDITION");
@@ -128,9 +148,9 @@ public class LiveConditionStatusService {
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영 // [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
Boolean overallEntryMet = evaluateOverallRule( Boolean overallEntryMet = evaluateOverallRule(
strategy.getBuyConditionJson(), market, params, visual); strategy.getBuyConditionJson(), market, params, visual, barTimeSec);
Boolean overallExitMet = evaluateOverallRule( Boolean overallExitMet = evaluateOverallRule(
strategy.getSellConditionJson(), market, params, visual); strategy.getSellConditionJson(), market, params, visual, barTimeSec);
return LiveConditionStatusDto.builder() return LiveConditionStatusDto.builder()
.market(market) .market(market)
@@ -141,9 +161,36 @@ public class LiveConditionStatusService {
.overallExitMet(overallExitMet) .overallExitMet(overallExitMet)
.rows(rows) .rows(rows)
.updatedAt(System.currentTimeMillis()) .updatedAt(System.currentTimeMillis())
.barTimeSec(barTimeSec)
.evalBarIndex(primaryEvalIndex)
.build(); .build();
} }
/**
* barTimeSec(초)에 가장 가까운 bar index — null 이면 최신 봉.
* 차트·업비트 API 와 동일하게 봉 시작(open) 시각 기준으로 매칭한다.
*/
private int resolveBarIndex(BarSeries series, Long barTimeSec) {
if (barTimeSec == null) return series.getEndIndex();
int bestIdx = series.getEndIndex();
long bestDist = Long.MAX_VALUE;
for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) {
long openSec = barOpenEpochSec(series, i);
long dist = Math.abs(openSec - barTimeSec);
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
return bestIdx;
}
/** Ta4j Bar → 차트용 봉 시작 시각(초) — BacktestingService.barStartEpoch 와 동일 */
private static long barOpenEpochSec(BarSeries series, int index) {
var bar = series.getBar(index);
return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
}
private LiveConditionStatusDto empty(String market, long strategyId) { private LiveConditionStatusDto empty(String market, long strategyId) {
return LiveConditionStatusDto.builder() return LiveConditionStatusDto.builder()
.market(market) .market(market)
@@ -169,7 +216,8 @@ public class LiveConditionStatusService {
*/ */
private Boolean evaluateOverallRule(String conditionJson, String market, private Boolean evaluateOverallRule(String conditionJson, String market,
Map<String, Map<String, Object>> params, Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual) { Map<String, Map<String, Object>> visual,
Long barTimeSec) {
if (conditionJson == null || conditionJson.isBlank()) return null; if (conditionJson == null || conditionJson.isBlank()) return null;
try { try {
com.fasterxml.jackson.databind.JsonNode dsl = com.fasterxml.jackson.databind.JsonNode dsl =
@@ -191,7 +239,7 @@ public class LiveConditionStatusService {
new StrategyDslToTa4jAdapter.RuleBuildContext( new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, ta4jStorage, false, java.util.Map.of()); series, params, visual, market, ta4jStorage, false, java.util.Map.of());
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx); org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
return rule.isSatisfied(series.getEndIndex(), null); return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
} catch (Exception e) { } catch (Exception e) {
log.debug("[LiveCondition] overall rule eval fail market={}: {}", market, e.getMessage()); log.debug("[LiveCondition] overall rule eval fail market={}: {}", market, e.getMessage());
return null; return null;
@@ -421,4 +469,24 @@ public class LiveConditionStatusService {
return indicatorType; return indicatorType;
} }
private static Map<String, Map<String, Object>> mergeIndicatorParams(
Map<String, Map<String, Object>> base,
Map<String, Map<String, Object>> override) {
if (override == null || override.isEmpty()) return base;
if (base == null || base.isEmpty()) return override;
Map<String, Map<String, Object>> merged = new LinkedHashMap<>(base);
override.forEach((type, overrideParams) -> {
Map<String, Object> baseParams = merged.get(type);
if (baseParams == null || baseParams.isEmpty()) {
merged.put(type, overrideParams);
} else {
Map<String, Object> inner = new LinkedHashMap<>(baseParams);
inner.putAll(overrideParams);
merged.put(type, inner);
}
});
return merged;
}
} }
+12 -1
View File
@@ -71,6 +71,7 @@ const AnalysisReportPage = lazy(() => import('./components/analysisReport/Analys
const SettingsPage = lazy(() => import('./components/SettingsPage')); const SettingsPage = lazy(() => import('./components/SettingsPage'));
const PaperTradingPage = lazy(() => import('./components/PaperTradingPage')); const PaperTradingPage = lazy(() => import('./components/PaperTradingPage'));
const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage')); const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage'));
const StrategyEvaluationPage = lazy(() => import('./components/StrategyEvaluationPage'));
const TrendSearchPage = lazy(() => import('./components/TrendSearchPage')); const TrendSearchPage = lazy(() => import('./components/TrendSearchPage'));
const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage')); const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage'));
@@ -231,6 +232,16 @@ function AppMainContent({
: <FormalLoginGate onLogin={requireFormalLogin} /> : <FormalLoginGate onLogin={requireFormalLogin} />
)} )}
{menuPage === 'strategy-evaluation' && (
formalLogin
? (
<Suspense fallback={<PageLoadFallback />}>
<StrategyEvaluationPage theme={theme} />
</Suspense>
)
: <FormalLoginGate onLogin={requireFormalLogin} title="전략 평가" />
)}
{menuPage === 'backtest' && ( {menuPage === 'backtest' && (
formalLogin formalLogin
? ( ? (
@@ -505,7 +516,7 @@ function App() {
}, [menuPage]); }, [menuPage]);
// 지표 설정: 차트·전략·알림 관련 페이지 첫 진입 시 로드 (이후 캐시 유지) // 지표 설정: 차트·전략·알림 관련 페이지 첫 진입 시 로드 (이후 캐시 유지)
const INDICATOR_SETTINGS_PAGES: MenuPage[] = ['chart', 'strategy-editor', 'notifications', 'strategy', 'backtest', 'analysis-report', 'settings']; const INDICATOR_SETTINGS_PAGES: MenuPage[] = ['chart', 'strategy-editor', 'strategy-evaluation', 'notifications', 'strategy', 'backtest', 'analysis-report', 'settings'];
const [indicatorSettingsEnabled, setIndicatorSettingsEnabled] = useState( const [indicatorSettingsEnabled, setIndicatorSettingsEnabled] = useState(
() => INDICATOR_SETTINGS_PAGES.includes(menuPage), () => INDICATOR_SETTINGS_PAGES.includes(menuPage),
); );
@@ -0,0 +1,370 @@
/**
* 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석
*/
import '../styles/strategyEvaluation.css';
import '../styles/backtestDashboard.css';
import '../styles/virtualTradingDashboard.css';
import '../styles/strategyEditorTheme.css';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Theme, Timeframe } from '../types';
import {
loadStrategies,
loadStrategy,
type StrategyDto,
} from '../utils/backendApi';
import { hydrateStrategyDto } from '../utils/strategyHydrate';
import { repairUtf8Mojibake } from '../utils/textEncoding';
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot';
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel';
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import { getKoreanName } from '../utils/marketNameCache';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import {
mergeEvalGetParams,
type EvalIndicatorParams,
} from '../utils/strategyEvaluationParams';
import {
BACKTEST_STRATEGY_TIMEFRAME,
buildQuickRunTimeframeSelectOptions,
resolveBacktestExecTimeframe,
type BacktestRunTimeframeChoice,
} from '../utils/backtestRunTimeframe';
import type { OHLCVBar } from '../types';
const LEFT_KEY = 'seval-left-width';
const RIGHT_KEY = 'seval-right-width';
const LEFT_DEFAULT = 380;
const RIGHT_DEFAULT = 440;
type LeftTab = 'strategy' | 'settings';
function readMinWidth(key: string, fallback: number): number {
return Math.max(readStoredSize(key, fallback), fallback);
}
interface Props {
theme?: Theme;
}
export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const { getParams: baseGetParams, getVisualConfig } = useIndicatorSettings();
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [strategySearch, setStrategySearch] = useState('');
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
const [selectedStrategy, setSelectedStrategy] = useState<StrategyDto | null>(null);
const [market, setMarket] = useState('KRW-BTC');
const [timeframeChoice, setTimeframeChoice] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
const [evalLoading, setEvalLoading] = useState(false);
const [evalError, setEvalError] = useState<string | null>(null);
const [appliedIndicatorParams, setAppliedIndicatorParams] = useState<EvalIndicatorParams | null>(null);
const [paramsRevision, setParamsRevision] = useState(0);
const getEvalParams = useMemo(
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
[baseGetParams, appliedIndicatorParams],
);
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
const leftRef = useRef(leftWidth);
const rightRef = useRef(rightWidth);
leftRef.current = leftWidth;
rightRef.current = rightWidth;
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v));
const onRightSplit = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const startX = e.clientX;
const start = rightRef.current;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('btd-splitter--active');
const splitter = e.currentTarget;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
setRightWidth(Math.min(600, Math.max(360, start - delta)));
};
const onUp = (ev: PointerEvent) => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
splitter.releasePointerCapture(ev.pointerId);
splitter.classList.remove('btd-splitter--active');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
storeSize(RIGHT_KEY, rightRef.current);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}, []);
const strategyPrimaryTimeframe = useMemo(
() => (selectedStrategy ? resolveStrategyPrimaryTimeframe(selectedStrategy) : undefined),
[selectedStrategy],
);
const chartTimeframe = useMemo((): Timeframe => {
return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe ?? '3m');
}, [timeframeChoice, strategyPrimaryTimeframe]);
const tfOptions = useMemo(
() => buildQuickRunTimeframeSelectOptions(strategyPrimaryTimeframe),
[strategyPrimaryTimeframe],
);
useEffect(() => {
void loadStrategies().then(list => setStrategies(list ?? []));
}, []);
useEffect(() => {
if (!selectedStrategyId) {
setSelectedStrategy(null);
return;
}
let cancelled = false;
void loadStrategy(selectedStrategyId).then(s => {
if (cancelled) return;
setSelectedStrategy(s ? hydrateStrategyDto(s) : null);
});
return () => { cancelled = true; };
}, [selectedStrategyId]);
const filteredStrategies = useMemo(() => {
const q = strategySearch.trim().toLowerCase();
if (!q) return strategies;
return strategies.filter(s =>
(s.name ?? '').toLowerCase().includes(q)
|| String(s.id ?? '').includes(q),
);
}, [strategies, strategySearch]);
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
const refreshEvaluation = useCallback(async () => {
if (!selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) {
setSnapshot(undefined);
return;
}
setEvalLoading(true);
setEvalError(null);
try {
const snap = await fetchEvaluationSnapshotAtBar(
market,
selectedStrategyId,
selectedStrategy,
selectedBarTimeSec,
appliedIndicatorParams,
);
setSnapshot(snap ?? undefined);
} catch (e) {
setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
setSnapshot(undefined);
} finally {
setEvalLoading(false);
}
}, [market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, appliedIndicatorParams]);
useEffect(() => {
void refreshEvaluation();
}, [refreshEvaluation]);
const handleSelectStrategy = useCallback((s: StrategyDto) => {
if (s.id == null) return;
setSelectedStrategyId(s.id);
setSelectedStrategy(hydrateStrategyDto(s));
setAppliedIndicatorParams(null);
setParamsRevision(v => v + 1);
setLeftTab('strategy');
}, []);
const handleSaveIndicatorParams = useCallback((params: EvalIndicatorParams) => {
setAppliedIndicatorParams(params);
setParamsRevision(v => v + 1);
}, []);
const handleResetIndicatorParams = useCallback(() => {
setAppliedIndicatorParams(null);
setParamsRevision(v => v + 1);
}, []);
const strategyLabel = selectedStrategy?.name
? repairUtf8Mojibake(selectedStrategy.name)
: '전략 미선택';
return (
<div className={`btd-page se-page se-page--${theme}`}>
<header className="btd-header">
<div className="btd-header-brand">
<h1 className="btd-header-title"> </h1>
<span className="btd-header-sub">Strategy Match Analysis</span>
</div>
</header>
<div className="btd-body">
<aside className="btd-sidebar" style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}>
<div className="btd-exec-list seval-left-panel">
<div className="btd-exec-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={leftTab === 'strategy'}
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
onClick={() => setLeftTab('strategy')}
>
</button>
<button
type="button"
role="tab"
aria-selected={leftTab === 'settings'}
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
onClick={() => setLeftTab('settings')}
disabled={!selectedStrategy}
>
</button>
</div>
{leftTab === 'strategy' ? (
<>
<div className="btd-exec-search">
<span className="btd-exec-search-icon" aria-hidden></span>
<input
className="btd-exec-search-input"
placeholder="전략 검색…"
value={strategySearch}
onChange={e => setStrategySearch(e.target.value)}
/>
{strategySearch && (
<button
type="button"
className="btd-exec-search-clear"
onClick={() => setStrategySearch('')}
aria-label="검색어 지우기"
>
×
</button>
)}
</div>
<div className="btd-exec-scroll">
{filteredStrategies.length === 0 ? (
<p className="btd-sidebar-empty"> </p>
) : (
filteredStrategies.map(s => {
const name = repairUtf8Mojibake(s.name ?? `전략 #${s.id}`);
const active = selectedStrategyId === s.id;
return (
<button
key={s.id}
type="button"
className={`btd-exec-item${active ? ' btd-exec-item--active' : ''}`}
onClick={() => handleSelectStrategy(s)}
>
<div className="btd-exec-item-top">
<span className="btd-exec-strategy">{name}</span>
</div>
{s.description && (
<p className="btd-exec-meta">{repairUtf8Mojibake(s.description)}</p>
)}
</button>
);
})
)}
</div>
</>
) : (
<StrategyEvaluationSettingsTab
strategy={selectedStrategy}
getParams={baseGetParams}
appliedParams={appliedIndicatorParams}
onSave={handleSaveIndicatorParams}
onReset={handleResetIndicatorParams}
/>
)}
</div>
</aside>
<div
className="btd-splitter btd-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="좌측 패널 너비"
onPointerDown={onLeftSplit}
/>
<main className="btd-main">
<div className="btd-main-content seval-main-content">
<StrategyEvaluationChart
market={market}
timeframe={chartTimeframe}
timeframeChoice={timeframeChoice}
tfOptions={tfOptions}
onMarketChange={setMarket}
onTimeframeChoiceChange={setTimeframeChoice}
theme={theme}
strategy={selectedStrategy}
selectedBarIndex={selectedBarIndex}
onSelectedBarIndexChange={setSelectedBarIndex}
onBarsLoaded={setBars}
paramsRevision={paramsRevision}
getParamsOverride={getEvalParams}
getVisualConfigOverride={getVisualConfig}
/>
</div>
</main>
<div
className="btd-splitter btd-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비"
onPointerDown={onRightSplit}
/>
<aside className="btd-right seval-right-panel" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
<div className="seval-signal-head">
<span className="seval-signal-market">{getKoreanName(market)}</span>
<span className="seval-live-dot" title="선택 봉 기준 평가" />
<span className="seval-live-label"> </span>
</div>
{evalError && <p className="seval-eval-error">{evalError}</p>}
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode="summary"
loading={evalLoading}
hasStrategy={selectedStrategyId != null}
className="seval-signal-panel"
/>
<footer className="seval-signal-footer">
<span className="seval-updated">
{selectedBarTimeSec != null
? new Date(selectedBarTimeSec * 1000).toLocaleString('ko-KR', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '—'}
{evalLoading ? ' · 평가 중…' : snapshot?.updatedAt
? ` · 갱신 ${new Date(snapshot.updatedAt).toLocaleTimeString('ko-KR')}`
: ''}
</span>
<span className="seval-strategy-chip">{strategyLabel}</span>
</footer>
</aside>
</div>
</div>
);
}
+14 -1
View File
@@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions'; import { canAccessMenu } from '../utils/permissions';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'analysis-report' | 'notifications' | 'settings'; export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings';
interface TopMenuBarProps { interface TopMenuBarProps {
activePage: MenuPage; activePage: MenuPage;
@@ -152,6 +152,18 @@ const IcStrategyEditor = () => (
</svg> </svg>
); );
const IcStrategyEvaluation = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="1.5" width="5" height="13" rx="1"/>
<line x1="3.5" y1="13" x2="3.5" y2="7"/>
<line x1="5.5" y1="13" x2="5.5" y2="5"/>
<rect x="8.5" y="3" width="5.5" height="3" rx="0.8"/>
<rect x="8.5" y="7.5" width="5.5" height="3" rx="0.8"/>
<rect x="8.5" y="12" width="5.5" height="2.5" rx="0.8"/>
<polyline points="13.5,4.5 12.5,5.5 11,4"/>
</svg>
);
const IcPaper = () => ( const IcPaper = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1.5" y="4" width="13" height="9" rx="1.5"/> <rect x="1.5" y="4" width="13" height="9" rx="1.5"/>
@@ -191,6 +203,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> }, { page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> }, { page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> }, { page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'strategy-evaluation', label: '전략 평가', icon: <IcStrategyEvaluation /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> }, { page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'analysis-report', label: '분석레포트', icon: <IcAnalysisReport /> }, { page: 'analysis-report', label: '분석레포트', icon: <IcAnalysisReport /> },
{ page: 'notifications', label: '알림목록', icon: <IcNotify /> }, { page: 'notifications', label: '알림목록', icon: <IcNotify /> },
+1 -1
View File
@@ -1122,7 +1122,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const onPanPointerDown = (e: PointerEvent) => { const onPanPointerDown = (e: PointerEvent) => {
if (!canPanRef.current || e.button !== 0) return; if (!canPanRef.current || e.button !== 0) return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (target?.closest('.chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) { if (target?.closest('[data-no-chart-pan], .chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) {
return; return;
} }
const pt = isChartPlotPointer(e.clientX, e.clientY); const pt = isChartPlotPointer(e.clientX, e.clientY);
@@ -0,0 +1,186 @@
/**
* 전략 평가 — 캔들 pane 세로 선택 영역 (드래그로 봉 이동)
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import type { ChartManager } from '../../utils/ChartManager';
import type { OHLCVBar } from '../../types';
import { findNearestBarIndex } from '../../utils/analysisChartData';
interface Props {
manager: ChartManager;
bars: OHLCVBar[];
selectedBarIndex: number;
onSelectedBarIndexChange: (index: number) => void;
}
interface BarBand {
left: number;
width: number;
top: number;
height: number;
}
function resolveBarBand(
manager: ChartManager,
bars: OHLCVBar[],
index: number,
): BarBand | null {
if (index < 0 || index >= bars.length) return null;
const main = manager.getPaneLayouts().find(l => l.paneIndex === 0);
if (!main || main.height < 8) return null;
const centerX = manager.timeToX(bars[index].time);
if (centerX == null || !Number.isFinite(centerX)) return null;
let halfWidth = 7;
if (index > 0) {
const prevX = manager.timeToX(bars[index - 1].time);
if (prevX != null) halfWidth = Math.max(5, Math.abs(centerX - prevX) * 0.42);
}
if (index < bars.length - 1) {
const nextX = manager.timeToX(bars[index + 1].time);
if (nextX != null) halfWidth = Math.max(halfWidth, Math.abs(nextX - centerX) * 0.42);
}
const plotWidth = manager.getCandlePaneTimeAxisBand()?.plotWidth
?? Math.max(40, manager.getContainer().clientWidth - 64);
const width = Math.min(Math.max(halfWidth * 2, 10), 48);
const left = Math.max(0, Math.min(plotWidth - width, centerX - width / 2));
return {
left,
width,
top: main.topY,
height: main.height,
};
}
function indexFromClientX(
manager: ChartManager,
bars: OHLCVBar[],
clientX: number,
): number {
const rect = manager.getContainer().getBoundingClientRect();
const chartX = clientX - rect.left;
const time = manager.xToTime(chartX);
if (time == null) return -1;
return findNearestBarIndex(bars, time);
}
const StrategyEvaluationBarSelector: React.FC<Props> = ({
manager,
bars,
selectedBarIndex,
onSelectedBarIndexChange,
}) => {
const [band, setBand] = useState<BarBand | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const draggingRef = useRef(false);
const displayIndex = dragIndex ?? selectedBarIndex;
const refreshBand = useCallback(() => {
setBand(resolveBarBand(manager, bars, displayIndex));
}, [manager, bars, displayIndex]);
useEffect(() => {
refreshBand();
const ro = new ResizeObserver(refreshBand);
ro.observe(manager.getContainer());
let raf = 0;
const schedule = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(refreshBand);
};
const unsubLayout = manager.subscribePaneLayout(schedule);
const unsubViewport = manager.subscribeViewport(schedule);
const timers = [0, 80, 200].map(ms => window.setTimeout(refreshBand, ms));
return () => {
ro.disconnect();
cancelAnimationFrame(raf);
unsubLayout();
unsubViewport();
timers.forEach(clearTimeout);
};
}, [manager, refreshBand]);
const finishDrag = useCallback((clientX: number) => {
draggingRef.current = false;
setDragIndex(null);
const idx = indexFromClientX(manager, bars, clientX);
if (idx >= 0 && idx !== selectedBarIndex) {
onSelectedBarIndexChange(idx);
}
}, [manager, bars, selectedBarIndex, onSelectedBarIndexChange]);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
draggingRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
if (!draggingRef.current) return;
ev.preventDefault();
ev.stopPropagation();
const idx = indexFromClientX(manager, bars, ev.clientX);
if (idx >= 0) setDragIndex(idx);
};
const onUp = (ev: PointerEvent) => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
finishDrag(ev.clientX);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
}, [manager, bars, finishDrag]);
if (!band || bars.length === 0) return null;
const bar = bars[displayIndex];
const timeLabel = bar
? new Date(bar.time * 1000).toLocaleString('ko-KR', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '';
return ReactDOM.createPortal(
<div className="seval-bar-selector-layer" aria-hidden>
<div
data-no-chart-pan
className={`seval-bar-selector${dragIndex != null ? ' seval-bar-selector--dragging' : ''}`}
style={{
left: `${band.left}px`,
top: `${band.top}px`,
width: `${band.width}px`,
height: `${band.height}px`,
}}
onPointerDown={onPointerDown}
title="드래그하여 분석 봉 이동"
>
<div className="seval-bar-selector__shine" />
<div className="seval-bar-selector__handle" />
{timeLabel && (
<div className="seval-bar-selector__label">{timeLabel}</div>
)}
</div>
</div>,
manager.getContainer(),
);
};
export default StrategyEvaluationBarSelector;
@@ -0,0 +1,456 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import TradingChart from '../TradingChart';
import { MarketSearchPanel } from '../MarketSearchPanel';
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
import { loadAnalysisCandles } from '../../utils/analysisChartData';
import type { ChartManager } from '../../utils/ChartManager';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useAppSettings } from '../../hooks/useAppSettings';
import {
chartPaneFlexRatio,
countNonOverlayIndicatorPanes,
} from '../../utils/strategyOscillatorSeries';
import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { getKoreanName } from '../../utils/marketNameCache';
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
import { loadBacktestSettings, runBacktest } from '../../utils/backendApi';
import { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartIndicators';
import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams';
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
import StrategyEvaluationBarSelector from './StrategyEvaluationBarSelector';
import {
parseBacktestRunTimeframeChoice,
type BacktestRunTimeframeChoice,
} from '../../utils/backtestRunTimeframe';
interface Props {
market: string;
timeframe: Timeframe;
timeframeChoice: BacktestRunTimeframeChoice;
tfOptions: { value: string; label: string }[];
onMarketChange: (market: string) => void;
onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void;
theme?: Theme;
strategy: StrategyDto | null;
selectedBarIndex: number;
onSelectedBarIndexChange: (index: number) => void;
onBarsLoaded?: (bars: OHLCVBar[]) => void;
paramsRevision?: number;
getParamsOverride?: (
type: string,
defaults?: Record<string, number | string | boolean>,
) => Record<string, number | string | boolean>;
getVisualConfigOverride?: (
type: string,
plots: import('../../utils/indicatorRegistry').PlotDef[],
hlines: import('../../utils/indicatorRegistry').HLineDef[],
) => {
plots: import('../../utils/indicatorRegistry').PlotDef[];
hlines: import('../../utils/indicatorRegistry').HLineDef[];
cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors;
};
}
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
function isKeyboardTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
const StrategyEvaluationChart: React.FC<Props> = ({
market,
timeframe,
timeframeChoice,
tfOptions,
onMarketChange,
onTimeframeChoiceChange,
theme = 'dark',
strategy,
selectedBarIndex,
onSelectedBarIndexChange,
onBarsLoaded,
paramsRevision = 0,
getParamsOverride,
getVisualConfigOverride,
}) => {
const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings();
const getParams = getParamsOverride ?? baseGetParams;
const getVisualConfig = getVisualConfigOverride ?? baseGetVisual;
const { defaults: appDefaults } = useAppSettings();
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
const [showMarketSearch, setShowMarketSearch] = useState(false);
const [marketQuery, setMarketQuery] = useState('');
const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [backtestRunning, setBacktestRunning] = useState(false);
const [backtestError, setBacktestError] = useState<string | null>(null);
const marketBtnRef = useRef<HTMLButtonElement>(null);
const managerRef = useRef<ChartManager | null>(null);
const backtestGenRef = useRef(0);
const signalsRef = useRef(signals);
signalsRef.current = signals;
const chartType: ChartType = 'candlestick';
const indicators = useMemo(() => {
if (!strategy) return [] as IndicatorConfig[];
return buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
}, [strategy, getParams, getVisualConfig]);
const auxPaneCount = useMemo(
() => countNonOverlayIndicatorPanes(indicators, isOverlayType),
[indicators],
);
const paneAreaRatio = useMemo(() => {
if (auxPaneCount <= 0) return null;
const flex = chartPaneFlexRatio(auxPaneCount);
return { candle: flex.candle, aux: flex.aux };
}, [auxPaneCount]);
const indicatorParams = useMemo(
() => (strategy ? buildEvalParamsFromStrategy(strategy, getParams) : {}),
[strategy, getParams],
);
const applyMarkers = useCallback((attempt = 0) => {
const mgr = managerRef.current;
if (!mgr?.hasMainSeries()) {
if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1));
return;
}
if (!strategy) {
mgr.clearBacktestMarkers();
return;
}
mgr.setBacktestMarkers(signalsRef.current, true);
}, [strategy]);
const runBacktestForBars = useCallback(async (barData: OHLCVBar[]) => {
if (!strategy?.id || barData.length < 10) {
setSignals([]);
managerRef.current?.clearBacktestMarkers();
return;
}
const gen = ++backtestGenRef.current;
setBacktestRunning(true);
setBacktestError(null);
try {
const btSettings = await loadBacktestSettings();
const { evaluationBarCount } = resolveEvaluationFromLoadedBars(
barData.length,
strategy.buyCondition,
strategy.sellCondition,
indicatorParams,
);
const res = await runBacktest({
strategyId: strategy.id,
bars: barData.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe,
symbol: market,
strategyName: strategy.name,
settings: btSettings,
indicatorParams,
evaluationBarCount,
});
if (gen !== backtestGenRef.current) return;
if (!res) {
setSignals([]);
setBacktestError('시그널 계산에 실패했습니다.');
managerRef.current?.clearBacktestMarkers();
return;
}
setSignals(res.signals);
applyMarkers();
} catch (e) {
if (gen !== backtestGenRef.current) return;
setSignals([]);
setBacktestError(e instanceof Error ? e.message : '시그널 계산 실패');
managerRef.current?.clearBacktestMarkers();
} finally {
if (gen === backtestGenRef.current) setBacktestRunning(false);
}
}, [strategy, market, timeframe, indicatorParams, applyMarkers]);
useEffect(() => {
if (!strategy?.id || loading || bars.length < 10) {
if (!strategy?.id) {
++backtestGenRef.current;
setSignals([]);
setBacktestError(null);
setBacktestRunning(false);
managerRef.current?.clearBacktestMarkers();
}
return;
}
void runBacktestForBars(bars);
return () => { ++backtestGenRef.current; };
}, [strategy?.id, bars, loading, runBacktestForBars]);
useEffect(() => {
applyMarkers();
}, [signals, applyMarkers]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const toTimeSec = Math.floor(Date.now() / 1000);
void loadAnalysisCandles(market, timeframe, toTimeSec, 300)
.then(data => {
if (cancelled) return;
setBars(data);
onBarsLoaded?.(data);
if (data.length > 0) {
onSelectedBarIndexChange(data.length - 1);
}
})
.catch(e => {
if (cancelled) return;
setError(e instanceof Error ? e.message : '차트 로드 실패');
setBars([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [market, timeframe, onBarsLoaded, onSelectedBarIndexChange]);
const scrollToBar = useCallback((barIdx: number) => {
const mgr = chartMgr;
if (!mgr || barIdx < 0 || barIdx >= bars.length) return;
const bar = bars[barIdx];
const barSec = Math.max(60, 180);
mgr.applyVisibleTimeRange(bar.time - barSec * 24, bar.time + barSec * 8);
}, [chartMgr, bars]);
useEffect(() => {
if (bars.length === 0) return;
scrollToBar(Math.min(Math.max(0, selectedBarIndex), bars.length - 1));
}, [selectedBarIndex, bars, scrollToBar]);
const onManagerReady = useCallback((mgr: ChartManager) => {
managerRef.current = mgr;
setChartMgr(mgr);
if (paneAreaRatio && paneAreaRatio.aux > 0) {
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
}
applyMarkers();
}, [paneAreaRatio, applyMarkers]);
const onCandlesReady = useCallback(() => {
const mgr = managerRef.current;
if (!mgr) return;
if (paneAreaRatio && paneAreaRatio.aux > 0) {
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
}
applyMarkers();
}, [paneAreaRatio, applyMarkers]);
const stepBar = useCallback((delta: number) => {
if (bars.length === 0) return;
const next = Math.min(bars.length - 1, Math.max(0, selectedBarIndex + delta));
onSelectedBarIndexChange(next);
}, [bars.length, selectedBarIndex, onSelectedBarIndexChange]);
const goLatest = useCallback(() => {
if (bars.length === 0) return;
onSelectedBarIndexChange(bars.length - 1);
}, [bars.length, onSelectedBarIndexChange]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (isKeyboardTypingTarget(e.target)) return;
if (bars.length === 0) return;
if (e.key === 'ArrowLeft') {
if (selectedBarIndex <= 0) return;
e.preventDefault();
stepBar(-1);
return;
}
if (selectedBarIndex >= bars.length - 1) return;
e.preventDefault();
stepBar(1);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [bars.length, selectedBarIndex, stepBar]);
const selectedBar = bars[selectedBarIndex] ?? null;
return (
<div className="seval-chart btd-analysis-chart">
<div className="seval-chart-toolbar btd-analysis-toolbar">
<div className="seval-chart-toolbar-run">
<button
ref={marketBtnRef}
type="button"
className="btd-run-market-btn"
title="종목 검색"
onClick={() => {
setMarketQuery('');
setShowMarketSearch(v => !v);
}}
>
<span className="btd-run-market-ko">{getKoreanName(market)}</span>
<span className="btd-run-market-sym">{market.replace('KRW-', '')}</span>
<svg className="btd-run-market-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<select
className="btd-run-select"
value={timeframeChoice}
onChange={e => onTimeframeChoiceChange(parseBacktestRunTimeframeChoice(e.target.value))}
title="차트 시간봉"
>
{tfOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{selectedBar && (
<span className="seval-chart-bar-time">
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
</span>
)}
{strategy && signals.length > 0 && !backtestRunning && (
<span className="seval-chart-signal-count" title="백테스트 매매 시그널">
{signals.length}
</span>
)}
{strategy && backtestRunning && (
<span className="seval-chart-backtest-busy"> </span>
)}
</div>
<span className="seval-chart-hint"> · · </span>
</div>
<div className="btd-analysis-main">
<div className="seval-chart-body btd-analysis-canvas-wrap btd-analysis-canvas-wrap--live">
{loading && <div className="seval-chart-status"> </div>}
{error && !loading && <div className="btd-analysis-error">{error}</div>}
{backtestError && !loading && !backtestRunning && !error && (
<div className="seval-chart-status seval-chart-status--error">{backtestError}</div>
)}
{!loading && !error && bars.length > 0 && (
<>
<TradingChart
key={`${market}-${timeframe}-${strategy?.id ?? 0}-${paramsRevision}-${indicators.map(i => i.id).join(',')}`}
bars={bars}
barsMarket={market}
market={market}
timeframe={timeframe}
chartType={chartType}
theme={theme}
mode="chart"
indicators={indicators}
drawingTool="cursor"
drawings={[]}
logScale={false}
onCrosshair={() => {}}
onManagerReady={onManagerReady}
onCandlesReady={onCandlesReady}
onAddDrawing={() => {}}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
volumeVisible={false}
paneSeparatorOptions={appDefaults.chartPaneSeparator}
paneLayoutClamp
showPaneResizeHandle={auxPaneCount > 0}
paneAreaRatio={paneAreaRatio}
showPaneLegend
crosshairInfoVisible={false}
showHoverToolbar={false}
showChartRightToolbar={false}
/>
{chartMgr && (
<StrategyEvaluationBarSelector
manager={chartMgr}
bars={bars}
selectedBarIndex={selectedBarIndex}
onSelectedBarIndexChange={onSelectedBarIndexChange}
/>
)}
</>
)}
{!loading && !error && bars.length === 0 && (
<div className="btd-analysis-error"> .</div>
)}
</div>
</div>
<div className="seval-chart-bottom-toolbar">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="이전 봉 (←)"
onClick={() => stepBar(-1)}
disabled={selectedBarIndex <= 0 || bars.length === 0}
>
</button>
<span className="seval-chart-count">
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
</span>
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="다음 봉 (→)"
onClick={() => stepBar(1)}
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
>
</button>
<span className="seval-chart-bottom-divider" aria-hidden />
<button
type="button"
className="btd-btn btd-btn--ghost"
title="최신 봉"
onClick={goLatest}
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
>
</button>
</div>
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={market}
query={marketQuery}
onQueryChange={setMarketQuery}
onSelect={m => {
onMarketChange(m);
setShowMarketSearch(false);
setMarketQuery('');
}}
onClose={() => {
setShowMarketSearch(false);
setMarketQuery('');
}}
anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null}
anchorPlacement="dropdown"
/>,
document.body,
)}
</div>
);
};
export default StrategyEvaluationChart;
@@ -0,0 +1,187 @@
/**
* 전략 평가 — 좌측 패널 전략설정 탭 (지표 파라미터 테스트)
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { StrategyDto } from '../../utils/backendApi';
import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { getGlobalParamKeys, getPlotParamKeys } from '../../utils/indicatorSettingsLayout';
import { GlobalParamRow } from '../IndicatorSettingsSections';
import { collectStrategyRegistryTypes } from '../../utils/strategyToChartIndicators';
import {
buildEvalParamsFromStrategy,
hasEvalParamsDiff,
type EvalIndicatorParams,
} from '../../utils/strategyEvaluationParams';
import { repairUtf8Mojibake } from '../../utils/textEncoding';
interface Props {
strategy: StrategyDto | null;
getParams: (type: string) => Record<string, number | string | boolean>;
appliedParams: EvalIndicatorParams | null;
onSave: (params: EvalIndicatorParams) => void;
onReset: () => void;
}
function getEditableParamKeys(
indicatorType: string,
params: Record<string, number | string | boolean>,
): string[] {
const def = getIndicatorDef(indicatorType);
const plots = def?.plots ?? [];
const keys = new Set<string>();
for (const k of getGlobalParamKeys(indicatorType, params, plots)) keys.add(k);
plots.forEach((p, i) => {
for (const k of getPlotParamKeys(indicatorType, p.id, i, plots, params)) keys.add(k);
});
if (keys.size === 0) {
Object.keys(params).forEach(k => {
if (k !== 'symbolMode' && k !== 'refSymbol') keys.add(k);
});
}
return [...keys];
}
const StrategyEvaluationSettingsTab: React.FC<Props> = ({
strategy,
getParams,
appliedParams,
onSave,
onReset,
}) => {
const [draft, setDraft] = useState<EvalIndicatorParams>({});
const [expanded, setExpanded] = useState<string | null>(null);
const indicatorTypes = useMemo(
() => collectStrategyRegistryTypes(strategy ?? undefined),
[strategy],
);
useEffect(() => {
if (!strategy) {
setDraft({});
setExpanded(null);
return;
}
const baseline = appliedParams ?? buildEvalParamsFromStrategy(strategy, getParams);
setDraft(JSON.parse(JSON.stringify(baseline)) as EvalIndicatorParams);
setExpanded(indicatorTypes[0] ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [strategy?.id, appliedParams, indicatorTypes.join(',')]);
const dirty = useMemo(
() => hasEvalParamsDiff(draft, appliedParams),
[draft, appliedParams],
);
const patchParam = useCallback((type: string, key: string, raw: string | boolean) => {
setDraft(prev => {
const current = prev[type] ?? {};
const oldVal = current[key];
const defVal = getIndicatorDef(type)?.defaultParams?.[key];
let nextVal: number | string | boolean;
if (typeof oldVal === 'number' || typeof defVal === 'number') {
const n = parseFloat(raw as string);
const fallback = typeof oldVal === 'number' ? oldVal : (defVal as number);
nextVal = Number.isNaN(n) ? fallback : n;
} else if (typeof oldVal === 'boolean' || typeof defVal === 'boolean') {
nextVal = raw as boolean;
} else {
nextVal = raw as string;
}
return {
...prev,
[type]: { ...current, [key]: nextVal },
};
});
}, []);
const resetType = useCallback((type: string) => {
setDraft(prev => ({
...prev,
[type]: { ...getParams(type) },
}));
}, [getParams]);
if (!strategy) {
return <p className="btd-sidebar-empty"> </p>;
}
if (indicatorTypes.length === 0) {
return <p className="btd-sidebar-empty"> </p>;
}
const strategyName = repairUtf8Mojibake(strategy.name ?? `전략 #${strategy.id}`);
return (
<div className="seval-settings-tab">
<div className="seval-settings-head">
<p className="seval-settings-strategy">{strategyName}</p>
<p className="seval-settings-hint"> · </p>
</div>
<div className="seval-settings-list">
{indicatorTypes.map(type => {
const def = getIndicatorDef(type);
const params = draft[type] ?? {};
const paramKeys = getEditableParamKeys(type, params);
const isOpen = expanded === type;
const label = def?.koreanName ?? def?.shortName ?? type;
return (
<div key={type} className={`seval-settings-card${isOpen ? ' seval-settings-card--open' : ''}`}>
<button
type="button"
className="seval-settings-card-head"
onClick={() => setExpanded(isOpen ? null : type)}
>
<span className="seval-settings-card-title">{label}</span>
<span className="seval-settings-card-type">{type}</span>
</button>
{isOpen && (
<div className="seval-settings-card-body">
{paramKeys.map(key => (
<GlobalParamRow
key={key}
indicatorType={type}
paramKey={key}
value={params[key] ?? getParams(type)[key]}
onChange={(k, raw) => patchParam(type, k, raw)}
/>
))}
<button
type="button"
className="seval-settings-reset-type"
onClick={() => resetType(type)}
>
</button>
</div>
)}
</div>
);
})}
</div>
<div className="seval-settings-actions">
<button
type="button"
className="seval-settings-save"
disabled={!dirty}
onClick={() => onSave(JSON.parse(JSON.stringify(draft)) as EvalIndicatorParams)}
>
·
</button>
<button
type="button"
className="seval-settings-reset"
disabled={!appliedParams && !dirty}
onClick={onReset}
>
</button>
</div>
</div>
);
};
export default StrategyEvaluationSettingsTab;
+405
View File
@@ -0,0 +1,405 @@
/* ── 전략 평가 — btd-page 레이아웃 위 보조 스타일 ─────────────────────────── */
.seval-left-panel {
height: 100%;
}
.seval-main-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 8px 10px;
}
.seval-main-content .seval-chart {
flex: 1;
min-height: 0;
}
.seval-chart {
display: flex;
flex-direction: column;
min-height: 0;
}
.seval-chart-body {
flex: 1;
min-height: 0;
}
.seval-chart-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--btd-divider, var(--se-border));
background: var(--se-header-bg, var(--se-bg-elevated));
}
.seval-chart-toolbar-run {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.seval-chart-bar-time {
font-size: 0.68rem;
color: var(--btd-gold, var(--se-gold));
padding: 4px 8px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--btd-gold, var(--se-gold)) 35%, var(--se-border));
background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 8%, transparent);
white-space: nowrap;
}
.seval-chart-hint {
font-size: 0.66rem;
color: var(--se-text-muted);
white-space: nowrap;
}
/* btd-analysis-canvas-wrap--live: tv-chart-wrap 높이 (backtestDashboard.css) */
.seval-chart-signal-count {
font-size: 0.66rem;
font-weight: 600;
color: #3fb950;
white-space: nowrap;
}
.seval-chart-backtest-busy {
font-size: 0.66rem;
color: var(--se-text-muted);
white-space: nowrap;
}
.seval-chart-status {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
color: var(--se-text-muted);
}
.seval-chart-status--error {
color: #f85149;
}
.seval-chart-bottom-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--btd-divider, var(--se-border));
background: var(--se-header-bg, var(--se-bg-elevated));
}
.seval-chart-nav--step {
min-width: 88px;
}
.seval-chart-bottom-divider {
width: 1px;
height: 20px;
background: var(--btd-divider, var(--se-border));
}
.seval-chart-count {
font-size: 0.68rem;
color: var(--se-text-muted);
font-variant-numeric: tabular-nums;
}
/* ── 세로 선택바 ─────────────────────────────────────────────────────────── */
.seval-bar-selector-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 24;
}
.seval-bar-selector {
position: absolute;
pointer-events: auto;
touch-action: none;
cursor: grab;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(
180deg,
rgba(212, 160, 23, 0.04) 0%,
rgba(255, 193, 37, 0.18) 45%,
rgba(212, 160, 23, 0.22) 50%,
rgba(255, 193, 37, 0.18) 55%,
rgba(212, 160, 23, 0.04) 100%
);
border: 1px solid rgba(212, 160, 23, 0.28);
box-shadow:
inset 0 0 28px rgba(255, 193, 37, 0.12),
0 0 18px rgba(212, 160, 23, 0.08);
transition: box-shadow 0.15s, border-color 0.15s;
}
.seval-bar-selector:hover {
border-color: rgba(212, 160, 23, 0.45);
}
.seval-bar-selector--dragging {
cursor: grabbing;
border-color: rgba(255, 193, 37, 0.55);
}
.seval-bar-selector__shine {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.06) 50%, transparent 100%);
pointer-events: none;
}
.seval-bar-selector__handle {
position: absolute;
top: 8px;
bottom: 8px;
left: 50%;
width: 2px;
transform: translateX(-50%);
border-radius: 1px;
background: linear-gradient(180deg, rgba(255, 193, 37, 0.15) 0%, rgba(255, 193, 37, 0.65) 50%, rgba(255, 193, 37, 0.15) 100%);
pointer-events: none;
}
.seval-bar-selector__label {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.62rem;
font-weight: 600;
white-space: nowrap;
color: #ffe082;
background: rgba(13, 17, 23, 0.72);
border: 1px solid rgba(212, 160, 23, 0.35);
pointer-events: none;
}
/* ── 우측 시그널 패널 ───────────────────────────────────────────────────── */
.seval-right-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.seval-signal-head {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 4px 8px;
font-size: 0.72rem;
font-weight: 700;
color: var(--se-text-muted);
}
.seval-signal-market {
color: var(--se-text);
}
.seval-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3fb950;
box-shadow: 0 0 8px #3fb950;
}
.seval-live-label {
margin-left: auto;
font-size: 0.68rem;
font-weight: 600;
color: #3fb950;
}
.seval-signal-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 2px;
}
.seval-eval-error {
margin: 0 4px 8px;
font-size: 0.72rem;
color: #f85149;
}
.seval-signal-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 4px 4px;
border-top: 1px solid var(--btd-divider, var(--se-border));
font-size: 0.68rem;
color: var(--se-text-muted);
}
.seval-strategy-chip {
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--btd-divider, var(--se-border));
background: var(--btd-surface, var(--se-input-bg));
color: var(--se-text);
}
.seval-right-panel .vtd-sig-panel-wrap {
height: auto;
}
/* ── 전략설정 탭 ─────────────────────────────────────────────────────────── */
.seval-settings-tab {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.seval-settings-head {
flex-shrink: 0;
padding: 4px 2px 8px;
}
.seval-settings-strategy {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
color: var(--se-text);
}
.seval-settings-hint {
margin: 4px 0 0;
font-size: 0.64rem;
line-height: 1.35;
color: var(--se-text-muted);
}
.seval-settings-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 2px 8px;
}
.seval-settings-card {
margin-bottom: 6px;
border-radius: 8px;
border: 1px solid var(--btd-divider, var(--se-border));
background: var(--btd-surface, var(--se-palette-card-bg));
overflow: hidden;
}
.seval-settings-card--open {
border-color: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 30%, var(--se-border));
}
.seval-settings-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 10px;
border: none;
background: transparent;
color: var(--se-text);
cursor: pointer;
text-align: left;
}
.seval-settings-card-title {
font-size: 0.76rem;
font-weight: 600;
}
.seval-settings-card-type {
font-size: 0.62rem;
color: var(--se-text-muted);
}
.seval-settings-card-body {
padding: 0 6px 10px;
border-top: 1px solid var(--btd-divider, var(--se-border));
}
.seval-settings-card-body .ism-row {
padding: 6px 4px;
}
.seval-settings-reset-type {
margin-top: 4px;
padding: 4px 8px;
border: none;
background: transparent;
color: var(--se-text-muted);
font-size: 0.66rem;
cursor: pointer;
text-decoration: underline;
}
.seval-settings-actions {
flex-shrink: 0;
display: flex;
gap: 8px;
padding: 8px 2px 4px;
border-top: 1px solid var(--btd-divider, var(--se-border));
}
.seval-settings-save,
.seval-settings-reset {
flex: 1;
padding: 9px 10px;
border-radius: 8px;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.seval-settings-save {
border: 1px solid color-mix(in srgb, var(--btd-gold, var(--se-gold)) 45%, var(--se-border));
background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 12%, var(--se-input-bg));
color: var(--btd-gold, var(--se-gold));
}
.seval-settings-save:disabled,
.seval-settings-reset:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.seval-settings-reset {
border: 1px solid var(--btd-divider, var(--se-border));
background: var(--btd-surface, var(--se-input-bg));
color: var(--se-text-muted);
}
@media (max-width: 960px) {
.seval-chart-hint {
display: none;
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ export const SIGNAL_SNAPSHOT_VISIBLE_BARS = SIGNAL_SNAPSHOT_CONTEXT_BARS * 2 + 1
/** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */ /** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */
const SIGNAL_SNAPSHOT_WARMUP_BARS = 50; const SIGNAL_SNAPSHOT_WARMUP_BARS = 50;
function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number { export function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number {
if (bars.length === 0) return -1; if (bars.length === 0) return -1;
let bestIdx = 0; let bestIdx = 0;
let bestDist = Math.abs(bars[0].time - candleTimeSec); let bestDist = Math.abs(bars[0].time - candleTimeSec);
+1
View File
@@ -26,6 +26,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
P('메뉴-추세검색', 'trend', '추세', '검색'), P('메뉴-추세검색', 'trend', '추세', '검색'),
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'), P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'), P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
P('메뉴-전략 평가', 'strategy-evaluation', '전략평가', '조건일치', '일치율'),
P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'), P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'),
P('메뉴-분석레포트', 'analysis-report', '레포트', '분석', '투자분석'), P('메뉴-분석레포트', 'analysis-report', '레포트', '분석', '투자분석'),
P('메뉴-설정', 'settings', '환경설정'), P('메뉴-설정', 'settings', '환경설정'),
+24 -1
View File
@@ -1460,17 +1460,40 @@ export interface LiveConditionStatusDto {
matchRate: number; matchRate: number;
rows: LiveConditionRowDto[]; rows: LiveConditionRowDto[];
updatedAt: number; updatedAt: number;
barTimeSec?: number | null;
evalBarIndex?: number | null;
overallEntryMet?: boolean | null;
overallExitMet?: boolean | null;
} }
/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */ /** 백엔드 Ta4j 조건 평가 — 실시간 또는 barTimeSec 지정 봉 */
export async function fetchLiveConditionStatus( export async function fetchLiveConditionStatus(
market: string, market: string,
strategyId: number, strategyId: number,
barTimeSec?: number | null,
indicatorParams?: Record<string, Record<string, unknown>> | null,
): Promise<LiveConditionStatusDto | null> { ): Promise<LiveConditionStatusDto | null> {
if (indicatorParams && Object.keys(indicatorParams).length > 0) {
return request<LiveConditionStatusDto>('/strategy/live-conditions/evaluate', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
market,
strategyId,
barTimeSec: barTimeSec != null && Number.isFinite(barTimeSec)
? Math.floor(barTimeSec)
: null,
indicatorParams,
}),
});
}
const params = new URLSearchParams({ const params = new URLSearchParams({
market, market,
strategyId: String(strategyId), strategyId: String(strategyId),
}); });
if (barTimeSec != null && Number.isFinite(barTimeSec)) {
params.set('barTimeSec', String(Math.floor(barTimeSec)));
}
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, { return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
cache: 'no-store', cache: 'no-store',
}); });
+7 -2
View File
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
| 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin'; | 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [ export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board', 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
]; ];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
@@ -21,7 +21,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
]; ];
export const ALL_MENU_IDS = [ export const ALL_MENU_IDS = [
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings',
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const), ...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
] as const; ] as const;
@@ -36,6 +36,7 @@ export const MENU_LABELS: Record<string, string> = {
'verification-board': '검증게시판', 'verification-board': '검증게시판',
strategy: '투자전략', strategy: '투자전략',
'strategy-editor': '전략편집기', 'strategy-editor': '전략편집기',
'strategy-evaluation': '전략 평가',
backtest: '백테스팅', backtest: '백테스팅',
'analysis-report': '분석레포트', 'analysis-report': '분석레포트',
notifications: '알림목록', notifications: '알림목록',
@@ -80,6 +81,10 @@ export function canAccessMenu(
if (permissions['strategy-editor'] === true) return true; if (permissions['strategy-editor'] === true) return true;
return permissions.strategy === true; return permissions.strategy === true;
} }
if (menuId === 'strategy-evaluation') {
if (permissions['strategy-evaluation'] === true) return true;
return permissions.backtest === true || permissions['strategy-editor'] === true;
}
return permissions[menuId] === true; return permissions[menuId] === true;
} }
@@ -0,0 +1,62 @@
/** live-conditions 행 ↔ 전략 DSL 행 병합 (useVirtualIndicatorSnapshots 와 동일) */
import type { LiveConditionRowDto } from './backendApi';
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
import { normalizeConditionRow } from './virtualSignalMetrics';
import type { VirtualConditionRow } from './virtualStrategyConditions';
function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'indicatorType' | 'conditionType' | 'targetValue' | 'plotKey'>): string {
return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`;
}
function liveFallbackKey(r: LiveConditionRowDto): string {
const plotKey = r.plotKey ?? r.indicatorType;
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${plotKey}`;
}
function liveRowToVirtual(r: LiveConditionRowDto): VirtualConditionRow & { currentValue: number | null } {
const plotKey = r.plotKey ?? r.indicatorType;
return normalizeConditionRow({
id: r.id,
indicatorType: r.indicatorType,
displayName: formatIndicatorDisplayLabel(r.indicatorType),
conditionType: r.conditionType,
conditionLabel: r.conditionLabel,
targetValue: r.targetValue,
timeframe: r.timeframe,
side: r.side,
plotKey,
satisfied: r.satisfied,
thresholdLabel: r.thresholdLabel,
currentValue: r.currentValue,
});
}
export function mergeRows(
base: VirtualConditionRow[],
live: LiveConditionRowDto[],
): Array<VirtualConditionRow & { currentValue: number | null }> {
const liveById = new Map<string, LiveConditionRowDto>();
const liveByKey = new Map<string, LiveConditionRowDto>();
for (const r of live) {
liveById.set(r.id, r);
liveByKey.set(liveFallbackKey(r), r);
}
if (base.length === 0) {
return live.map(liveRowToVirtual);
}
return base.map(row => {
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
if (!liveRow) {
return normalizeConditionRow({ ...row, currentValue: null, satisfied: null });
}
return normalizeConditionRow({
...row,
satisfied: liveRow.satisfied,
thresholdLabel: liveRow.thresholdLabel,
currentValue: liveRow.currentValue,
targetValue: liveRow.targetValue ?? row.targetValue,
});
});
}
@@ -0,0 +1,37 @@
/**
* 전략 평가 — 전략에 포함된 지표 파라미터 맵
*/
import type { StrategyDto } from './backendApi';
import { collectStrategyRegistryTypes } from './strategyToChartIndicators';
export type EvalIndicatorParams = Record<string, Record<string, number | string | boolean>>;
export function buildEvalParamsFromStrategy(
strategy: StrategyDto | null | undefined,
getParams: (type: string) => Record<string, number | string | boolean>,
): EvalIndicatorParams {
const types = collectStrategyRegistryTypes(strategy);
const out: EvalIndicatorParams = {};
for (const type of types) {
out[type] = { ...getParams(type) };
}
return out;
}
export function mergeEvalGetParams(
baseGetParams: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
applied: EvalIndicatorParams | null | undefined,
) {
return (type: string, defaults?: Record<string, number | string | boolean>) => {
const base = baseGetParams(type, defaults);
const override = applied?.[type];
return override ? { ...base, ...override } : base;
};
}
export function hasEvalParamsDiff(
draft: EvalIndicatorParams,
applied: EvalIndicatorParams | null | undefined,
): boolean {
return JSON.stringify(draft) !== JSON.stringify(applied ?? {});
}
@@ -0,0 +1,66 @@
/**
* 전략 평가 — 특정 봉 시점 조건 일치율 스냅샷
*/
import {
fetchLiveConditionStatus,
type StrategyDto,
} from './backendApi';
import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin';
import { hydrateStrategyDto, normalizeMarketCode } from './strategyHydrate';
import { extractVirtualConditions } from './virtualStrategyConditions';
import { coerceFiniteNumber } from './safeFormat';
import { normalizeConditionRow } from './virtualSignalMetrics';
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
import { mergeRows } from './strategyEvaluationMerge';
export async function fetchEvaluationSnapshotAtBar(
market: string,
strategyId: number,
strategy: StrategyDto | undefined,
barTimeSec: number | null,
indicatorParams?: Record<string, Record<string, unknown>> | null,
): Promise<VirtualIndicatorSnapshot | null> {
const normalizedMarket = normalizeMarketCode(market);
const hydrated = strategy ? hydrateStrategyDto(strategy) : undefined;
const baseRows = hydrated ? extractVirtualConditions(hydrated) : [];
try {
await pinStrategyEvaluationTimeframes(normalizedMarket, strategyId);
} catch {
/* pin 실패해도 평가 시도 */
}
let status = null;
try {
status = await fetchLiveConditionStatus(
normalizedMarket,
strategyId,
barTimeSec,
indicatorParams,
);
} catch {
status = null;
}
if (!status) {
if (baseRows.length === 0) return null;
return {
market: normalizedMarket,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })),
updatedAt: Date.now(),
matchRate: 0,
};
}
const rows = mergeRows(baseRows, status.rows ?? []);
return {
market: normalizedMarket,
strategyId,
timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows,
updatedAt: status.updatedAt || Date.now(),
matchRate: coerceFiniteNumber(status.matchRate) ?? 0,
};
}
@@ -169,6 +169,13 @@ function collectIndicatorRefs(strategy: StrategyDto, side?: 'BUY' | 'SELL'): Ind
return out; return out;
} }
/** 전략 DSL에 포함된 보조지표 registry type 목록 (중복 제거) */
export function collectStrategyRegistryTypes(strategy: StrategyDto | null | undefined): string[] {
if (!strategy) return [];
const refs = collectIndicatorRefs(strategy);
return [...new Set(refs.map(r => r.registryType))];
}
function applyTargetHlines( function applyTargetHlines(
hlines: HLineDef[], hlines: HLineDef[],
target: number | null | undefined, target: number | null | undefined,
+1
View File
@@ -15,6 +15,7 @@ export const TRADING_MENU_PAGES = new Set([
'notifications', 'notifications',
'strategy', 'strategy',
'strategy-editor', 'strategy-editor',
'strategy-evaluation',
'backtest', 'backtest',
]); ]);
+20 -1
View File
@@ -1270,15 +1270,34 @@ export interface LiveConditionStatusDto {
updatedAt: number; updatedAt: number;
} }
/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */ /** 백엔드 Ta4j 조건 평가 — 실시간 또는 barTimeSec·지표 파라미터 오버라이드 */
export async function fetchLiveConditionStatus( export async function fetchLiveConditionStatus(
market: string, market: string,
strategyId: number, strategyId: number,
barTimeSec?: number | null,
indicatorParams?: Record<string, Record<string, unknown>> | null,
): Promise<LiveConditionStatusDto | null> { ): Promise<LiveConditionStatusDto | null> {
if (indicatorParams && Object.keys(indicatorParams).length > 0) {
return request<LiveConditionStatusDto>('/strategy/live-conditions/evaluate', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
market,
strategyId,
barTimeSec: barTimeSec != null && Number.isFinite(barTimeSec)
? Math.floor(barTimeSec)
: null,
indicatorParams,
}),
});
}
const params = new URLSearchParams({ const params = new URLSearchParams({
market, market,
strategyId: String(strategyId), strategyId: String(strategyId),
}); });
if (barTimeSec != null && Number.isFinite(barTimeSec)) {
params.set('barTimeSec', String(Math.floor(barTimeSec)));
}
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, { return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
cache: 'no-store', cache: 'no-store',
}); });