전략평가 메뉴 추가

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
+1 -1
View File
@@ -61,7 +61,7 @@ export const SIGNAL_SNAPSHOT_VISIBLE_BARS = SIGNAL_SNAPSHOT_CONTEXT_BARS * 2 + 1
/** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */
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;
let bestIdx = 0;
let bestDist = Math.abs(bars[0].time - candleTimeSec);
+1
View File
@@ -26,6 +26,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
P('메뉴-추세검색', 'trend', '추세', '검색'),
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
P('메뉴-전략 평가', 'strategy-evaluation', '전략평가', '조건일치', '일치율'),
P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'),
P('메뉴-분석레포트', 'analysis-report', '레포트', '분석', '투자분석'),
P('메뉴-설정', 'settings', '환경설정'),
+24 -1
View File
@@ -1460,17 +1460,40 @@ export interface LiveConditionStatusDto {
matchRate: number;
rows: LiveConditionRowDto[];
updatedAt: number;
barTimeSec?: number | null;
evalBarIndex?: number | null;
overallEntryMet?: boolean | null;
overallExitMet?: boolean | null;
}
/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */
/** 백엔드 Ta4j 조건 평가 — 실시간 또는 barTimeSec 지정 봉 */
export async function fetchLiveConditionStatus(
market: string,
strategyId: number,
barTimeSec?: number | null,
indicatorParams?: Record<string, Record<string, unknown>> | 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({
market,
strategyId: String(strategyId),
});
if (barTimeSec != null && Number.isFinite(barTimeSec)) {
params.set('barTimeSec', String(Math.floor(barTimeSec)));
}
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
cache: 'no-store',
});
+7 -2
View File
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
| 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
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[] = [
@@ -21,7 +21,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
];
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),
] as const;
@@ -36,6 +36,7 @@ export const MENU_LABELS: Record<string, string> = {
'verification-board': '검증게시판',
strategy: '투자전략',
'strategy-editor': '전략편집기',
'strategy-evaluation': '전략 평가',
backtest: '백테스팅',
'analysis-report': '분석레포트',
notifications: '알림목록',
@@ -80,6 +81,10 @@ export function canAccessMenu(
if (permissions['strategy-editor'] === true) return 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;
}
@@ -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;
}
/** 전략 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(
hlines: HLineDef[],
target: number | null | undefined,
+1
View File
@@ -15,6 +15,7 @@ export const TRADING_MENU_PAGES = new Set([
'notifications',
'strategy',
'strategy-editor',
'strategy-evaluation',
'backtest',
]);