From fb24059f4746a1238c3e641c3d690d8a3b845793 Mon Sep 17 00:00:00 2001 From: hjcud Date: Mon, 10 Aug 2026 01:28:07 +0900 Subject: [PATCH] fix automatic backtest launch and Basic preview --- src/BasicEditorInteraction.test.tsx | 7 +- src/StrategyApiView.test.tsx | 29 +- src/StrategyPreview.test.tsx | 149 ++++++- src/api/strategies.test.ts | 27 ++ src/api/strategies.ts | 30 ++ src/components/StrategyPreviewChart.tsx | 20 +- src/lib/i18n.tsx | 2 + src/lib/strategyPreview.ts | 491 ++++++++++++++++++++---- src/styles/balanced.css | 36 ++ src/views/StrategyViews.tsx | 90 +++-- 10 files changed, 772 insertions(+), 109 deletions(-) diff --git a/src/BasicEditorInteraction.test.tsx b/src/BasicEditorInteraction.test.tsx index 9b7f588..7ee12ee 100644 --- a/src/BasicEditorInteraction.test.tsx +++ b/src/BasicEditorInteraction.test.tsx @@ -229,10 +229,15 @@ describe('Basic editor interactions', () => { expect(budgetNarrative).toHaveClass('tone-buy'); expect(budgetNarrative.querySelectorAll('b')).toHaveLength(2); + const buyRsi = screen.getByTestId('buy-rsi-block'); + await user.click(within(buyRsi).getByRole('combobox', { name: 'RSI 반등 방향' })); + await user.click(screen.getByRole('option', { name: '상승' })); + expect(blockNarrative).toHaveTextContent('RSI가 기준선에서 위로 반등할 때'); + const blocks = await openBlocks(user); await user.click(within(blocks).getByRole('button', { name: 'MACD 전환 블록 추가' })); const narratives = within(buyCard).getAllByTestId('basic-narrative-block'); - expect(narratives[0]).toHaveTextContent('방향을 바꾸고'); + expect(narratives[0]).toHaveTextContent('위로 반등하고'); expect(narratives[1]).toHaveTextContent('교차할 때'); fireEvent.keyDown(screen.getByRole('group', { name: '매도 전략 카드 이동 영역' }), { key: 'Enter' }); diff --git a/src/StrategyApiView.test.tsx b/src/StrategyApiView.test.tsx index 34ca8e4..f1d4fd0 100644 --- a/src/StrategyApiView.test.tsx +++ b/src/StrategyApiView.test.tsx @@ -184,10 +184,11 @@ describe('Strategy API view', () => { expect(screen.getByRole('dialog', { name: 'PARTITION 1 종목 관리' })).toHaveTextContent('SPY'); expect(loadOrder).toEqual(['document', 'lease']); await user.click(screen.getByRole('button', { name: '완료' })); + await user.selectOptions(screen.getByRole('combobox', { name: 'PARTITION 01 기본 봉 주기' }), '1시간봉'); await user.click(screen.getByRole('button', { name: 'PARTITION 01 전략 미리보기' })); expect(await screen.findByTestId('strategy-preview-canvas')).toBeInTheDocument(); expect(marketDataClient.getRecentBars).toHaveBeenCalledWith( - 'spy-id', '30m', 300, expect.any(AbortSignal), + 'spy-id', '1h', 300, expect.any(AbortSignal), ); const save = screen.getByRole('button', { name: '저장' }); await waitFor(() => expect(save).toBeEnabled()); @@ -423,15 +424,28 @@ describe('Strategy API view', () => { requestedEditSequence: 1, semanticHash: 'new-hash', elementCatalogVersionId: catalogId, findings: [], completedAt: '2026-08-07T12:01:00Z', }; + const loadedValidation = { + ...validation, + validationRunId: '21000000-0000-4000-8000-000000000000', + requestedEditSequence: 0, + semanticHash: 'old-hash', + }; const releaseInputs = { executionPolicies: [{ version: 'policy-v1', brokerRulesVersion: 'market-v1', accountingRulesVersion: 'accounting-v1', precisionRulesVersion: 'precision-v1', feePolicyId: 'fee-id', feeRateBps: 20, buyingPowerBufferPolicyId: 'buffer-id', buyingPowerBufferBps: 1, + }, { + version: 'older-policy', brokerRulesVersion: 'older-market', accountingRulesVersion: 'older-accounting', + precisionRulesVersion: 'older-precision', feePolicyId: 'older-fee', feeRateBps: 30, + buyingPowerBufferPolicyId: 'older-buffer', buyingPowerBufferBps: 2, }], datasets: [{ id: 'dataset-id', feedCode: 'alpaca-sip', dataLayer: 'ADJUSTED', resolution: '1m', periodStart: '2025-01-01', periodEnd: '2026-01-01', schemaVersion: 'market-bars-v2', + }, { + id: 'older-dataset', feedCode: 'alpaca-sip', dataLayer: 'ADJUSTED', resolution: '1m', + periodStart: '2024-01-01', periodEnd: '2025-01-01', schemaVersion: 'market-bars-v2', }], observedAt: '2026-08-07T12:01:00Z', }; @@ -449,6 +463,7 @@ describe('Strategy API view', () => { requestedEditSequence: input.clientRevision, semanticHash: 'preview-hash', })), + getCurrentValidations: vi.fn().mockResolvedValue([loadedValidation]), validateStrategy: vi.fn().mockResolvedValue(validation), getReleaseInputs: vi.fn().mockResolvedValue(releaseInputs), releaseStrategy: vi.fn().mockResolvedValue({ botId: 'bot-id', backtestLane: 'BASIC' }), @@ -466,10 +481,12 @@ describe('Strategy API view', () => { render( {}} strategyId={strategyId} authoringClient={authoringClient} catalogClient={{ getBasic: vi.fn().mockResolvedValue(catalog) }} onLaunchBot={onLaunchBot} />); const save = await screen.findByRole('button', { name: '저장' }); await waitFor(() => expect(save).toBeEnabled()); - expect(screen.getByRole('button', { name: '개인 봇 출시' })).toBeDisabled(); + await waitFor(() => expect(authoringClient.getCurrentValidations).toHaveBeenCalledWith(expect.any(AbortSignal))); + await waitFor(() => expect(screen.getByRole('button', { name: '개인 봇 출시' })).toBeEnabled()); const rsiValue = screen.getByRole('spinbutton', { name: 'RSI 반등 값' }); await user.clear(rsiValue); await user.type(rsiValue, '31'); + expect(screen.getByRole('button', { name: '개인 봇 출시' })).toBeDisabled(); await waitFor(() => expect(authoringClient.previewValidation).toHaveBeenCalled()); await waitFor(() => expect(vi.mocked(authoringClient.previewValidation!).mock.calls.at(-1)?.[1].semanticDocument).toEqual(expect.objectContaining({ groups: expect.arrayContaining([expect.objectContaining({ @@ -492,8 +509,12 @@ describe('Strategy API view', () => { })], }); await user.click(screen.getByRole('button', { name: '개인 봇 출시' })); - expect(await screen.findByRole('combobox', { name: '실행 정책' })).toHaveValue('policy-v1'); - await user.click(screen.getByRole('button', { name: '봇 출시하기' })); + const launchDialog = await screen.findByRole('dialog', { name: '개인 운용 봇 출시' }); + await waitFor(() => expect(authoringClient.getReleaseInputs).toHaveBeenCalledTimes(1)); + expect(within(launchDialog).queryByText('실행 정책')).not.toBeInTheDocument(); + expect(within(launchDialog).queryByText('공식 백테스트 데이터')).not.toBeInTheDocument(); + expect(within(launchDialog).queryByRole('combobox')).not.toBeInTheDocument(); + await user.click(within(launchDialog).getByRole('button', { name: '봇 출시하기' })); await waitFor(() => expect(authoringClient.releaseStrategy).toHaveBeenCalledWith(strategyId, expect.objectContaining({ validationRunId: validation.validationRunId, diff --git a/src/StrategyPreview.test.tsx b/src/StrategyPreview.test.tsx index dba1dcd..16adc3c 100644 --- a/src/StrategyPreview.test.tsx +++ b/src/StrategyPreview.test.tsx @@ -1,14 +1,17 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, test } from 'vitest'; import { BasicEditor } from './views/StrategyViews'; +import { StrategyPreviewChart } from './components/StrategyPreviewChart'; import { LanguageProvider } from './lib/i18n'; import { PREVIEW_WINDOW, bollinger, evaluateStrategyPreview, + generatePreviewCandles, identifyIndicator, parseSignalRule, + parseSignalRules, rsi, splitPartitionSymbols, } from './lib/strategyPreview'; @@ -30,6 +33,15 @@ const flowsOf = (buy: PreviewBlock[], sell: PreviewBlock[]): PreviewFlow[] => [ { id: 'sell-1', label: '매도', side: 'sell', blocks: sell }, ]; +const candlesFrom = (closes: number[], volumes?: number[]) => closes.map((close, index) => ({ + time: Date.UTC(2026, 6, 1 + index, 20, 0, 0) / 1000, + open: close, + high: close + 1, + low: close - 1, + close, + volume: volumes?.[index] ?? 10_000, +})); + describe('strategy preview engine', () => { test('splits a partition symbol list into chart-selectable symbols', () => { expect(splitPartitionSymbols('AAPL · MSFT · SPY')).toEqual(['AAPL', 'MSFT', 'SPY']); @@ -37,13 +49,17 @@ describe('strategy preview engine', () => { expect(splitPartitionSymbols('종목 선택')).toEqual([]); }); - test('computes RSI on the standard Wilder scale', () => { + test('computes the official bounded-window RSI used by backtests', () => { const rising = Array.from({ length: 40 }, (_, index) => 100 + index); const values = rsi(rising, 14); // The first 14 bars cannot have a value, and a pure uptrend pins RSI at 100. expect(values.slice(0, 14).every((value) => value === null)).toBe(true); expect(values[39]).toBeCloseTo(100, 5); expect(values.every((value) => value === null || (value >= 0 && value <= 100))).toBe(true); + + const officialFixture = [100, 101, 100, 99, 98, 97, 96, 95, 94, 94, 94, 94, 94, 94, 94]; + expect(rsi(officialFixture, 14).at(-1)).toBeCloseTo(12.5, 8); + expect(rsi(Array.from({ length: 15 }, () => 100), 14).at(-1)).toBe(50); }); test('keeps Bollinger bands ordered around the moving average', () => { @@ -66,6 +82,18 @@ describe('strategy preview engine', () => { expect(crossing.rule).toMatchObject({ kind: 'SMA', fastPeriod: 20, slowPeriod: 60 }); }); + test('uses the editor 상승 direction as an upward crossing', () => { + const preview = evaluateStrategyPreview({ + symbol: 'AAPL', + flows: [{ + id: 'buy-up', label: '상승 매수', side: 'buy', + blocks: [{ label: 'RSI 반등', op: '상승', value: '30', tone: 'condition' }], + }], + }); + + expect(preview.flows[0].description).toBe('RSI(14) 30 상향 돌파'); + }); + test('reports indicators it cannot evaluate instead of inventing signals', () => { const { rule, unsupported } = parseSignalRule([{ label: 'Supertrend', op: '=', value: 'UP', tone: 'indicator' }]); expect(rule).toBeNull(); @@ -185,6 +213,123 @@ describe('strategy preview engine', () => { }); describe('Basic editor partition preview state', () => { + test('always explains the buy and sell conditions and warns when bars are insufficient', () => { + render( {}} + />); + + const conditions = screen.getByRole('list', { name: '매수·매도 조건' }); + const [buy, sell] = within(conditions).getAllByRole('listitem'); + expect(buy).toHaveTextContent('매수'); + expect(buy).toHaveTextContent('RSI(14) 30 하향 돌파'); + expect(sell).toHaveTextContent('매도'); + expect(sell).toHaveTextContent('RSI(14) 70 상향 돌파'); + expect(screen.getByRole('status')).toHaveTextContent('신호를 계산하기에 최근 데이터가 부족합니다.'); + }); + + test('recognizes every published Basic condition instead of silently dropping blocks', () => { + const blocks: PreviewBlock[] = [ + { label: '가격 비교', op: '>', value: '전일 종가', tone: 'data' }, + { label: '가격 변화율', op: '상승', base: '전일 종가', value: '1', tone: 'data' }, + { label: '거래량', op: '>', value: '최근 20봉 평균 거래량 2배', tone: 'data' }, + { label: '연속 상승·하락', op: '↑', value: '3봉', tone: 'indicator' }, + { label: '평균선 교차', op: '↑', value: '5봉 · 20봉', tone: 'indicator' }, + { label: 'RSI 반등', op: '↑', value: '30', tone: 'condition' }, + { label: 'MACD 전환', op: '↑', value: '12 · 26 · 9', tone: 'condition' }, + { label: '가격 띠 반전', op: '↑', value: '20봉 · 2σ', tone: 'condition' }, + { label: '현재 수익률', op: '수익', value: '1', tone: 'risk' }, + { label: '보유 기간', op: '≥', value: '5봉', tone: 'risk' }, + { label: '최고 수익률', op: '≥', value: '2', tone: 'risk' }, + { label: '고점 대비 하락', op: '≥', value: '1', tone: 'risk' }, + { label: '정기 매수', value: '매월 첫 거래일', tone: 'time' }, + ]; + const parsed = parseSignalRules(blocks); + expect(parsed.unsupported).toEqual([]); + expect(parsed.rules.map((rule) => rule.kind)).toEqual([ + 'PRICE', 'PRICE_CHANGE', 'VOLUME_COMPARE', 'STREAK', 'SMA', 'RSI', 'MACD', + 'BOLLINGER', 'POSITION_RETURN', 'HOLDING_PERIOD', 'PEAK_RETURN', + 'DRAWDOWN_FROM_PEAK', 'SCHEDULE', + ]); + }); + + test('requires every condition in a container instead of using only the first one', () => { + const preview = evaluateStrategyPreview({ + symbol: 'AAPL', + flows: [{ + id: 'buy-and', label: 'AND 매수', side: 'buy', + blocks: [ + { label: 'RSI 반등', op: '↑', value: '30', tone: 'condition' }, + { label: '가격 변화율', op: '상승', base: '전일 종가', value: '1000', tone: 'data' }, + ], + }], + }); + expect(preview.markers).toEqual([]); + expect(preview.flows[0].description).toContain(' · '); + }); + + test('fails a whole flow closed when any condition is unsupported', () => { + const preview = evaluateStrategyPreview({ + symbol: 'AAPL', + flows: [{ + id: 'buy-unsafe', label: '미지원 포함', side: 'buy', + blocks: [ + { label: 'RSI 반등', op: '↑', value: '30', tone: 'condition' }, + { label: 'Supertrend', op: '↑', value: '10', tone: 'indicator' }, + ], + }], + }); + expect(preview.unsupported).toEqual(['Supertrend']); + expect(preview.flows[0].evaluable).toBe(false); + expect(preview.markers).toEqual([]); + }); + + test('evaluates price, volume, streak, return, holding, peak and drawdown conditions together', () => { + const preview = evaluateStrategyPreview({ + symbol: 'AAPL', + candles: candlesFrom([100, 101, 102, 110, 120, 112, 111], [100, 200, 300, 400, 500, 600, 700]), + flows: [ + { + id: 'buy-state', label: '상태 매수', side: 'buy', maxExecutions: 1, + blocks: [ + { label: '가격 변화율', op: '상승', base: '전일 종가', value: '0.5', tone: 'data' }, + { label: '거래량', op: '>', value: '이전 봉 거래량', tone: 'data' }, + { label: '연속 상승·하락', op: '↑', value: '2봉', tone: 'indicator' }, + ], + }, + { + id: 'sell-state', label: '상태 매도', side: 'sell', maxExecutions: 1, + blocks: [ + { label: '현재 수익률', op: '수익', value: '1', tone: 'risk' }, + { label: '보유 기간', op: '≥', value: '2봉', tone: 'risk' }, + { label: '최고 수익률', op: '≥', value: '5', tone: 'risk' }, + { label: '고점 대비 하락', op: '≥', value: '3', tone: 'risk' }, + ], + }, + ], + }); + + expect(preview.markers.map((marker) => marker.side)).toEqual(['buy', 'sell']); + expect(preview.markers[0].price).toBe(110); + expect(preview.markers[1].price).toBe(111); + expect(preview.flows.every((flow) => flow.evaluable)).toBe(true); + }); + + test('does not invent a current-close fill when a signal occurs on the last bar', () => { + const preview = evaluateStrategyPreview({ + symbol: 'AAPL', + candles: candlesFrom([100, 101]), + flows: [{ + id: 'last-bar', label: '마지막 봉', side: 'buy', + blocks: [{ label: '가격 변화율', op: '상승', base: '전일 종가', value: '0.5', tone: 'data' }], + }], + }); + expect(preview.markers).toEqual([]); + }); + test('does not invent a graph, signals, or fallback symbols', async () => { const user = userEvent.setup(); render( {}} />); diff --git a/src/api/strategies.test.ts b/src/api/strategies.test.ts index ee50c66..8e18320 100644 --- a/src/api/strategies.test.ts +++ b/src/api/strategies.test.ts @@ -180,6 +180,33 @@ describe('strategy authoring API client', () => { ); }); + it('loads the current valid revisions so reopening an editor preserves launchability', async () => { + const current = { + validationRunId: '21000000-0000-4000-8000-000000000001', + strategyId: document.strategyId, + strategyName: 'Validated strategy', + requestedEditSequence: 3, + semanticHash: document.semanticHash, + elementCatalogVersionId: '0f1a0000-0000-4000-8000-000000000001', + languageVersion: 'basic/v1', + schemaVersion: 'basic-semantic/v1', + catalogVersion: 'basic-elements:2026-08-07', + completedAt: '2026-08-07T12:00:00Z', + }; + const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [current] }), { status: 200 })); + const client = createStrategyAuthoringClient({ fetchImpl }); + + await expect(client.getCurrentValidations!()).resolves.toEqual([expect.objectContaining({ + validationRunId: current.validationRunId, + strategyId: document.strategyId, + requestedEditSequence: 3, + semanticHash: document.semanticHash, + })]); + expect(fetchImpl).toHaveBeenCalledWith('/api/v1/strategy-validations/current', expect.objectContaining({ + credentials: 'include', + })); + }); + it('loads server-owned release inputs and creates an immutable release', async () => { const inputs = { executionPolicies: [{ diff --git a/src/api/strategies.ts b/src/api/strategies.ts index 0d36b06..4a31a75 100644 --- a/src/api/strategies.ts +++ b/src/api/strategies.ts @@ -73,6 +73,15 @@ export interface StrategyValidationResult { completedAt: string; } +export interface CurrentStrategyValidation { + validationRunId: string; + strategyId: string; + requestedEditSequence: number; + semanticHash: string; + elementCatalogVersionId: string; + completedAt: string; +} + export interface PreviewStrategyValidationInput { catalogId: string; clientRevision: number; @@ -126,6 +135,7 @@ export interface StrategyAuthoringClient { releaseLease(strategyId: string, leaseToken: string, signal?: AbortSignal): Promise; saveDocument(strategyId: string, input: SaveStrategyDocumentInput, signal?: AbortSignal): Promise; previewValidation?(strategyId: string, input: PreviewStrategyValidationInput, signal?: AbortSignal): Promise; + getCurrentValidations?(signal?: AbortSignal): Promise; validateStrategy(strategyId: string, catalogId: string, signal?: AbortSignal): Promise; getReleaseInputs(signal?: AbortSignal): Promise; releaseStrategy(strategyId: string, input: ReleaseStrategyInput, signal?: AbortSignal): Promise<{ botId: string; backtestLane: string }>; @@ -306,6 +316,10 @@ export function createStrategyAuthoringClient({ ); return readValidation(await response.json()); }, + async getCurrentValidations(signal) { + const response = await request('/api/v1/strategy-validations/current', 'Current strategy validations', { signal }); + return readCurrentValidations(await response.json()); + }, async validateStrategy(strategyId, catalogId, signal) { const response = await request( `/api/v1/strategies/${encodeURIComponent(strategyId)}/validations`, @@ -432,6 +446,22 @@ function readValidation(value: unknown): StrategyValidationResult { }; } +function readCurrentValidations(value: unknown): CurrentStrategyValidation[] { + const page = object(value, 'Invalid current strategy validations'); + if (!Array.isArray(page.items)) throw new Error('Invalid current strategy validation items'); + return page.items.map((raw) => { + const item = object(raw, 'Invalid current strategy validation'); + return { + validationRunId: string(item.validationRunId, 'validationRunId'), + strategyId: string(item.strategyId, 'strategyId'), + requestedEditSequence: nonNegativeInteger(item.requestedEditSequence, 'requestedEditSequence'), + semanticHash: string(item.semanticHash, 'semanticHash'), + elementCatalogVersionId: string(item.elementCatalogVersionId, 'elementCatalogVersionId'), + completedAt: string(item.completedAt, 'completedAt'), + }; + }); +} + export function readStrategyReleaseInputs(value: unknown): StrategyReleaseInputs { const result = object(value, 'Invalid strategy release inputs response'); if (!Array.isArray(result.executionPolicies) || !Array.isArray(result.datasets)) { diff --git a/src/components/StrategyPreviewChart.tsx b/src/components/StrategyPreviewChart.tsx index 7e1a516..39e6035 100644 --- a/src/components/StrategyPreviewChart.tsx +++ b/src/components/StrategyPreviewChart.tsx @@ -20,7 +20,7 @@ interface CardPosition { const CARD_WIDTH = 320; /* 첫 렌더에서 아직 실측할 수 없을 때 쓰는 근사 높이. 이후에는 실제 높이로 잡는다. */ -const CARD_HEIGHT = 264; +const CARD_HEIGHT = 340; /* SVG 좌표계. 카드가 늘어나도 선 굵기는 유지하고 도형만 늘어난다. */ const VIEW_WIDTH = 300; @@ -101,7 +101,7 @@ export function StrategyPreviewChart({ }, []); useEffect(() => { setPosition((current) => clampToViewport(current, cardRef.current?.offsetHeight || CARD_HEIGHT)); - }, [flows.length, symbols.length]); + }, [flows, symbols.length]); const preview = useMemo(() => evaluateStrategyPreview({ symbol, flows, candles }), [candles, flows, symbol]); @@ -156,6 +156,7 @@ export function StrategyPreviewChart({ const { summary } = preview; const focusedFlow = preview.flows.find((flow) => flow.id === focusedFlowId) ?? null; + const hasInsufficientData = preview.flows.some((flow) => flow.evaluable && !flow.dataReady); /* 완료된 매매가 없으면 수익률은 아직 판단할 수 없으므로 색도 중립으로 둔다. */ const returnTone = summary.tradeCount === 0 ? 'neutral' @@ -258,10 +259,21 @@ export function StrategyPreviewChart({ >