전략평가 메뉴 추가
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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', '환경설정'),
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const TRADING_MENU_PAGES = new Set([
|
||||
'notifications',
|
||||
'strategy',
|
||||
'strategy-editor',
|
||||
'strategy-evaluation',
|
||||
'backtest',
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user