From f8740b15b1d44d8b66555d6f7c84ff43f8003a7e Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Mon, 10 Aug 2026 02:26:08 +0900 Subject: [PATCH 1/2] fix: preview the selected strategy timeframe --- src/RuntimeHonesty.test.tsx | 4 ++++ src/StrategyApiView.test.tsx | 6 +++++- src/api/marketData.test.ts | 4 ++-- src/api/marketData.ts | 2 +- src/views/BotsView.tsx | 2 +- src/views/StrategyViews.tsx | 13 +++++++++---- 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/RuntimeHonesty.test.tsx b/src/RuntimeHonesty.test.tsx index cc0dcf3..3c21881 100644 --- a/src/RuntimeHonesty.test.tsx +++ b/src/RuntimeHonesty.test.tsx @@ -296,6 +296,10 @@ describe('production runtime honesty', () => { />); expect(await screen.findByText('시장 데이터')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '4시간' })); + await waitFor(() => expect(marketDataClient.getRecentBars).toHaveBeenCalledWith( + 'instrument-aapl', '4h', 400, expect.any(AbortSignal), + )); fireEvent.click(screen.getByRole('button', { name: 'MSFT 차트 보기' })); expect(await screen.findByText('시세 데이터 대기')).toBeInTheDocument(); expect(screen.queryByText('시장 데이터')).not.toBeInTheDocument(); diff --git a/src/StrategyApiView.test.tsx b/src/StrategyApiView.test.tsx index 34ca8e4..f0cbeed 100644 --- a/src/StrategyApiView.test.tsx +++ b/src/StrategyApiView.test.tsx @@ -184,10 +184,14 @@ 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 기본 봉 주기' }), + '4시간봉', + ); 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', '4h', 400, expect.any(AbortSignal), ); const save = screen.getByRole('button', { name: '저장' }); await waitFor(() => expect(save).toBeEnabled()); diff --git a/src/api/marketData.test.ts b/src/api/marketData.test.ts index e2dab52..74505ad 100644 --- a/src/api/marketData.test.ts +++ b/src/api/marketData.test.ts @@ -23,11 +23,11 @@ describe('market data client', () => { baseUrl: 'https://api.example.com/', fetchImpl, getAccessToken: () => 'session-token', }); - const result = await client.getRecentBars('instrument-1', '4h', 300); + const result = await client.getRecentBars('instrument-1', '4h'); expect(result.bars[0].close).toBe(210.5); expect(fetchImpl).toHaveBeenCalledWith( - 'https://api.example.com/api/v1/market-data/instruments/instrument-1/bars?timeframe=4h&limit=300', + 'https://api.example.com/api/v1/market-data/instruments/instrument-1/bars?timeframe=4h&limit=400', expect.objectContaining({ credentials: 'include', headers: expect.objectContaining({ Authorization: 'Bearer session-token' }), diff --git a/src/api/marketData.ts b/src/api/marketData.ts index f3e0e81..393ca83 100644 --- a/src/api/marketData.ts +++ b/src/api/marketData.ts @@ -77,7 +77,7 @@ export function createMarketDataClient({ `/api/v1/market-data/instruments/${encodeURIComponent(instrumentId)}/bars`; return { - async getRecentBars(instrumentId, timeframe = '30m', limit = 300, signal) { + async getRecentBars(instrumentId, timeframe = '30m', limit = 400, signal) { const response = await fetchImpl( `${root}${path(instrumentId)}?timeframe=${encodeURIComponent(timeframe)}&limit=${encodeURIComponent(String(limit))}`, { credentials: 'include', headers: headers('application/json'), signal }, diff --git a/src/views/BotsView.tsx b/src/views/BotsView.tsx index 0c59c23..80e9a21 100644 --- a/src/views/BotsView.tsx +++ b/src/views/BotsView.tsx @@ -1631,7 +1631,7 @@ export function BotsView({ const refreshSnapshot = async () => { try { const snapshot = await marketDataClient.getRecentBars( - selectedMarketInstrument.instrumentId, chartTimeframe, 300, controller.signal, + selectedMarketInstrument.instrumentId, chartTimeframe, 400, controller.signal, ); publish(snapshot.bars); setMarketDataError(null); diff --git a/src/views/StrategyViews.tsx b/src/views/StrategyViews.tsx index b32591e..485b6f6 100644 --- a/src/views/StrategyViews.tsx +++ b/src/views/StrategyViews.tsx @@ -20,7 +20,7 @@ import { splitPartitionSymbols } from '../lib/strategyPreview'; import type { PreviewCandle, PreviewFlow } from '../lib/strategyPreview'; import { StrategyPreviewChart } from '../components/StrategyPreviewChart'; import { defaultMarketDataClient } from '../api/marketData'; -import type { MarketDataClient } from '../api/marketData'; +import type { ChartTimeframe, MarketDataClient } from '../api/marketData'; import { Localized } from '../lib/i18n'; import { browserSessionStore } from '../lib/session'; import { setSessionAccessToken } from '../api/sessionAccessToken'; @@ -823,9 +823,9 @@ const allNumbers = (value: string | undefined): number[] => ( [...String(value ?? '').matchAll(/\d+/g)].map((match) => Number(match[0])) ); -const resolutionCode = (timeframe: string): string => ({ +const resolutionCode = (timeframe: string): ChartTimeframe => ({ '30분봉': '30m', '1시간봉': '1h', '4시간봉': '4h', '일봉': '1d', -}[timeframe] ?? '30m'); +} satisfies Record)[timeframe] ?? '30m'; const priceReferenceCode = (value: string | undefined): string => { const exact: Record = { @@ -3026,7 +3026,12 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st const controller = new AbortController(); setPreviewPending(true); setPreviewError(null); - void marketDataClient.getRecentBars(instrumentId, '30m', 300, controller.signal) + void marketDataClient.getRecentBars( + instrumentId, + resolutionCode(previewSection.timeframe), + 400, + controller.signal, + ) .then((snapshot) => { setPreviewCandles(snapshot.bars.map((bar) => ({ time: Math.floor(new Date(bar.occurredAt).getTime() / 1000), From 012527bacd5009241d5b560ad10722d7eec91e21 Mon Sep 17 00:00:00 2001 From: Junyou Park Date: Mon, 10 Aug 2026 02:53:32 +0900 Subject: [PATCH 2/2] feat: show fast strategy preview signals --- src/StrategyPreview.test.tsx | 30 +++++++++++++++++++++++++ src/components/StrategyPreviewChart.tsx | 3 +++ src/lib/i18n.tsx | 1 + src/lib/strategyPreview.ts | 8 ++++++- src/styles/balanced.css | 8 +++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/StrategyPreview.test.tsx b/src/StrategyPreview.test.tsx index 16adc3c..e1f4b4f 100644 --- a/src/StrategyPreview.test.tsx +++ b/src/StrategyPreview.test.tsx @@ -5,6 +5,7 @@ import { BasicEditor } from './views/StrategyViews'; import { StrategyPreviewChart } from './components/StrategyPreviewChart'; import { LanguageProvider } from './lib/i18n'; import { + PREVIEW_MAX_CANDLES, PREVIEW_WINDOW, bollinger, evaluateStrategyPreview, @@ -117,6 +118,20 @@ describe('strategy preview engine', () => { expect(preview.summary.winRate).not.toBeNull(); }); + test('bounds the fast local preview to the latest 400 server bars', () => { + const supplied = candlesFrom(Array.from({ length: 450 }, (_, index) => 100 + index)); + + const preview = evaluateStrategyPreview({ + symbol: 'AAPL', + candles: supplied, + flows: flowsOf(BUY_BLOCKS, SELL_BLOCKS), + }); + + expect(PREVIEW_MAX_CANDLES).toBe(400); + expect(preview.candles).toHaveLength(400); + expect(preview.candles[0]).toBe(supplied[50]); + }); + test('is deterministic for the same symbol and timeframe', () => { const first = evaluateStrategyPreview({ symbol: 'MSFT', timeframeSeconds: 3600, flows: flowsOf(BUY_BLOCKS, SELL_BLOCKS) }); const second = evaluateStrategyPreview({ symbol: 'MSFT', timeframeSeconds: 3600, flows: flowsOf(BUY_BLOCKS, SELL_BLOCKS) }); @@ -213,6 +228,21 @@ describe('strategy preview engine', () => { }); describe('Basic editor partition preview state', () => { + test('renders fast supported buy and sell markers with an estimate disclaimer', () => { + render( {}} + />); + + expect(screen.getAllByTestId('preview-marker-buy').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('preview-marker-sell').length).toBeGreaterThan(0); + expect(screen.getByText('빠르게 계산할 수 있는 조건만 반영한 예상 결과이며 실제 실행 결과와 다를 수 있습니다.')) + .toBeInTheDocument(); + }); + test('always explains the buy and sell conditions and warns when bars are insufficient', () => { render( 0 &&

{t(`${preview.unsupported.join(', ')} 블록은 계산할 수 없어 해당 플로우의 신호를 표시하지 않아요`)}

} + + {t('빠르게 계산할 수 있는 조건만 반영한 예상 결과이며 실제 실행 결과와 다를 수 있습니다.')} + ; } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 9512e7f..f292e97 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -1292,6 +1292,7 @@ const english: Record = { '신호를 계산하기에 최근 데이터가 부족합니다.': 'There is not enough recent data to calculate signals.', '신호만 강조': 'signals only', '최근 1개월 종가와 신호': 'closing prices and signals over the past month', + '빠르게 계산할 수 있는 조건만 반영한 예상 결과이며 실제 실행 결과와 다를 수 있습니다.': 'This estimate uses only conditions that can be calculated quickly and may differ from actual execution results.', /* '최근 1개월'은 백테스트 기간 칩에 이미 있다. */ '계산할 수 있는 지표 블록이 없어요': 'No indicator block here can be evaluated', '블록은 계산에서 제외했어요': 'blocks are excluded from the calculation', diff --git a/src/lib/strategyPreview.ts b/src/lib/strategyPreview.ts index d203cbc..3441100 100644 --- a/src/lib/strategyPreview.ts +++ b/src/lib/strategyPreview.ts @@ -231,6 +231,9 @@ export const PREVIEW_WINDOW = { count: 150, } as const; +/* 브라우저에서 즉시 다시 계산할 수 있도록 실제 시세 입력도 최근 400봉으로 제한한다. */ +export const PREVIEW_MAX_CANDLES = 400; + /* ---------- 지표 계산 ---------------------------------------------------- */ type Series = Array; @@ -1036,7 +1039,10 @@ export const evaluateStrategyPreview = ({ timeframeSeconds = PREVIEW_WINDOW.seconds, candleCount = PREVIEW_WINDOW.count, }: PreviewInput): StrategyPreview => { - const candles = suppliedCandles ?? generatePreviewCandles(symbol, timeframeSeconds, candleCount); + const sourceCandles = suppliedCandles ?? generatePreviewCandles(symbol, timeframeSeconds, candleCount); + const candles = sourceCandles.length > PREVIEW_MAX_CANDLES + ? sourceCandles.slice(-PREVIEW_MAX_CANDLES) + : sourceCandles; const unsupported = new Set(); /* 한 컨테이너 안의 조건은 런타임과 똑같이 AND다. 하나라도 해석할 수 없는 블록이 있으면 그 플로우는 fail-closed로 신호를 만들지 않는다. */ diff --git a/src/styles/balanced.css b/src/styles/balanced.css index 8ffb144..b10c0b9 100644 --- a/src/styles/balanced.css +++ b/src/styles/balanced.css @@ -9012,6 +9012,14 @@ small.dashboard-bot-scope { font-size: var(--fs-micro); line-height: 1.4; } +.strategy-preview-disclaimer { + display: block; + margin: 0; + color: var(--text-faint); + font-size: var(--fs-micro); + line-height: 1.4; + word-break: keep-all; +} @media (max-width: 800px) { .variant-balanced[data-design="signal-studio"] .strategy-preview-card { display: none; } }