diff --git a/e2e/backtest.e2e.ts b/e2e/backtest.e2e.ts index 14807c2..be334b0 100644 --- a/e2e/backtest.e2e.ts +++ b/e2e/backtest.e2e.ts @@ -115,7 +115,7 @@ test.describe('backtest screens against the /api/v1 contract', () => { await expect(botOptions.getByRole('option').first()).toHaveAttribute('aria-selected', 'true'); }); - test('loads the selected run detail: status, attempts and published performance', async ({ page }) => { + test('loads the selected run detail and separates performance from execution information', async ({ page }) => { await signIn(page); await page.goto(BACKTESTS); @@ -124,17 +124,54 @@ test.describe('backtest screens against the /api/v1 contract', () => { await expect(detail).toBeVisible(); await expect(detail).toContainText('검증된 공식 결과가 발행되었습니다.'); + // The metrics document, rendered from `metricsDocument` rather than from zeroes. + await expect(detail).toContainText('$10,299.96'); + await expect(detail).toContainText('2.9996%'); + await expect(detail.getByText('계산 기준 보기')).toHaveCount(0); + const totalReturnHelp = detail.getByRole('button', { name: '총 수익률 설명' }); + const totalReturnTooltip = detail.getByRole('tooltip', { + name: '시작 자산과 비교해 종료 자산이 얼마나 늘거나 줄었는지 보여줍니다.', + }); + await expect(totalReturnTooltip).toBeHidden(); + await totalReturnHelp.hover(); + await expect(totalReturnTooltip).toBeVisible(); + + await detail.getByRole('tab', { name: '월별 분석' }).click(); + await detail.getByRole('tab', { name: '2026년 7월 ET 결과 보기' }).click(); + await expect(detail.getByText('America/New_York')).toHaveCount(0); + await expect(detail.getByRole('article', { name: '평가 21회' })).toBeVisible(); + const counterHelp = [ + ['평가', '해당 월에 전략 조건을 확인한 총 평가 횟수입니다.'], + ['활성 분기', '해당 월의 평가에 실제로 참여한 서로 다른 전략 흐름의 수입니다.'], + ['트리거', '전략 조건이 충족되어 거래 판단이 시작된 횟수입니다.'], + ['거래 이벤트', '전략 실행 과정에서 생성된 거래 관련 이벤트의 수입니다.'], + ['데이터 공백', '평가에 필요한 시장 데이터가 없거나 충분하지 않았던 횟수입니다.'], + ['거부', '거래 판단이나 주문이 검증 또는 실행 단계에서 거부로 집계된 건수입니다.'], + ] as const; + for (const [label, description] of counterHelp) { + const help = detail.getByRole('button', { name: `${label} 설명` }); + const tooltip = detail.getByRole('tooltip', { name: description }); + await expect(tooltip).toBeHidden(); + await help.hover(); + await expect(tooltip).toBeVisible(); + } + await expect(detail.getByText('RSI BELOW 30')).toBeVisible(); + const firstFailureHelp = detail.getByRole('button', { name: '첫 실패 조건 설명' }); + const firstFailureTooltip = detail.getByRole('tooltip', { + name: '월별 전략 평가가 다음 단계로 진행되지 못했을 때, 가장 먼저 충족되지 않은 조건과 그 횟수를 보여줍니다. 시스템 오류를 뜻하지 않습니다.', + }); + await expect(firstFailureTooltip).toBeHidden(); + await firstFailureHelp.hover(); + await expect(firstFailureTooltip).toBeVisible(); + await expect(detail.getByText('BASIC · BASIC')).toHaveCount(0); + + await detail.getByRole('tab', { name: '실행 정보' }).click(); const attempts = detail.getByRole('table', { name: '자동 실행 시도 기록' }); await expect(attempts.getByRole('row')).toHaveCount(2); await expect(attempts).toContainText('SUCCEEDED'); await expect(attempts).not.toContainText('실행 키'); await expect(attempts).not.toContainText('실패 코드'); await expect(attempts).not.toContainText('worker-execution-1'); - - // The metrics document, rendered from `metricsDocument` rather than from zeroes. - await expect(detail).toContainText('$10,299.96'); - await expect(detail).toContainText('2.9996%'); - await expect(detail).toContainText('metrics:1.0.0'); }); test('renders an ET month as individual trades, not as evidence manifests', async ({ page }) => { @@ -142,6 +179,7 @@ test.describe('backtest screens against the /api/v1 contract', () => { await signIn(page); await page.goto(BACKTESTS); + await page.getByRole('tab', { name: '거래 내역' }).click(); await page.getByRole('tab', { name: '2026년 7월 ET 결과 보기' }).click(); const trades = page.getByRole('table', { name: '2026년 7월 개별 거래' }); @@ -176,10 +214,31 @@ test.describe('backtest screens against the /api/v1 contract', () => { .endsWith(`/api/v1/backtests/${RUN_ID}/monthly-trades?et_month=2026-07`))).toBe(true); }); + test('keeps selected backtest controls readable in dark and light themes', async ({ page }) => { + await signIn(page); + await page.goto(BACKTESTS); + + const resultTab = page.getByRole('tab', { name: '월별 분석' }); + await resultTab.click(); + const monthTab = page.getByRole('tab', { name: '2026년 8월 ET 결과 보기' }); + + await page.getByRole('button', { name: '화면 설정 열기' }).click(); + await page.getByRole('button', { name: '다크 모드' }).click(); + await expect(page.getByTestId('app-shell')).toHaveAttribute('data-theme', 'dark'); + await expect(resultTab).toHaveCSS('color', 'rgb(220, 227, 228)'); + await expect(monthTab).toHaveCSS('color', 'rgb(94, 207, 202)'); + + await page.getByRole('button', { name: '라이트 모드' }).click(); + await expect(page.getByTestId('app-shell')).toHaveAttribute('data-theme', 'light'); + await expect(resultTab).toHaveCSS('color', 'rgb(26, 34, 36)'); + await expect(monthTab).toHaveCSS('color', 'rgb(14, 116, 118)'); + }); + test('says an ET month traded nothing instead of showing an empty table', async ({ page }) => { await signIn(page); await page.goto(BACKTESTS); + await page.getByRole('tab', { name: '거래 내역' }).click(); // August is COMPLETED with no trade records: `200 {"items": []}` is the answer, // and it opens first because the screen selects the most recent month. diff --git a/src/BacktestLiveView.test.tsx b/src/BacktestLiveView.test.tsx index 562db01..631a886 100644 --- a/src/BacktestLiveView.test.tsx +++ b/src/BacktestLiveView.test.tsx @@ -89,6 +89,13 @@ function recordRequests(): string[] { return paths; } +async function openResultTab( + user: ReturnType, + name: '성과 요약' | '월별 분석' | '거래 내역' | '실행 정보', +) { + await user.click(await screen.findByRole('tab', { name })); +} + describe('BacktestLiveView against the /api/v1 backtest surface', () => { it('requests a custom backtest from server-confirmed bots and immutable inputs', async () => { let received: Record | null = null; @@ -158,7 +165,7 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { expect(screen.getAllByText('취소됨').length).toBeGreaterThan(0); }); - it('renders the completed run overview, performance and provenance', async () => { + it('renders the completed run overview with concise metric help', async () => { view(); expect(screen.getByRole('status')).toHaveTextContent('백테스트 결과를 불러오는 중'); @@ -169,12 +176,64 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { expect(screen.getByText('-1.00%')).toBeInTheDocument(); expect(screen.getByText('9.16515139')).toBeInTheDocument(); expect(screen.getByText('37.50%')).toBeInTheDocument(); - expect(screen.getByText('metrics:1.0.0')).toBeInTheDocument(); - expect(screen.getByText('metric-rules:1.0.0')).toBeInTheDocument(); + expect(screen.queryByText('계산 기준 보기')).not.toBeInTheDocument(); + expect(screen.getByLabelText('총 수익률 설명')).toBeInTheDocument(); + expect(screen.getByRole('tooltip', { name: '시작 자산과 비교해 종료 자산이 얼마나 늘거나 줄었는지 보여줍니다.' })) + .toBeInTheDocument(); + }); + + it('keeps real results inside the original selector, overview chart and compact metric layout', async () => { + view(); + + const workspace = await screen.findByTestId('backtest-live-workspace'); + expect(workspace).toHaveClass('backtest-comparison-workspace'); + + const overview = await screen.findByRole('region', { name: '선택한 백테스트 성과 개요' }); + expect(within(overview).getByText('월별 활동')).toBeInTheDocument(); + expect(within(overview).getByRole('img', { name: '월별 백테스트 활동' })).toBeInTheDocument(); + expect(within(overview).getByText('2026.07')).toBeInTheDocument(); + expect(within(overview).getByText('2026.08')).toBeInTheDocument(); + + expect(screen.getByTestId('backtest-live-metrics')).toHaveClass('backtest-metric-panel'); + expect(balancedStyles).toMatch(/\.backtest-live-overview-chart\s*\{[^}]*height:\s*330px/s); + }); + + it('separates dense result categories into one-at-a-time tabs', async () => { + const user = userEvent.setup(); + view(); + + const tabs = await screen.findByRole('tablist', { name: '백테스트 결과 분류' }); + expect(within(tabs).getByRole('tab', { name: '성과 요약' })).toHaveAttribute('aria-selected', 'true'); + expect(within(tabs).getByRole('tab', { name: '월별 분석' })).toBeInTheDocument(); + expect(within(tabs).getByRole('tab', { name: '거래 내역' })).toBeInTheDocument(); + expect(within(tabs).getByRole('tab', { name: '실행 정보' })).toBeInTheDocument(); + expect(screen.getByTestId('backtest-live-metrics')).toBeVisible(); + expect(screen.queryByRole('heading', { name: 'ET 월별 판단' })).not.toBeInTheDocument(); + + await user.click(within(tabs).getByRole('tab', { name: '월별 분석' })); + expect(await screen.findByRole('heading', { name: 'ET 월별 판단' })).toBeVisible(); + expect(screen.queryByTestId('backtest-live-metrics')).not.toBeInTheDocument(); + + await user.click(within(tabs).getByRole('tab', { name: '거래 내역' })); + expect(await screen.findByRole('heading', { name: 'ET 월별 거래' })).toBeVisible(); + + await user.click(within(tabs).getByRole('tab', { name: '실행 정보' })); + expect(await screen.findByRole('table', { name: '자동 실행 시도 기록' })).toBeVisible(); + }); + + it('keeps active result controls readable in both light and dark themes', () => { + const monthActiveRule = balancedStyles.match(/\.backtest-live-month-tabs button\.active\s*\{[^}]*\}/s)?.[0] ?? ''; + const resultActiveRule = balancedStyles.match(/\.backtest-live-result-tabs button\.active\s*\{[^}]*\}/s)?.[0] ?? ''; + + expect(monthActiveRule).toMatch(/color:\s*var\(--accent\)/); + expect(monthActiveRule).not.toMatch(/color:\s*var\(--accent-ink\)/); + expect(resultActiveRule).toMatch(/color:\s*var\(--text\)/); }); it('shows the automatic execution attempt history', async () => { + const user = userEvent.setup(); view(); + await openResultTab(user, '실행 정보'); const attempts = await screen.findByRole('table', { name: '자동 실행 시도 기록' }); const row = within(attempts).getAllByRole('row')[1]; @@ -189,18 +248,37 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { const user = userEvent.setup(); view(); + await openResultTab(user, '월별 분석'); // The most recent ET month is selected first; July is the month with activity. await user.click(await screen.findByRole('tab', { name: '2026년 7월 ET 결과 보기' })); const judgment = await screen.findByRole('region', { name: '2026년 7월 ET 월별 판단' }); - expect(within(judgment).getByText('평가 21회')).toBeInTheDocument(); - expect(within(judgment).getByText('활성 분기 2개')).toBeInTheDocument(); - expect(within(judgment).getByText('거래 이벤트 2건')).toBeInTheDocument(); - expect(within(judgment).getByText('데이터 공백 1회')).toBeInTheDocument(); - expect(within(judgment).getByText('트리거 2회')).toBeInTheDocument(); - expect(within(judgment).getByText('거부 1건')).toBeInTheDocument(); - expect(within(judgment).getByText('rsi-below-30')).toBeInTheDocument(); - expect(within(judgment).getByText('BASIC · BASIC')).toBeInTheDocument(); + expect(within(judgment).queryByText('America/New_York')).not.toBeInTheDocument(); + expect(within(judgment).getByRole('article', { name: '평가 21회' })).toBeInTheDocument(); + expect(within(judgment).getByRole('article', { name: '활성 분기 2개' })).toBeInTheDocument(); + expect(within(judgment).getByRole('article', { name: '거래 이벤트 2건' })).toBeInTheDocument(); + expect(within(judgment).getByRole('article', { name: '데이터 공백 1회' })).toBeInTheDocument(); + expect(within(judgment).getByRole('article', { name: '트리거 2회' })).toBeInTheDocument(); + expect(within(judgment).getByRole('article', { name: '거부 1건' })).toBeInTheDocument(); + const counterHelp = [ + ['평가', '해당 월에 전략 조건을 확인한 총 평가 횟수입니다.'], + ['활성 분기', '해당 월의 평가에 실제로 참여한 서로 다른 전략 흐름의 수입니다.'], + ['트리거', '전략 조건이 충족되어 거래 판단이 시작된 횟수입니다.'], + ['거래 이벤트', '전략 실행 과정에서 생성된 거래 관련 이벤트의 수입니다.'], + ['데이터 공백', '평가에 필요한 시장 데이터가 없거나 충분하지 않았던 횟수입니다.'], + ['거부', '거래 판단이나 주문이 검증 또는 실행 단계에서 거부로 집계된 건수입니다.'], + ] as const; + counterHelp.forEach(([label, description]) => { + expect(within(judgment).getByLabelText(`${label} 설명`)).toBeInTheDocument(); + expect(within(judgment).getByRole('tooltip', { name: description })).toBeInTheDocument(); + }); + expect(within(judgment).getByText('RSI BELOW 30')).toBeInTheDocument(); + expect(within(judgment).getByLabelText('첫 실패 조건 설명')).toBeInTheDocument(); + expect(within(judgment).getByRole('tooltip', { + name: '월별 전략 평가가 다음 단계로 진행되지 못했을 때, 가장 먼저 충족되지 않은 조건과 그 횟수를 보여줍니다. 시스템 오류를 뜻하지 않습니다.', + })).toBeInTheDocument(); + expect(within(judgment).queryByText('BASIC · BASIC')).not.toBeInTheDocument(); + expect(within(judgment).queryByText(/\|step-/)).not.toBeInTheDocument(); expect(within(judgment).getByText('3회')).toBeInTheDocument(); }); @@ -208,7 +286,9 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { const user = userEvent.setup(); view(); + await openResultTab(user, '거래 내역'); await user.click(await screen.findByRole('tab', { name: '2026년 7월 ET 결과 보기' })); + await user.click(screen.getByText('데이터 증거 보기')); const july = await screen.findByRole('table', { name: '2026년 7월 거래 상세 증거' }); // 07-20 sits inside July; 07-27 runs into August and still covers July. expect(within(july).getAllByRole('row')).toHaveLength(3); @@ -227,6 +307,7 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { const user = userEvent.setup(); view(); + await openResultTab(user, '거래 내역'); await user.click(await screen.findByRole('tab', { name: '2026년 7월 ET 결과 보기' })); const trades = await screen.findByRole('table', { name: '2026년 7월 개별 거래' }); @@ -249,6 +330,7 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { const user = userEvent.setup(); view(); + await openResultTab(user, '거래 내역'); await user.click(await screen.findByRole('tab', { name: '2026년 7월 ET 결과 보기' })); // The fill's `occurredAt` is 2026-08-01T03:30:00Z — 23:30 on 31 July in New York. @@ -262,6 +344,7 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { const user = userEvent.setup(); view(); + await openResultTab(user, '거래 내역'); await user.click(await screen.findByRole('tab', { name: '2026년 7월 ET 결과 보기' })); const trades = await screen.findByRole('table', { name: '2026년 7월 개별 거래' }); @@ -284,15 +367,20 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { view(); - // The most recent month is opened first, so August is asked for before anything - // is pressed. `et_month` is a required parameter the server never defaults. + // Dense trade detail stays lazy until its result category is actually opened. + await screen.findByRole('tablist', { name: '백테스트 결과 분류' }); + expect(urls).toEqual([]); + await openResultTab(user, '거래 내역'); + // The most recent month is opened first. `et_month` is required by the server. await waitFor(() => expect(urls).toEqual(['?et_month=2026-08'])); await user.click(screen.getByRole('tab', { name: '2026년 7월 ET 결과 보기' })); await waitFor(() => expect(urls).toEqual(['?et_month=2026-08', '?et_month=2026-07'])); }); it('says a month traded nothing rather than showing an empty table', async () => { + const user = userEvent.setup(); view(); + await openResultTab(user, '거래 내역'); // August is COMPLETED with no trade records: `200 {"items": []}` is the true // answer, and it must not read as a loading or a broken month. @@ -313,6 +401,7 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { )); view(); + await openResultTab(user, '거래 내역'); await user.click(await screen.findByRole('tab', { name: '2026년 7월 ET 결과 보기' })); expect(await screen.findByText('2026년 7월 거래 기록이 월별 집계와 일치하지 않습니다.')) @@ -335,6 +424,7 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { )); view(); + await openResultTab(user, '거래 내역'); await user.click(await screen.findByRole('tab', { name: '2026년 7월 ET 결과 보기' })); const alert = await screen.findByText('2026년 7월 개별 거래를 불러오지 못했습니다.'); @@ -424,12 +514,14 @@ describe('BacktestLiveView against the /api/v1 backtest surface', () => { }); it('says the performance summary is not published yet instead of showing zeroes', async () => { + const user = userEvent.setup(); server.use(...backtestHandlers({ performance: null })); view(); expect(await screen.findByText('성과 요약이 아직 발행되지 않았습니다.')).toBeInTheDocument(); // The monthly judgment the server does publish is still shown. + await openResultTab(user, '월별 분석'); expect(screen.getByRole('tab', { name: '2026년 8월 ET 결과 보기' })).toBeInTheDocument(); }); diff --git a/src/styles/balanced.css b/src/styles/balanced.css index b10c0b9..198ec44 100644 --- a/src/styles/balanced.css +++ b/src/styles/balanced.css @@ -13249,65 +13249,184 @@ small.dashboard-bot-scope { .variant-balanced[data-design="signal-studio"] .backtest-live-month-tabs { display: flex; flex-wrap: wrap; - gap: var(--space-2); - margin-bottom: var(--space-4); + gap: 6px; + margin: 0; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-2) 72%, transparent); } .variant-balanced[data-design="signal-studio"] .backtest-live-month-tabs button { - padding: var(--space-2) var(--space-3); + min-height: 36px; + padding: 0 var(--space-3); border: 1px solid var(--line); - border-radius: var(--radius-sm); + border-radius: 999px; background: transparent; - color: var(--text-soft); + color: var(--text-faint); cursor: pointer; - font-weight: 700; + font-size: 12px; + font-weight: 650; } .variant-balanced[data-design="signal-studio"] .backtest-live-month-tabs button.active { border-color: var(--accent); - background: var(--accent-soft); + background: color-mix(in srgb, var(--accent) 13%, var(--surface)); color: var(--accent); } .variant-balanced[data-design="signal-studio"] .backtest-live-judgments { display: grid; + gap: var(--space-5); + margin: 0; + padding: var(--space-5); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-summary-head { + display: flex; + align-items: center; + justify-content: space-between; gap: var(--space-3); - margin-bottom: var(--space-4); - padding: var(--space-3); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-summary-head > div:first-child { + display: grid; + gap: 3px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-summary-head small { + color: var(--accent); + font: 700 10px var(--font-mono); + letter-spacing: .12em; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-summary-head strong { + font-size: 18px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-context { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-context span { + padding: 5px 9px; border: 1px solid var(--line); - border-radius: var(--radius-sm); + border-radius: 999px; + color: var(--text-faint); + background: var(--surface-2); + font-size: 11px; } -.variant-balanced[data-design="signal-studio"] .backtest-live-judgments header, -.variant-balanced[data-design="signal-studio"] .backtest-live-judgments li { +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article { + position: relative; + display: grid; + gap: 8px; + min-height: 88px; + align-content: center; + padding: 16px; + overflow: visible; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-2); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 2px; + background: var(--line-strong); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article.trigger::before, +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article.trade::before { + background: var(--accent); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article.gap::before, +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article.rejected::before { + background: var(--warning); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpi-label { + display: flex; + align-items: center; + gap: 5px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpi-label > span { + color: var(--text-faint); + font-size: 11px; + font-weight: 650; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article:nth-child(3n + 1) .backtest-live-metric-tooltip { + right: auto; + left: 0; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis strong { + font: 700 20px var(--font-mono); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary { + overflow: visible; + border: 1px solid var(--line); + border-radius: var(--radius-md); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary > header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); + padding: 14px 16px; + border-bottom: 1px solid var(--line); + border-radius: inherit inherit 0 0; + background: color-mix(in srgb, var(--surface-2) 76%, transparent); } -.variant-balanced[data-design="signal-studio"] .backtest-live-judgments header span, -.variant-balanced[data-design="signal-studio"] .backtest-live-judgments small { +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary > header div { + display: grid; + gap: 2px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary > header small, +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary > header span { color: var(--text-faint); + font-size: 10px; } -.variant-balanced[data-design="signal-studio"] .backtest-live-judgments ul { +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary > header strong { + font-size: 13px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-title { + display: flex; + align-items: center; + gap: 5px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-title .backtest-live-metric-tooltip { + right: auto; + left: 0; + width: min(260px, calc(100vw - 64px)); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary ul { display: grid; - gap: var(--space-2); margin: 0; padding: 0; list-style: none; } -.variant-balanced[data-design="signal-studio"] .backtest-live-judgments li > span { - display: grid; - gap: 2px; -} -.variant-balanced[data-design="signal-studio"] .backtest-live-counters { +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary li { display: flex; - flex-wrap: wrap; - gap: var(--space-2); + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 48px; + padding: 10px 16px; + border-top: 1px solid var(--line); } -.variant-balanced[data-design="signal-studio"] .backtest-live-counters li { - display: inline-flex; - padding: 2px var(--space-2); - border: 1px solid var(--line); - border-radius: var(--radius-sm); +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary li:first-child { + border-top: 0; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary li > span { color: var(--text-soft); font-size: 12px; + font-weight: 650; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary li > strong { + color: var(--warning); + font: 700 12px var(--font-mono); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-failure-summary > p { + margin: 0; + padding: 18px 16px; + color: var(--text-faint); + font-size: 12px; } .variant-balanced[data-design="signal-studio"] .backtest-live-trades, .variant-balanced[data-design="signal-studio"] .backtest-live-trade-rows, @@ -13354,10 +13473,340 @@ small.dashboard-bot-scope { font-size: 12px; } +/* Keep the live API screen in the same visual hierarchy as the original + backtest workspace: selector + overview, then compact metrics and detail. */ +.variant-balanced[data-design="signal-studio"] .backtest-live-workspace.backtest-comparison-workspace { + grid-template-columns: minmax(280px, .85fr) minmax(0, 2.15fr); + align-items: start; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-detail { + display: contents; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-list.backtest-bot-selector, +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-panel { + height: 400px; + min-height: 400px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-list .backtest-bot-options { + min-height: 0; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-list .backtest-bot-options > div > button { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + justify-content: initial; + padding: 12px 15px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-list .backtest-bot-options > div > button.active { + box-shadow: inset 0 -2px var(--accent); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-list .status { + white-space: nowrap; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-pagination { + min-height: 42px; + padding: 0 10px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-pagination .button { + min-height: 26px; + padding: 0 9px; + font-size: 9px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-panel { + overflow: hidden; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-chart { + height: 330px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary { + min-height: 82px; + display: flex; + align-items: center; + gap: 28px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-2) 74%, transparent); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary > div:not(.backtest-live-activity-legend) { + min-width: 112px; + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + gap: 3px 10px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary span, +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary small { + color: var(--text-faint); + font-size: 10px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary strong { + font: 800 20px var(--font-mono); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary .is-primary strong { + color: var(--accent); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary small { + grid-column: 1 / -1; + white-space: nowrap; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend { + max-width: 100%; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + padding: 3px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + scrollbar-width: none; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend > strong, +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend > span { + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 7px; + border-radius: 3px; + font: 650 10px/1 var(--font-sans); + white-space: nowrap; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend > strong { + color: var(--text-soft); + font-size: 10px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend i { + width: 7px; + height: 7px; + display: block; + border-radius: 50%; + background: var(--accent); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend .trigger i { background: var(--info); } +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend .trade i { background: var(--tone-indicator); } +.variant-balanced[data-design="signal-studio"] .backtest-live-activity-chart { + width: 100%; + height: 100%; + min-height: 0; + padding: 10px 16px 6px; + overflow: visible; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-gridline { + stroke: var(--line); + stroke-width: 1; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-line { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-line.evaluation { + stroke: var(--accent); + stroke-width: 3; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-line.trigger { + stroke: var(--info); + stroke-width: 2; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-bar { + fill: var(--tone-indicator); + opacity: .26; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-point { + fill: var(--surface); + stroke-width: 2.5; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-point.evaluation { stroke: var(--accent); } +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-point.trigger { stroke: var(--info); } +.variant-balanced[data-design="signal-studio"] .backtest-live-chart-label { + fill: var(--text-faint); + font: 10px var(--font-mono); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-overview-state { + min-height: 100%; + display: grid; + place-items: center; + padding: var(--space-5); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-browser { + grid-column: 1 / -1; + min-width: 0; + display: grid; + gap: var(--space-3); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-tabs { + min-width: 0; + display: flex; + gap: 4px; + padding: 5px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-2); + scrollbar-width: none; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-tabs::-webkit-scrollbar { + display: none; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-tabs button { + min-width: max-content; + min-height: 40px; + flex: 1 0 auto; + padding: 0 var(--space-4); + border: 1px solid transparent; + border-radius: calc(var(--radius-md) - 3px); + color: var(--text-faint); + background: transparent; + font: 650 12px var(--font-sans); + cursor: pointer; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-tabs button:hover { + color: var(--text); + background: color-mix(in srgb, var(--surface-3) 72%, transparent); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-tabs button.active { + border-color: var(--line-strong); + color: var(--text); + background: var(--surface); + box-shadow: 0 1px 2px rgb(0 0 0 / 10%); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-tabs button.active::before { + content: ""; + width: 5px; + height: 5px; + display: inline-block; + margin-right: 7px; + border-radius: 50%; + background: var(--accent); + vertical-align: 1px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-result-content { + min-width: 0; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metrics, +.variant-balanced[data-design="signal-studio"] .backtest-live-monthly, +.variant-balanced[data-design="signal-studio"] .backtest-live-attempt-panel { + grid-column: 1 / -1; + min-width: 0; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metrics { + margin-bottom: 0; + overflow: visible; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metrics .metric-row { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + border-block: 0; + background: var(--line); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metrics .metric-row > div { + position: relative; + min-height: 112px; + align-content: center; + padding: var(--space-5); + border-left: 0; + background: var(--surface); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metric-label { + display: flex; + align-items: center; + gap: 5px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metric-help { + position: relative; + width: 18px; + height: 18px; + display: inline-grid; + flex: 0 0 18px; + place-items: center; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--text-faint); + background: transparent; + cursor: help; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metric-help:hover, +.variant-balanced[data-design="signal-studio"] .backtest-live-metric-help:focus-visible { + color: var(--accent); + outline: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metric-tooltip { + position: absolute; + z-index: 30; + top: calc(100% + 9px); + right: 0; + width: 220px; + padding: 10px 12px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--text); + background: var(--surface-3); + box-shadow: 0 8px 24px rgb(0 0 0 / 22%); + font: 500 11px/1.55 var(--font-sans); + text-align: left; + visibility: hidden; + opacity: 0; + transform: translateY(-3px); + transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease; + pointer-events: none; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metrics .metric-row > div:nth-child(4n + 1) .backtest-live-metric-tooltip { + right: auto; + left: 0; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-metric-help:hover .backtest-live-metric-tooltip, +.variant-balanced[data-design="signal-studio"] .backtest-live-metric-help:focus-visible .backtest-live-metric-tooltip { + visibility: visible; + opacity: 1; + transform: translateY(0); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-attempt-panel { + overflow: hidden; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-attempt-panel .backtest-live-attempts { + margin-top: 0; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-evidence-disclosure { + border-top: 1px solid var(--line); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-evidence-disclosure > summary { + padding: var(--space-3) var(--space-4); + color: var(--text-faint); + font-size: 12px; + font-weight: 650; + cursor: pointer; + user-select: none; +} +.variant-balanced[data-design="signal-studio"] .backtest-live-evidence-disclosure[open] > summary { + color: var(--text-soft); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-evidence-disclosure { + margin-top: var(--space-4); +} +.variant-balanced[data-design="signal-studio"] .backtest-live-evidence-disclosure .backtest-live-trades { + margin: 0 var(--space-4) var(--space-4); +} + @media (max-width: 860px) { - .variant-balanced[data-design="signal-studio"] .backtest-live-workspace { + .variant-balanced[data-design="signal-studio"] .backtest-live-workspace, + .variant-balanced[data-design="signal-studio"] .backtest-live-workspace.backtest-comparison-workspace { grid-template-columns: 1fr; } + .variant-balanced[data-design="signal-studio"] .backtest-live-list.backtest-bot-selector { + height: auto; + min-height: 0; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-overview-panel { + height: auto; + min-height: 400px; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-detail { display: contents; } .variant-balanced[data-design="signal-studio"] .backtest-request-form { grid-template-columns: 1fr; } @@ -13367,6 +13816,59 @@ small.dashboard-bot-scope { } } @media (max-width: 620px) { + .variant-balanced[data-design="signal-studio"] .backtest-live-result-tabs button { + min-height: 38px; + padding-inline: var(--space-3); + } + .variant-balanced[data-design="signal-studio"] .backtest-live-metrics .metric-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .variant-balanced[data-design="signal-studio"] .backtest-live-metrics .metric-row > div { + min-height: 102px; + padding: var(--space-4); + } + .variant-balanced[data-design="signal-studio"] .backtest-live-metrics .metric-row > div:nth-child(odd) .backtest-live-metric-tooltip { + right: auto; + left: 0; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-metrics .metric-row > div:nth-child(even) .backtest-live-metric-tooltip { + right: 0; + left: auto; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-judgments { + gap: var(--space-4); + padding: var(--space-4); + } + .variant-balanced[data-design="signal-studio"] .backtest-live-monthly-summary-head { + align-items: flex-start; + flex-direction: column; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-monthly-context { + justify-content: flex-start; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article:nth-child(odd) .backtest-live-metric-tooltip { + right: auto; + left: 0; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-monthly-kpis article:nth-child(even) .backtest-live-metric-tooltip { + right: 0; + left: auto; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-overview-summary { + align-items: flex-start; + flex-wrap: wrap; + gap: 12px 20px; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-activity-legend { + width: 100%; + margin-left: 0; + } + .variant-balanced[data-design="signal-studio"] .backtest-live-overview-chart { + height: 380px; + } .variant-balanced[data-design="signal-studio"] .backtest-request-backdrop { padding: 12px; } .variant-balanced[data-design="signal-studio"] .backtest-request-dialog { max-height: calc(100dvh - 24px); border-radius: 15px; } .variant-balanced[data-design="signal-studio"] .backtest-request-dialog > header { grid-template-columns: 38px minmax(0, 1fr) 32px; padding: 16px; } diff --git a/src/views/BacktestLiveView.tsx b/src/views/BacktestLiveView.tsx index 974887b..830c57e 100644 --- a/src/views/BacktestLiveView.tsx +++ b/src/views/BacktestLiveView.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react'; import type { FormEvent, ReactNode } from 'react'; -import { AlertTriangle, BarChart3, Check, ChevronDown, Clock3, Plus, X } from 'lucide-react'; +import { AlertTriangle, BarChart3, Bot, Check, ChevronDown, CircleHelp, Clock3, Plus, X } from 'lucide-react'; import { BacktestApiError } from '../api/backtests'; import type { BacktestAttempt, @@ -18,7 +18,6 @@ import { EmptyState, ErrorState, LoadingState, - MetricRow, PageHeading, Panel, Status, @@ -63,6 +62,7 @@ const newIdempotencyKey = () => globalThis.crypto?.randomUUID?.() * * `transport` — everything else, and the only one worth retrying blind. */ type FailureKind = 'unauthenticated' | 'forbidden' | 'missing' | 'notReady' | 'transport'; +type BacktestResultTab = 'performance' | 'monthly' | 'trades' | 'execution'; const STATUS_LABELS: Record = { QUEUED: '대기 중', @@ -244,9 +244,9 @@ export function BacktestLiveView({ client, session = browserSessionStore }: Back return
} @@ -306,7 +306,10 @@ export function BacktestLiveView({ client, session = browserSessionStore }: Back title="백테스트할 봇이 없습니다." detail="출시된 봇이 생기면 공식 백테스트가 자동으로 시작되고 이곳에 결과가 표시됩니다." />} - {listFailure === null && runs && runs.length > 0 &&
+ {listFailure === null && runs && runs.length > 0 &&
void; onNext: () => void; }) { - return