diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fff4c00c..c2714cdf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -93,6 +93,16 @@ docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit # Frontend tests docker compose -f compose.dev.yaml exec backtest-fe npm test +# Frontend quality checks (all four run in CI) +docker compose -f compose.dev.yaml exec backtest-fe npm run lint +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check # prod code +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check:test # test code +docker compose -f compose.dev.yaml exec backtest-fe npm run test:run + +# Reproduce the CI gate exactly +docker build --target test ./backtest_fe +docker build --target test ./backtest_be_fast + # API docs: http://localhost:8000/api/v1/docs # Frontend: http://localhost:5173 ``` @@ -111,6 +121,14 @@ docker compose -f compose.dev.yaml exec backtest-fe npm test - **Frontend:** Vitest + RTL for components, Playwright for E2E - **Mocking:** External APIs (yfinance) in unit tests, real calls in `@pytest.mark.external` - **Strategy values:** Use `buy_hold_strategy`, NOT `buy_and_hold` in test fixtures +- **Baseline:** BE 141 unit tests, FE 113 tests — all green. A failure is a regression. +- **Test files are type-checked** via `tsconfig.test.json` (`npm run type-check:test`); `tsconfig.build.json` excludes them. +- **Never set `isolate: false`** in `vitest.config.ts` — shared happy-dom + vitest's duration-based file reordering makes the suite flaky. + +## Frontend Stack Constraints +- **React 19 / Vite 7 / Recharts 3 / React Router 7 / Tailwind CSS 4.** +- **Tailwind 4 is CSS-first:** no `tailwind.config.js`; config lives in `src/index.css`. Do NOT move theme color literals into `@theme` — `useTheme` injects them at runtime via `root.style.setProperty()`. Use `.app-container`, not `.container`. +- **`VITE_API_BASE_URL` must be empty** — the service layer already passes full `/api/v1/...` paths. ## Documentation Detailed architecture docs in each service's `docs/` directory: diff --git a/CLAUDE.md b/CLAUDE.md index e7fda29c..3722d41f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,16 @@ docker compose -f compose.dev.yaml up -d --build docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit -v docker compose -f compose.dev.yaml exec backtest-fe npm test + +# FE quality checks (all four run in CI) +docker compose -f compose.dev.yaml exec backtest-fe npm run lint +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check # prod code +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check:test # test code +docker compose -f compose.dev.yaml exec backtest-fe npm run test:run + +# Reproduce the CI gate exactly (same as Jenkins 'Quality Gate' stage) +docker build --target test ./backtest_fe +docker build --target test ./backtest_be_fast ``` ## Architecture @@ -37,6 +47,14 @@ docker compose -f compose.dev.yaml exec backtest-fe npm test 6. **`cachetools>=5.3.0`** required in BE (TTLCache for data_repository). +7. **`VITE_API_BASE_URL` must be empty.** The service layer passes full paths (`/api/v1/...`) to axios, so a `/api` base yields `/api/api/v1/backtest` and 404s. `client.ts` has a defensive interceptor that strips the duplicate, but that is a safety net — do not rely on it by setting a base. + +8. **Tailwind 4, CSS-first config.** There is no `tailwind.config.js`; config lives in `src/index.css`. Do NOT move theme color literals into `@theme` — `useTheme` injects them at runtime via `root.style.setProperty()`, and baking them in kills theme switching. Dark mode is `@custom-variant dark (&:is(.dark *))`. Use `.app-container`, not `.container` (v4 emits its own with different max-widths). + +9. **Never set `isolate: false` in `vitest.config.ts`.** All test files would share one happy-dom environment, and vitest reorders files by cached durations, so the suite becomes flaky — the same commit alternated between `113 passed` and `3 failed`. + +10. **FE build must pin `NODE_ENV=production`.** `Dockerfile.dev` sets `NODE_ENV=development`, which leaks into `docker compose exec ... npm run build` and makes vite bundle the React dev build. The `build` scripts set it explicitly; keep it when editing them. + ## Sub-Agent Usage - **`Explore`** for broad codebase research (where does X live, how is Y wired) @@ -49,6 +67,14 @@ Always verify changes in Docker containers (`docker compose exec`) before declar - **BE markers:** `@pytest.mark.unit` (no DB), `@pytest.mark.integration` (DB), `@pytest.mark.external` (real API) - **FE:** Vitest + React Testing Library; Playwright for E2E +- **Current baseline:** BE 141 unit tests, FE 113 tests — both fully green. Any failure is a regression, not pre-existing noise. +- **Test files are type-checked** via `tsconfig.test.json` / `npm run type-check:test`. `tsconfig.build.json` deliberately excludes them. + +## CI + +`Jenkinsfile` runs a `Quality Gate` stage (FE and BE in parallel) before building images. Each Dockerfile has a `test` stage that CI invokes with `--target test`; those stages are outside the final image's dependency chain, so a plain `docker build` does not run them and produces the same artifacts as before. + +The gate blocks **deployment**, not merging — the pipeline checks out `*/main` and the repo uses no branch protection or GitHub checks. ## Commit Convention diff --git a/Jenkinsfile b/Jenkinsfile index 89ad6af2..ccdda125 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -21,6 +21,29 @@ pipeline { } } + stage('Quality Gate') { + steps { + script { + // 각 Dockerfile의 test 스테이지를 돌린다. + // - FE: lint / type-check / type-check:test / vitest + // - BE: pytest tests/unit (DB 불필요) + // 실패하면 이미지 빌드와 배포에 도달하지 못한다. + // + // test 스테이지는 최종 이미지의 의존 경로에 없으므로 --target으로 + // 명시해야 실행된다. deps/base 레이어는 뒤이은 이미지 빌드가 + // 그대로 재사용하므로 의존성 설치가 두 번 돌지 않는다. + parallel( + 'Frontend': { + sh 'docker build --target test ./backtest_fe' + }, + 'Backend': { + sh 'docker build --target test ./backtest_be_fast' + } + ) + } + } + } + stage('Login GHCR') { steps { script { diff --git a/README.md b/README.md index 9c1938d5..c63157dd 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ | 구분 | 기술 | |:-----|:-----| -| **Backend** | Python 3.11, FastAPI, SQLAlchemy, pandas, numpy, backtesting.py | -| **Frontend** | TypeScript, React, Vite, Zustand, Recharts, shadcn/ui, Tailwind CSS | +| **Backend** | Python 3.11, FastAPI, SQLAlchemy, pandas, numpy, backtesting.py 0.3.3 | +| **Frontend** | TypeScript 5, React 19, Vite 7, Zustand, Recharts 3, React Router 7, shadcn/ui, Tailwind CSS 4 | | **Database** | MySQL 8.0 | | **Infra** | Docker, Docker Compose, Nginx, Jenkins | -| **Test** | Pytest (BE), Vitest, React Testing Library, Playwright (FE) | +| **Test** | Pytest (BE), Vitest 4, React Testing Library, Playwright (FE) | --- @@ -35,9 +35,9 @@ backtest/ │ ├── Dockerfile # 프로덕션 Docker 이미지 │ └── requirements.txt # Python 의존성 ├── backtest_fe/ # Frontend (React + Vite) -│ ├── src/ # 소스 코드 -│ ├── __tests__/ # 테스트 코드 -│ └── Dockerfile # 프로덕션 Docker 이미지 +│ ├── src/ # 소스 코드 (테스트는 각 모듈 옆 __tests__/에 위치) +│ ├── e2e/ # Playwright E2E +│ └── Dockerfile # 프로덕션 Docker 이미지 (test 스테이지 포함) ├── database/ # DB 스키마 및 초기화 스크립트 ├── compose.dev.yaml # 개발용 Docker Compose ├── Jenkinsfile # CI/CD 파이프라인 @@ -180,8 +180,22 @@ docker compose -f compose.dev.yaml exec backtest-fe npm test # UI 모드 docker compose -f compose.dev.yaml exec backtest-fe npm run test:ui + +# 린트 및 타입 체크 +docker compose -f compose.dev.yaml exec backtest-fe npm run lint +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check # 프로덕션 코드 +docker compose -f compose.dev.yaml exec backtest-fe npm run type-check:test # 테스트 코드 +``` + +### CI 게이트를 그대로 재현 + +```bash +docker build --target test ./backtest_fe # lint → type-check ×2 → vitest +docker build --target test ./backtest_be_fast # pytest tests/unit ``` +현재 기준선은 BE 141건, FE 113건이며 모두 통과합니다. 실패가 보이면 회귀입니다. + --- ## GHCR에 이미지 Push @@ -204,9 +218,14 @@ docker push ghcr.io/kyj0503/backtest-fe:latest ### 자동 Push (Jenkins) `main` 브랜치에 Push하면 Jenkins가 자동으로: -1. Backend/Frontend 이미지 빌드 -2. GHCR에 Push (`latest` + 빌드 번호 태그) -3. home-server 배포 트리거 +1. **Quality Gate** — FE/BE 각 Dockerfile의 `test` 스테이지를 병렬 실행 + (FE: lint, type-check, type-check:test, vitest / BE: `pytest tests/unit`) +2. Backend/Frontend 이미지 빌드 +3. GHCR에 Push (`latest` + 빌드 번호 태그) +4. home-server 배포 트리거 +5. 헬스 체크 + +Quality Gate가 실패하면 이미지 빌드와 배포에 도달하지 못합니다. 다만 이 게이트는 **배포**를 막는 것이며, 파이프라인이 `*/main`을 체크아웃하고 브랜치 보호를 쓰지 않으므로 병합 자체를 막지는 않습니다. --- diff --git a/backtest_be_fast/Dockerfile b/backtest_be_fast/Dockerfile index ff04ca8c..2f225664 100644 --- a/backtest_be_fast/Dockerfile +++ b/backtest_be_fast/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 # Python 3.11 슬림 이미지 사용 -FROM python:3.11-slim +# base: 의존성까지만 설치한 공통 기반 (test / runtime이 공유) +FROM python:3.11-slim AS base # 작업 디렉터리 설정 WORKDIR /app @@ -40,6 +41,23 @@ COPY requirements-test.txt . RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements-test.txt +# 품질 게이트 +# +# runtime이 base에서 갈라져 나오므로 이 스테이지는 최종 이미지의 의존 경로에 +# 없다. 따라서 `docker build`(타깃 미지정)로는 실행되지 않고, CI가 +# `docker build --target test`로 명시적으로 호출한다. +# DB가 필요 없는 unit 마커만 돌린다 (tests/integration은 MySQL을 요구). +FROM base AS test + +COPY app ./app +COPY tests ./tests +COPY pytest.ini ./ + +RUN pytest tests/unit -q + +# runtime: 실제 배포 이미지 (마지막 스테이지 = 기본 빌드 타깃) +FROM base AS runtime + # 애플리케이션 코드 복사 COPY app ./app COPY scripts ./scripts diff --git a/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md b/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md index 0756d43a..56a15114 100644 --- a/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md +++ b/backtest_be_fast/docs/TEST_COVERAGE_SUMMARY.md @@ -4,7 +4,9 @@ Comprehensive unit tests have been created for the following backend modules, following pytest best practices with `@pytest.mark.unit` and `@pytest.mark.asyncio` markers. All external I/O (yfinance, DB) is mocked. -**Total Tests Created: 59** +**Total Tests Created: 59** (이 문서가 다루는 4개 모듈 기준) + +> 현재 `tests/unit` 전체는 **141건**입니다. 이 문서는 아래 4개 모듈에 한정된 보고서이며, 전체 목록은 [UNIT_TEST_QUICK_REFERENCE.md](./UNIT_TEST_QUICK_REFERENCE.md)를 참고하십시오. **Test Files: 4** **All Tests: PASSING ✅** @@ -44,7 +46,7 @@ Tests the core `BacktestEngine` that wraps backtesting.py library. --- -### 2. tests/unit/test_currency_converter.py (18 tests) +### 2. tests/unit/test_currency_converter.py (19 tests) Tests the `CurrencyConverter` for multi-currency support (13 currencies including USD, KRW, JPY, EUR, GBP). @@ -65,7 +67,7 @@ Tests the `CurrencyConverter` for multi-currency support (13 currencies includin - `test_unsupported_currency_returns_data_unchanged` - Unsupported currency logs warning, returns original - `test_conversion_error_returns_original_data` - Network errors return original data -- **TestLoadAndPrepareExchangeRates** (4 tests) +- **TestLoadAndPrepareExchangeRates** (5 tests) - `test_usd_raises_valueerror` - USD raises ValueError (no conversion needed) - `test_unsupported_currency_raises_valueerror` - Unsupported currency raises ValueError - `test_successful_load_returns_dataframe` - Successful load returns DataFrame with 'Close' column @@ -84,7 +86,7 @@ Tests the `CurrencyConverter` for multi-currency support (13 currencies includin --- -### 3. tests/unit/test_data_repository.py (17 tests) +### 3. tests/unit/test_data_repository.py (14 tests) Tests the `YfinanceDataRepository` with 3-tier caching: memory (TTLCache) → DB → yfinance. @@ -122,7 +124,7 @@ Tests the `YfinanceDataRepository` with 3-tier caching: memory (TTLCache) → DB --- -### 4. tests/unit/test_portfolio_manager_helpers.py (14 tests) +### 4. tests/unit/test_portfolio_manager_helpers.py (16 tests) Tests static helper methods in `PortfolioManagerService` (pure functions, no I/O). @@ -140,7 +142,7 @@ Tests static helper methods in `PortfolioManagerService` (pure functions, no I/O - `test_calculate_daily_return_stats_single_return` - Single return edge case (volatility = 0) - `test_calculate_daily_return_stats_with_zeros` - Zero returns don't count as positive/negative -- **TestFormatIndividualResultsList** (5 tests) +- **TestFormatIndividualResultsList** (7 tests) - `test_format_strategy_mode_returns_correct_structure` - Correct format for 'strategy' mode - `test_format_buy_hold_mode_returns_correct_structure` - Correct format for 'buy_hold' mode - `test_format_buy_hold_mode_with_negative_return` - Negative return handled correctly @@ -161,13 +163,13 @@ Tests static helper methods in `PortfolioManagerService` (pure functions, no I/O ### Run All New Tests: ```bash -source venv/bin/activate -cd /home/coontec/source/backtest/backtest_be_fast -python -m pytest tests/unit/test_backtest_engine.py \ - tests/unit/test_currency_converter.py \ - tests/unit/test_data_repository.py \ - tests/unit/test_portfolio_manager_helpers.py \ - -v --tb=short +# 저장소 루트에서 (이 프로젝트는 Docker로 실행됩니다) +docker compose -f compose.dev.yaml exec backtest-be-fast \ + pytest tests/unit/test_backtest_engine.py \ + tests/unit/test_currency_converter.py \ + tests/unit/test_data_repository.py \ + tests/unit/test_portfolio_manager_helpers.py \ + -v --tb=short ``` ### Results: @@ -246,7 +248,7 @@ python -m pytest tests/unit/test_backtest_engine.py \ ## Key Testing Principles Applied 1. **Unit Testing Best Practices** - - Tests are fast (<0.5s total for 59 tests) + - Tests are fast (<0.5s total for these 59 tests; 전체 141건도 0.5초 내) - Tests are isolated (no shared state) - Tests are deterministic (no random data) - Each test has a single responsibility @@ -287,6 +289,6 @@ python -m pytest tests/unit/test_backtest_engine.py \ ## Summary -All 59 unit tests pass successfully, covering critical backend modules with comprehensive test scenarios including happy paths, error handling, and edge cases. Tests follow pytest best practices with proper markers, fixtures, and mocking strategies. The test suite runs fast (0.39s) and provides confidence in code quality without external dependencies. +이 문서가 다루는 4개 모듈의 59건은 모두 통과합니다. `tests/unit` 전체 **141건**도 모두 통과하며, CI의 `Quality Gate` 스테이지(`docker build --target test ./backtest_be_fast`)가 이를 강제합니다. 실패가 보이면 회귀입니다. **Status: ✅ COMPLETE AND PASSING** diff --git a/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md b/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md index be532b0e..c4b62102 100644 --- a/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md +++ b/backtest_be_fast/docs/UNIT_TEST_QUICK_REFERENCE.md @@ -2,15 +2,23 @@ ## Quick Commands -### Run All New Unit Tests +### Run All Unit Tests ```bash -cd /home/coontec/source/backtest/backtest_be_fast -source venv/bin/activate -pytest tests/unit/test_backtest_engine.py \ - tests/unit/test_currency_converter.py \ - tests/unit/test_data_repository.py \ - tests/unit/test_portfolio_manager_helpers.py \ - -v --tb=short +# From the repository root (the project runs in Docker) +docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit -v + +# Same command CI runs as a gate +docker build --target test ./backtest_be_fast +``` + +### Run a Subset +```bash +docker compose -f compose.dev.yaml exec backtest-be-fast \ + pytest tests/unit/test_backtest_engine.py \ + tests/unit/test_currency_converter.py \ + tests/unit/test_data_repository.py \ + tests/unit/test_portfolio_manager_helpers.py \ + -v --tb=short ``` ### Run Individual Test Files @@ -67,12 +75,24 @@ pytest-watch tests/unit | File | Tests | Coverage | |------|-------|----------| +| `test_currency_converter.py` | 19 | CurrencyConverter: get_conversion_multiplier, convert_dataframe_to_usd, load_and_prepare_exchange_rates | +| `test_portfolio_manager_helpers.py` | 16 | PortfolioManagerService: _calculate_weighted_stats, _calculate_daily_return_stats, _format_individual_results_list | +| `test_nth_weekday.py` | 14 | Nth-weekday date resolution (rebalancing / DCA scheduling) | +| `test_data_repository.py` | 14 | YfinanceDataRepository: get_stock_data (3-tier cache), invalidate_cache, TTLCache behavior | +| `test_portfolio_schemas.py` | 12 | Portfolio request schema validation | +| `test_chart_data_service.py` | 12 | Chart data assembly | | `test_backtest_engine.py` | 10 | BacktestEngine: run_backtest, _build_strategy, _convert_result_to_response, _create_fallback_result | -| `test_currency_converter.py` | 18 | CurrencyConverter: get_conversion_multiplier, convert_dataframe_to_usd, load_and_prepare_exchange_rates | -| `test_data_repository.py` | 17 | YfinanceDataRepository: get_stock_data (3-tier cache), invalidate_cache, TTLCache behavior | -| `test_portfolio_manager_helpers.py` | 14 | PortfolioManagerService: _calculate_weighted_stats, _calculate_daily_return_stats, _format_individual_results_list | - -**Total: 59 tests** +| `test_strategy_service.py` | 9 | Strategy resolution and parameter validation | +| `test_nth_weekday_edge_cases.py` | 9 | Nth-weekday boundary cases | +| `test_request_models.py` | 8 | Backtest request model validation | +| `test_rsi_strategy.py` | 6 | RSI strategy requirements | +| `test_bollinger_strategy.py` | 4 | Bollinger Bands strategy requirements | +| `test_sma_strategy.py` | 2 | SMA strategy requirements | +| `test_macd_strategy.py` | 2 | MACD strategy requirements | +| `test_ema_strategy.py` | 2 | EMA strategy requirements | +| `test_buy_hold_strategy.py` | 2 | Buy & Hold strategy requirements | + +**Total: 141 tests** (all passing; a failure is a regression, not pre-existing noise) --- @@ -87,7 +107,7 @@ pytest-watch tests/unit ### test_currency_converter.py - `TestGetConversionMultiplier` (8 tests) - Currency-specific multipliers - `TestConvertDataframeToUsd` (4 tests) - Vectorized conversion -- `TestLoadAndPrepareExchangeRates` (4 tests) - Exchange rate loading +- `TestLoadAndPrepareExchangeRates` (5 tests) - Exchange rate loading - `TestCurrencyConverterEdgeCases` (2 tests) - Edge cases ### test_data_repository.py @@ -100,7 +120,7 @@ pytest-watch tests/unit ### test_portfolio_manager_helpers.py - `TestCalculateWeightedStats` (4 tests) - Weighted statistics - `TestCalculateDailyReturnStats` (5 tests) - Volatility, profit factor -- `TestFormatIndividualResultsList` (5 tests) - Result formatting +- `TestFormatIndividualResultsList` (7 tests) - Result formatting --- @@ -149,7 +169,7 @@ def test_float_comparison(self): ### Successful Run ``` -======================== 59 passed, 2 warnings in 0.39s ======================== +======================= 141 passed, 8 warnings in 0.46s ======================== ``` ### Failed Test Example @@ -185,34 +205,45 @@ pytest tests/unit/test_backtest_engine.py -s ## CI/CD Integration -### GitHub Actions Example -```yaml -- name: Run unit tests - run: | - source venv/bin/activate - pytest tests/unit -v --tb=short +이 저장소의 실제 구성입니다 (예시가 아님). + +`Dockerfile`에 `test` 스테이지가 있고, `Jenkinsfile`의 `Quality Gate` 스테이지가 이를 호출합니다. + +```dockerfile +# backtest_be_fast/Dockerfile +FROM base AS test +COPY app ./app +COPY tests ./tests +COPY pytest.ini ./ +RUN pytest tests/unit -q ``` -### Jenkins Pipeline Example ```groovy -stage('Unit Tests') { +// Jenkinsfile +stage('Quality Gate') { steps { - sh ''' - source venv/bin/activate - pytest tests/unit -v --tb=short --junitxml=test-results.xml - ''' + script { + parallel( + 'Frontend': { sh 'docker build --target test ./backtest_fe' }, + 'Backend': { sh 'docker build --target test ./backtest_be_fast' } + ) + } } } ``` +`test` 스테이지는 최종 이미지의 의존 경로 밖에 있으므로 `docker build`(타깃 미지정)로는 실행되지 않고, 배포 이미지에도 `tests/`가 포함되지 않습니다. DB가 필요한 `tests/integration`은 게이트에 포함하지 않습니다. + +게이트가 실패하면 이미지 빌드와 배포에 도달하지 못합니다. 다만 파이프라인이 `*/main`을 체크아웃하므로 이는 **배포 게이트**이지 병합 게이트가 아닙니다. + --- ## Troubleshooting ### Issue: Tests fail with "ModuleNotFoundError" -**Solution:** Activate virtual environment +**Solution:** 컨테이너 안에서 실행하십시오. 이 프로젝트는 Docker로 돌아가며, 의존성은 컨테이너의 `/opt/venv`에 설치되어 있습니다. ```bash -source venv/bin/activate +docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit -v ``` ### Issue: Tests fail with "asyncio.exceptions.TimeoutError" diff --git a/backtest_be_fast/tests/README.md b/backtest_be_fast/tests/README.md index 744dd395..4dcb6ecd 100644 --- a/backtest_be_fast/tests/README.md +++ b/backtest_be_fast/tests/README.md @@ -119,13 +119,16 @@ pytest -m "not integration" # DB 없이 실행 가능한 테스트만 ## 테스트 현황 -총 **68개 단위 테스트** (통합/E2E 제외) +총 **141개 단위 테스트** (통합/E2E 제외), 전부 통과. -| 카테고리 | 테스트 수 | 상태 | +| 카테고리 | 테스트 수 | 구성 | |---------|----------|------| -| 전략 테스트 | 45+ | 통과 | -| 서비스 테스트 | 15+ | 통과 | -| 스키마 테스트 | 8+ | 통과 | +| 서비스 테스트 | 80 | currency_converter 19, portfolio_manager_helpers 16, data_repository 14, chart_data_service 12, backtest_engine 10, strategy_service 9 | +| 날짜 계산 테스트 | 23 | nth_weekday 14, nth_weekday_edge_cases 9 | +| 스키마 테스트 | 20 | portfolio_schemas 12, request_models 8 | +| 전략 테스트 | 18 | rsi 6, bollinger 4, sma·macd·ema·buy_hold 각 2 | + +CI의 `Quality Gate` 스테이지가 `docker build --target test ./backtest_be_fast`로 이 141건을 강제하므로, 실패는 회귀입니다. ## 테스트 특징 diff --git a/backtest_fe/.dockerignore b/backtest_fe/.dockerignore new file mode 100644 index 00000000..2bb3a1f7 --- /dev/null +++ b/backtest_fe/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +coverage +playwright-report +test-results +.git +.env +.env.* diff --git a/backtest_fe/Dockerfile b/backtest_fe/Dockerfile index b9a0a88f..e6d5bc58 100644 --- a/backtest_fe/Dockerfile +++ b/backtest_fe/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -# Stage 1: Build the React application -FROM node:20.19.0-alpine AS build +# Stage 1: Install dependencies (build/test 공통 기반) +FROM node:20.19.0-alpine AS deps WORKDIR /app @@ -15,13 +15,31 @@ RUN --mount=type=cache,target=/root/.npm \ npm install --no-audit --prefer-offline --no-fund; \ fi +# Stage 2: 품질 게이트 +# +# 최종 이미지의 의존 경로에 없으므로 `docker build`(타깃 미지정)로는 실행되지 +# 않는다. CI가 `docker build --target test`로 명시적으로 호출한다. +# deps 레이어를 재사용하므로 npm ci가 다시 돌지 않는다. +FROM deps AS test + +COPY . . + +# 개별 RUN으로 분리해 어느 단계에서 깨졌는지 로그에서 바로 보이게 한다. +RUN npm run lint +RUN npm run type-check +RUN npm run type-check:test +RUN npm run test:run + +# Stage 3: Build the React application +FROM deps AS build + # Copy rest of sources COPY . . # Build production assets RUN npm run build -# Stage 2: Serve the application with Nginx +# Stage 4: Serve the application with Nginx FROM nginx:stable-alpine COPY --from=build /app/dist /usr/share/nginx/html diff --git a/backtest_fe/README.md b/backtest_fe/README.md index 0426a04d..df5c7cc4 100644 --- a/backtest_fe/README.md +++ b/backtest_fe/README.md @@ -4,13 +4,14 @@ ## 기술 스택 -- **React 18** + **TypeScript** -- **Vite** (빌드 도구) -- **Vitest** (테스트 프레임워크) +- **React 19** + **TypeScript 5** +- **Vite 7** (빌드 도구) +- **Vitest 4** (테스트 프레임워크) +- **Tailwind CSS 4** (스타일링, 설정은 `src/index.css`에 CSS-first로 존재) - **shadcn/ui** (UI 컴포넌트) -- **Recharts** (차트 라이브러리) -- **React Router** (라우팅) -- **MSW** (API 모킹) +- **Recharts 3** (차트 라이브러리) +- **React Router 7** (라우팅) +- **MSW 2** (API 모킹) ## 설치 및 실행 @@ -35,26 +36,23 @@ npm run test:ui # UI 모드 ### 컴포넌트 배치 -#### `src/components/` -**역할**: 앱 레벨 전역 컴포넌트 -- ErrorBoundary (전역 에러 처리) -- Header (앱 헤더) -- ThemeSelector (테마 선택) - -**사용 시기**: 앱 전체에서 단 한 번만 사용되는 레이아웃 컴포넌트 - #### `src/shared/components/` -**역할**: 재사용 가능한 공통 비즈니스 컴포넌트 -- FormField, FormSection (폼 관련) -- ChartLoading, LoadingSpinner (로딩 상태) -- ErrorMessage (에러 표시) -- FinancialTermTooltip (금융 용어 툴팁) +**역할**: 재사용 가능한 공통 컴포넌트. 용도별 하위 디렉터리로 나뉜다. + +| 디렉터리 | 내용 | +|---|---| +| `layout/` | Header, Footer, ErrorBoundary, ThemeSelector | +| `form/` | FormField, FormSection, FormLegend | +| `loading/` | LoadingSpinner, ChartLoading | +| `feedback/` | ErrorMessage | +| `tooltip/` | FinancialTermTooltip | +| `debug/` | PerformanceMonitor | -**사용 시기**: 여러 feature에서 재사용 가능한 비즈니스 로직을 포함한 컴포넌트 +**사용 시기**: 여러 feature에서 재사용 가능한 컴포넌트 #### `src/shared/ui/` **역할**: shadcn/ui 기반 순수 UI 컴포넌트 -- Button, Input, Card, Dialog 등 (17개) +- Button, Input, Card, Dialog 등 (16개) **사용 시기**: 디자인 시스템 레벨의 재사용 가능한 순수 UI 컴포넌트 @@ -154,62 +152,91 @@ src/features/backtest/components/__tests__/BacktestForm.test.tsx ```bash npm run test # Watch 모드 npm run test:run # 1회 실행 -npm run test:coverage # 커버리지 (17.13%) +npm run test:coverage # 커버리지 npm run test:ui # UI 모드 ``` ### 현재 테스트 통계 -- **테스트 파일**: 13개 -- **테스트 케이스**: 98개 +- **테스트 파일**: 16개 +- **테스트 케이스**: 113개 - **통과율**: 100% -- **커버리지**: 17.13% (핵심 로직 70~99%) +- **커버리지**: 21.81% statements / 22.62% lines + +### 테스트 격리에 관한 주의 + +`vitest.config.ts`의 `isolate`를 `false`로 바꾸지 말 것. 모든 테스트 파일이 하나의 happy-dom 환경을 공유하게 되는데, vitest는 직전 실행의 파일별 소요시간을 캐시해 실행 순서를 조정하므로 순서가 매번 달라진다. 그 결과 스위트가 flaky해진다 — 실제로 같은 커밋에서 `113 passed`와 `3 failed`가 번갈아 나온 적이 있다. --- ## 주요 컴포넌트 ### Pages (라우트 진입점) -- `HomePage.tsx` - 단일 종목 백테스트 -- `PortfolioPage.tsx` - 포트폴리오 백테스트 +- `pages/HomePage.tsx` - 랜딩 +- `pages/PortfolioPage.tsx` - 백테스트 (단일 종목 + 포트폴리오) ### Features -- `features/backtest/` - 백테스트 전용 로직 (60+ files) +- `features/backtest/` - 백테스트 전용 로직 (77 files) ### Shared -- `shared/components/` - 공통 비즈니스 컴포넌트 (9 files) -- `shared/ui/` - shadcn/ui 컴포넌트 (17 files) -- `shared/hooks/` - 공통 훅 (5 files) +- `shared/components/` - 공통 컴포넌트 (12 files, 테스트 제외) +- `shared/ui/` - shadcn/ui 컴포넌트 (16 files) +- `shared/hooks/` - 공통 훅 (3 files: useAsync, useForm, useTheme) - `shared/lib/utils/` - 범용 유틸리티 (5 files) --- ## 스타일링 -- **Tailwind CSS** - 유틸리티 퍼스트 -- **CSS Variables** - 테마 시스템 (4개 테마) +- **Tailwind CSS 4** - 유틸리티 퍼스트. v4는 JS 설정 파일을 쓰지 않으므로 `tailwind.config.js`는 없고 설정이 `src/index.css`에 있다. +- **CSS Variables** - 테마 시스템 (`src/themes/`에 4개 테마) - **shadcn/ui** - 컴포넌트 디자인 시스템 +### Tailwind 4에서 주의할 점 + +- 다크 모드는 `@custom-variant dark (&:is(.dark *))`로 정의된다. `useTheme`이 ``에 `.dark`를 토글한다. +- 테마 색상은 `useTheme`이 런타임에 `root.style.setProperty()`로 주입한다. 색상 리터럴을 `@theme` 블록으로 옮기면 빌드타임에 고정되어 테마 전환이 죽는다. +- v4가 자체 `.container`를 방출하므로, v3 동작을 재현한 `.app-container`를 대신 쓴다. + --- ## 개발 도구 ### 린트 및 타입 체크 ```bash -npm run lint # ESLint -npm run lint:fix # 자동 수정 -npm run type-check # TypeScript 타입 체크 +npm run lint # ESLint (에러 0 강제, 경고 상한 3) +npm run lint:fix # 자동 수정 +npm run type-check # 프로덕션 코드 타입 체크 (tsconfig.build.json) +npm run type-check:test # 테스트 코드 타입 체크 (tsconfig.test.json) ``` +`type-check`는 테스트 파일을 제외한다. 테스트 코드는 `type-check:test`가 담당하며, 둘 다 CI 게이트에서 실행된다. 테스트만 따로 체크하는 설정이 없던 시절에 삭제된 함수를 import하는 테스트가 8개월간 방치된 적이 있어 분리해 두었다. + +`lint`의 경고 상한 3은 현재 남아 있는 `react-hooks/exhaustive-deps` 3건을 고정한 래칫이다. 경고가 늘어나는 것을 막되, 의존성 배열을 강제로 바꾸면 런타임 동작이 달라질 수 있어 아직 해소하지 않았다. 해소하면서 상한도 함께 내리는 것이 목표다. + ### 빌드 분석 ```bash -npm run build:analyze # 번들 크기 분석 +npm run build:analyze ``` +**주의**: 현재 이 스크립트는 `build`와 동일한 일을 한다. 번들 분석 플러그인이 설치되어 있지 않고 `--mode analyze`에 대응하는 설정도 없다. 실제 분석이 필요하면 `rollup-plugin-visualizer` 등을 붙여야 한다. + +--- + +## CI + +`Jenkinsfile`의 `Quality Gate` 스테이지가 `docker build --target test ./backtest_fe`로 아래를 순서대로 실행한다. 하나라도 실패하면 이미지 빌드와 배포에 도달하지 못한다. + +``` +npm run lint → npm run type-check → npm run type-check:test → npm run test:run +``` + +이 게이트는 **배포**를 막는다. main 브랜치 보호를 쓰지 않으므로 병합 자체를 막지는 않는다. + --- ## 추가 문서 -- [TEST.md](./TEST.md) - 테스트 가이드 -- [CODEBASE_STRUCTURE_ANALYSIS.md](./CODEBASE_STRUCTURE_ANALYSIS.md) - 구조 상세 분석 -- [TEST_IMPROVEMENT_REPORT.md](./TEST_IMPROVEMENT_REPORT.md) - 테스트 개선 내역 -- [TEST_EXECUTION_SUMMARY.md](./TEST_EXECUTION_SUMMARY.md) - 테스트 실행 결과 +- [docs/architecture/codebase_structure.md](./docs/architecture/codebase_structure.md) - 구조 상세 +- [docs/architecture/state_management.md](./docs/architecture/state_management.md) - 상태 관리 +- [docs/testing/](./docs/testing/) - 테스트 전략·작성·실행 가이드 +- [docs/optimization/](./docs/optimization/) - 차트 성능, 데이터 샘플링 diff --git a/backtest_fe/docs/testing/execution.md b/backtest_fe/docs/testing/execution.md index 3b90de0a..80849b77 100644 --- a/backtest_fe/docs/testing/execution.md +++ b/backtest_fe/docs/testing/execution.md @@ -17,22 +17,31 @@ ## 설정 파일 -- **`vite.config.ts`**: Vitest는 Vite의 설정을 공유합니다. `test` 속성을 통해 Vitest 관련 설정을 추가합니다. +- **`vitest.config.ts`**: Vitest 설정은 `vite.config.ts`와 **분리된 별도 파일**에 있습니다. ```typescript - // vite.config.ts - import { defineConfig } from 'vite'; - + // vitest.config.ts + import { defineConfig } from 'vitest/config'; + export default defineConfig({ - // ... 다른 설정 ... + // ... plugins, resolve.alias ... test: { - globals: true, // describe, it, expect 등을 전역으로 사용 - environment: 'jsdom', // 브라우저 환경 시뮬레이션 - setupFiles: './src/test/setup.ts', // 각 테스트 파일 실행 전 설정 파일 - css: true, // CSS 파일 처리 활성화 + globals: true, // describe, it, expect 등을 전역으로 사용 + environment: 'happy-dom', // 브라우저 환경 시뮬레이션 + setupFiles: ['./src/test/setup.ts'], + css: true, + pool: 'forks', + maxWorkers: 1, + isolate: true, // 아래 주의 참고 + sequence: { concurrent: false }, }, }); ``` -- **`src/test/setup.ts`**: 모든 테스트가 실행되기 전에 필요한 전역 설정을 담당합니다. 예를 들어, `vitest-localstorage-mock`을 설정하거나 `matchMedia`와 같은 브라우저 API를 모킹합니다. + + > **`isolate`를 `false`로 바꾸지 마십시오.** 모든 테스트 파일이 하나의 happy-dom 환경을 공유하게 되는데, vitest는 직전 실행의 파일별 소요시간을 캐시해 실행 순서를 조정하므로 순서가 매번 달라집니다. 그 결과 스위트가 flaky해집니다 — 실제로 같은 커밋에서 `113 passed`와 `3 failed`가 번갈아 나왔고, 깨끗한 도커 빌드에서는 최대 9건까지 실패했습니다. + +- **`src/test/setup.ts`**: 모든 테스트 실행 전 전역 설정을 담당합니다. MSW 서버 기동(`server.listen`), `@testing-library/jest-dom` 매처 등록, happy-dom이 구현하지 않는 `window.alert`/`confirm`/`prompt` 모킹 등이 여기에 있습니다. + +- **`tsconfig.test.json`**: 테스트 코드 전용 타입 체크 설정입니다. `tsconfig.build.json`이 테스트 파일을 제외하고 vitest는 타입 체크를 하지 않으므로, 이 설정이 없으면 삭제된 함수를 import해도 컴파일 단계에서 잡히지 않습니다(실제로 그런 테스트가 8개월간 방치된 적이 있습니다). ## 테스트 실행 @@ -50,12 +59,46 @@ ``` 브라우저에서 테스트 결과, 코드 커버리지, 모듈 의존성 그래프 등을 시각적으로 확인하며 대화형으로 테스트를 실행할 수 있습니다. 개발 중에 특정 테스트만 골라 실행하거나 디버깅할 때 매우 유용합니다. +- **1회 실행 (CI에서 쓰는 형태):** + ```bash + npm run test:run + ``` + +- **타입 체크:** + ```bash + npm run type-check # 프로덕션 코드 (tsconfig.build.json) + npm run type-check:test # 테스트 코드 (tsconfig.test.json) + ``` + 테스트 코드는 `type-check`에 포함되지 않으므로 반드시 `type-check:test`를 함께 돌려야 합니다. + - **E2E 테스트 실행:** ```bash npm run test:e2e ``` Playwright를 사용하여 `e2e/` 디렉토리의 종단간 테스트를 실행합니다. + > 개발 컨테이너에는 Playwright 브라우저가 설치되어 있지 않아 컨테이너 안에서는 실행되지 않습니다. 호스트에서 `npx playwright install` 후 실행하십시오. + +## CI에서의 실행 + +`Jenkinsfile`의 `Quality Gate` 스테이지가 `docker build --target test ./backtest_fe`로 아래를 순서대로 실행합니다. 하나라도 실패하면 이미지 빌드와 배포에 도달하지 못합니다. + +``` +npm run lint → npm run type-check → npm run type-check:test → npm run test:run +``` + +로컬에서 CI와 동일한 조건으로 확인하려면 같은 명령을 그대로 쓰면 됩니다. + +```bash +docker build --target test ./backtest_fe +``` + +E2E는 이 게이트에 포함되지 않습니다. + +## 현재 기준선 + +테스트 파일 16개 / 테스트 113건, 전부 통과. 실패가 보이면 회귀입니다. + ## 파일 구조 - **테스트 파일 위치**: 테스트 대상 파일과 동일한 디렉토리에 `*.test.ts` 또는 `*.test.tsx` 형식으로 위치시키는 것을 권장합니다. (예: `Button.tsx`와 `Button.test.tsx`) diff --git a/backtest_fe/package.json b/backtest_fe/package.json index f409f48c..266c74b3 100644 --- a/backtest_fe/package.json +++ b/backtest_fe/package.json @@ -83,12 +83,13 @@ }, "scripts": { "dev": "vite --host", - "build": "tsc -p tsconfig.build.json && vite build", - "build:analyze": "tsc -p tsconfig.build.json && vite build --mode analyze", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "build": "tsc -p tsconfig.build.json && NODE_ENV=production vite build", + "build:analyze": "tsc -p tsconfig.build.json && NODE_ENV=production vite build --mode analyze", + "lint": "eslint . --report-unused-disable-directives --max-warnings 3", "lint:fix": "eslint . --fix", "preview": "vite preview --host", "type-check": "tsc --noEmit -p tsconfig.build.json", + "type-check:test": "tsc --noEmit -p tsconfig.test.json", "clean": "rm -rf dist node_modules/.vite", "test": "vitest", "test:run": "vitest run", diff --git a/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx b/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx index b2983dac..b1446cc7 100644 --- a/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx +++ b/backtest_fe/src/features/backtest/components/results/__tests__/UnifiedInfoSection.test.tsx @@ -14,6 +14,8 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import UnifiedInfoSection from '../UnifiedInfoSection'; +import type { NewsItem } from '../../../model/types/backtest-result-types'; +import type { VolatilityEvent } from '../../../model/types/volatility-news-types'; describe('UnifiedInfoSection', () => { const mockVolatilityEvents = { @@ -22,10 +24,11 @@ describe('UnifiedInfoSection', () => { date: '2023-01-05', daily_return: 7.5, close_price: 150.25, + volume: 98_000_000, event_type: '급등', }, ], - }; + } satisfies Record; const mockLatestNews = { AAPL: [ @@ -36,7 +39,7 @@ describe('UnifiedInfoSection', () => { pubDate: '2023-01-12', }, ], - }; + } satisfies Record; describe('정상 렌더링', () => { it('급등락 이벤트와 뉴스가 있을 때 컴포넌트를 렌더링한다', () => { @@ -73,6 +76,7 @@ describe('UnifiedInfoSection', () => { date: '2023-01-08', daily_return: 5.8, close_price: 105.30, + volume: 21_000_000, event_type: '급등', }], }; diff --git a/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts b/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts index 22d8ab04..caf234cc 100644 --- a/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts +++ b/backtest_fe/src/features/backtest/hooks/__tests__/useBacktestForm.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, assert } from 'vitest' import { renderHook, waitFor, act } from '@testing-library/react' import { useBacktestForm } from '../useBacktestForm' @@ -41,8 +41,13 @@ describe('useBacktestForm', () => { }) expect(result.current.state.portfolioInputMode).toBe('weight') - expect(result.current.state.portfolio[0].amount).toBe(10000) - expect(result.current.state.portfolio[1].amount).toBe(10000) + expect(result.current.state.portfolio).toHaveLength(2) + const [first, second] = result.current.state.portfolio + assert.isDefined(first) + assert.isDefined(second) + expect(second.symbol).toBe('MSFT') + expect(first.amount).toBe(10000) + expect(second.amount).toBe(10000) expect(result.current.helpers.getTotalAmount()).toBe(20000) }) diff --git a/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts b/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts index 209893d6..cbf25135 100644 --- a/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts +++ b/backtest_fe/src/features/backtest/model/__tests__/backtestFormReducer.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, assert } from 'vitest' import { backtestFormReducer, backtestFormHelpers } from '../backtestFormReducer' import { initialBacktestFormState, type BacktestFormState } from '../types/backtest-form-types' import { ASSET_TYPES } from '../strategyConfig' @@ -31,10 +31,16 @@ describe('backtestFormReducer', () => { payload: 'weight', }) - expect(nextState.portfolio[0].weight).toBe(60) - expect(nextState.portfolio[1].weight).toBe(40) - expect(nextState.portfolio[0].amount).toBe(6000) - expect(nextState.portfolio[1].amount).toBe(4000) + expect(nextState.portfolio).toHaveLength(2) + const [aapl, msft] = nextState.portfolio + assert.isDefined(aapl) + assert.isDefined(msft) + expect(aapl.symbol).toBe('AAPL') + expect(msft.symbol).toBe('MSFT') + expect(aapl.weight).toBe(60) + expect(msft.weight).toBe(40) + expect(aapl.amount).toBe(6000) + expect(msft.amount).toBe(4000) expect(nextState.portfolioInputMode).toBe('weight') }) @@ -67,8 +73,14 @@ describe('backtestFormReducer', () => { }) expect(nextState.totalInvestment).toBe(20000) - expect(nextState.portfolio[0].amount).toBe(12000) - expect(nextState.portfolio[1].amount).toBe(8000) + expect(nextState.portfolio).toHaveLength(2) + const [aapl, msft] = nextState.portfolio + assert.isDefined(aapl) + assert.isDefined(msft) + expect(aapl.symbol).toBe('AAPL') + expect(msft.symbol).toBe('MSFT') + expect(aapl.amount).toBe(12000) + expect(msft.amount).toBe(8000) }) it('clears weight when updating amounts directly in amount mode', () => { @@ -90,8 +102,12 @@ describe('backtestFormReducer', () => { payload: { index: 0, field: 'amount', value: 15000 }, }) - expect(updated.portfolio[0].amount).toBe(15000) - expect(updated.portfolio[0].weight).toBeUndefined() + expect(updated.portfolio).toHaveLength(1) + const [updatedStock] = updated.portfolio + assert.isDefined(updatedStock) + expect(updatedStock.symbol).toBe('AAPL') + expect(updatedStock.amount).toBe(15000) + expect(updatedStock.weight).toBeUndefined() }) }) diff --git a/backtest_fe/__tests__/recalcAmountsByWeight.test.ts b/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts similarity index 70% rename from backtest_fe/__tests__/recalcAmountsByWeight.test.ts rename to backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts index 0e7baf90..ca0c3d95 100644 --- a/backtest_fe/__tests__/recalcAmountsByWeight.test.ts +++ b/backtest_fe/src/features/backtest/model/__tests__/recalcAmountsByWeight.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest'; -import { getDcaPeriodInfo } from '../src/features/backtest/model/constants/dcaConfig'; +import { describe, it, expect, assert } from 'vitest'; +import { DcaFrequency, getDcaPeriodInfo } from '../constants/dcaConfig'; +import { Stock } from '../types/backtest-form-types'; // DCA 주기를 근사 일수로 변환하는 헬퍼 함수 -const getDcaApproxDays = (frequency: string): number => { - const { type, interval } = getDcaPeriodInfo(frequency as any); +const getDcaApproxDays = (frequency: DcaFrequency): number => { + const { type, interval } = getDcaPeriodInfo(frequency); if (type === 'weekly') { return interval * 7; } else if (type === 'monthly') { @@ -13,7 +14,7 @@ const getDcaApproxDays = (frequency: string): number => { }; // reducer의 recalcAmountsByWeight 함수 복사 -const recalcAmountsByWeight = (portfolio: any[], totalInvestment: number, startDate?: string, endDate?: string) => { +const recalcAmountsByWeight = (portfolio: Stock[], totalInvestment: number, startDate?: string, endDate?: string) => { if (!startDate || !endDate || totalInvestment === 0) { // 날짜 정보 없으면 기본 계산 return portfolio.map(s => @@ -29,26 +30,25 @@ const recalcAmountsByWeight = (portfolio: any[], totalInvestment: number, startD const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); // Step 1: weight 항목들만 먼저 처리해서 총 투자액 누적 - const weightIndices: number[] = []; + const weightEntries: { index: number; stock: Stock }[] = []; let accumulatedTotal = 0; const results = new Map(); // index -> amount portfolio.forEach((s, index) => { if (typeof s.weight === 'number') { - weightIndices.push(index); + weightEntries.push({ index, stock: s }); } }); // Step 2: weight 항목들의 비중 기반 투자액 계산 (마지막은 오차 보정) - weightIndices.forEach((index, pos) => { - const s = portfolio[index]; - const isLastWeightItem = pos === weightIndices.length - 1; - const totalAmountForStock = (s.weight / 100) * totalInvestment; + weightEntries.forEach(({ index, stock: s }, pos) => { + const isLastWeightItem = pos === weightEntries.length - 1; + const totalAmountForStock = ((s.weight ?? 0) / 100) * totalInvestment; if (isLastWeightItem) { // 마지막 weight 항목: 오차 보정 (totalInvestment - 이전까지 누적) const correctedTotalAmount = totalInvestment - accumulatedTotal; - + if (s.investmentType === 'dca') { const intervalDays = getDcaApproxDays(s.dcaFrequency || 'monthly_1'); const dcaPeriods = Math.max(1, Math.floor(days / intervalDays) + 1); @@ -75,8 +75,9 @@ const recalcAmountsByWeight = (portfolio: any[], totalInvestment: number, startD // Step 3: 최종 결과 반영 return portfolio.map((s, index) => { - if (results.has(index)) { - return { ...s, amount: results.get(index)! }; + const amount = results.get(index); + if (amount !== undefined) { + return { ...s, amount }; } return s; }); @@ -84,7 +85,7 @@ const recalcAmountsByWeight = (portfolio: any[], totalInvestment: number, startD describe('recalcAmountsByWeight', () => { it('should calculate correct DCA amounts for 50/50 portfolio with $10,000', () => { - const portfolio = [ + const portfolio: Stock[] = [ { symbol: 'AAPL', amount: 0, @@ -104,23 +105,25 @@ describe('recalcAmountsByWeight', () => { ]; const result = recalcAmountsByWeight(portfolio, 10000, '2025-01-01', '2025-10-31'); - - console.log('Portfolio after recalc:', result); - + + // 입력 종목 수만큼 그대로 반환되어야 한다 (항목 유실/추가 금지) + expect(result).toHaveLength(2); + const [aapl, googl] = result; + assert.isDefined(aapl); + assert.isDefined(googl); + expect(aapl.symbol).toBe('AAPL'); + expect(googl.symbol).toBe('GOOGL'); + // AAPL: $5,000 / 11 periods = $454.55 → $454 // GOOGL: ($10,000 - $4,994) / 11 = $455 - expect(result[0].amount).toBeGreaterThan(0); - expect(result[1].amount).toBeGreaterThan(0); - + expect(aapl.amount).toBeGreaterThan(0); + expect(googl.amount).toBeGreaterThan(0); + // 검증: 각 종목의 총 투자액 계산 - const aapl_total = result[0].amount * 11; // 회당 금액 × 11 periods - const googl_total = result[1].amount * 11; // 회당 금액 × 11 periods + const aapl_total = aapl.amount * 11; // 회당 금액 × 11 periods + const googl_total = googl.amount * 11; // 회당 금액 × 11 periods const combined_total = aapl_total + googl_total; - - console.log(`AAPL: $${result[0].amount}/period × 11 = $${aapl_total}`); - console.log(`GOOGL: $${result[1].amount}/period × 11 = $${googl_total}`); - console.log(`Total: $${combined_total}`); - + // 총 투자액이 $10,000 근처여야 함 (±5%) expect(combined_total).toBeGreaterThanOrEqual(9500); expect(combined_total).toBeLessThanOrEqual(10500); diff --git a/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts b/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts index c68a2fa2..f59fa0b6 100644 --- a/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts +++ b/backtest_fe/src/features/backtest/services/__tests__/backtestService.integration.test.ts @@ -68,7 +68,7 @@ describe('BacktestService (integration)', () => { } server.use( - http.post(`${TEST_BASE_URL}/api/v1/backtest`, async ({ request }) => { + http.post(`${TEST_BASE_URL}/api/v1/backtest`, async ({ request }) => { capturedBody = await request.json() return HttpResponse.json(mockResponse) }) diff --git a/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts b/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts index 5e489206..6c1bbde6 100644 --- a/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts +++ b/backtest_fe/src/features/backtest/utils/__tests__/portfolioCalculations.test.ts @@ -1,21 +1,20 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, assert } from 'vitest'; import { getDcaAdjustedTotal, getDcaAmountFromWeight, } from '../portfolioCalculations'; -import { getDcaWeeks } from '../../model/strategyConfig'; +import { calculateDcaPeriods } from '../calculateDcaPeriods'; import type { DcaFrequency } from '../../model/strategyConfig'; describe('portfolioCalculations', () => { - // 테스트 날짜: 39주 (279일) - // 2025-01-01 ~ 2025-10-07 = 279일 = 39주 + // 테스트 기간: 2025-01-01 ~ 2025-10-07 = 279일 const startDate = '2025-01-01'; const endDate = '2025-10-07'; - // DCA 횟수 계산: - // - weekly_4: 39주 / 4주 = 9, +1 = 10회 - // - weekly_8: 39주 / 8주 = 4, +1 = 5회 - // - weekly_12: 39주 / 12주 = 3, +1 = 4회 + // DCA 횟수 계산 (calculateDcaPeriods: floor(기간일수 / 주기일수) + 1, weekly=7일/monthly=30일 근사): + // - monthly_1 (30일): 279 / 30 = 9, +1 = 10회 + // - monthly_2 (60일): 279 / 60 = 4, +1 = 5회 + // - monthly_3 (90일): 279 / 90 = 3, +1 = 4회 describe('getDcaAdjustedTotal', () => { it('should calculate DCA-adjusted total correctly', () => { @@ -27,7 +26,7 @@ describe('portfolioCalculations', () => { { amount: 10000, investmentType: 'dca', - dcaFrequency: 'weekly_4', + dcaFrequency: 'monthly_1', }, { amount: 10000, @@ -37,7 +36,7 @@ describe('portfolioCalculations', () => { const total = getDcaAdjustedTotal(portfolio, startDate, endDate); - // 39주 / 4주 = 9, +1 = 10회 DCA + // 279일 / 30일 = 9, +1 = 10회 DCA // AAPL DCA: 10,000 × 10 = 100,000 // GOOGL lump_sum: 10,000 × 1 = 10,000 // 총액: 110,000 @@ -97,7 +96,7 @@ describe('portfolioCalculations', () => { { amount: 10000, investmentType: 'dca', - dcaFrequency: 'weekly_4', + dcaFrequency: 'monthly_1', }, { amount: 10000, @@ -120,12 +119,12 @@ describe('portfolioCalculations', () => { describe('getDcaAmountFromWeight', () => { it('should calculate DCA per-period amount from weight', () => { // 총 $20,000, AAPL 60% = $12,000 - // 39주 / 4주 = 9, +1 = 10회 DCA + // 279일 / 30일 = 9, +1 = 10회 DCA // 회당 금액 = 12,000 / 10 = 1,200 const amount = getDcaAmountFromWeight( 60, 20000, - 'weekly_4', + 'monthly_1', startDate, endDate ); @@ -139,40 +138,40 @@ describe('portfolioCalculations', () => { const amount = getDcaAmountFromWeight( 40, 20000, - 'weekly_4', + 'monthly_1', startDate, endDate ); // GOOGL 40% = $8,000 - // DCA로 계산: 8,000 / 10 = 800 + // DCA로 계산: 8,000 / 10회 = 800 expect(amount).toBe(800); }); it('should handle different DCA frequencies', () => { - // 8주 주기: 39주 / 8주 = 4, +1 = 5회 - const amount_8weeks = getDcaAmountFromWeight( + // 2개월 주기(60일): 279 / 60 = 4, +1 = 5회 + const amount_2months = getDcaAmountFromWeight( 60, 20000, - 'weekly_8', + 'monthly_2', startDate, endDate ); - // 12주 주기: 39주 / 12주 = 3, +1 = 4회 - const amount_12weeks = getDcaAmountFromWeight( + // 3개월 주기(90일): 279 / 90 = 3, +1 = 4회 + const amount_3months = getDcaAmountFromWeight( 60, 20000, - 'weekly_12', + 'monthly_3', startDate, endDate ); // 12,000 / 5 = 2,400 - expect(amount_8weeks).toBe(2400); + expect(amount_2months).toBe(2400); // 12,000 / 4 = 3,000 - expect(amount_12weeks).toBe(3000); + expect(amount_3months).toBe(3000); }); it('should return same amount when no dates provided', () => { @@ -180,27 +179,30 @@ describe('portfolioCalculations', () => { const amount = getDcaAmountFromWeight( 50, 20000, - 'weekly_4' + 'monthly_1' ); expect(amount).toBe(10000); // 50% of 20,000 }); }); - describe('getDcaWeeks', () => { - it('should return correct weeks for each frequency', () => { - expect(getDcaWeeks('weekly_1')).toBe(1); - expect(getDcaWeeks('weekly_2')).toBe(2); - expect(getDcaWeeks('weekly_4')).toBe(4); - expect(getDcaWeeks('weekly_8')).toBe(8); - expect(getDcaWeeks('weekly_12')).toBe(12); - expect(getDcaWeeks('weekly_24')).toBe(24); - expect(getDcaWeeks('weekly_48')).toBe(48); + describe('calculateDcaPeriods', () => { + it('should return correct period count for each frequency', () => { + // 기간 279일 기준, floor(279 / 주기일수) + 1 (첫 투자 포함) + expect(calculateDcaPeriods(startDate, endDate, 'weekly_1')).toBe(40); // 7일: 39 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'weekly_2')).toBe(20); // 14일: 19 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_1')).toBe(10); // 30일: 9 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_2')).toBe(5); // 60일: 4 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_3')).toBe(4); // 90일: 3 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_6')).toBe(2); // 180일: 1 + 1 + expect(calculateDcaPeriods(startDate, endDate, 'monthly_12')).toBe(1); // 360일: 0 + 1 }); - it('should default to 1 week for unknown frequency', () => { - // getDcaWeeks는 DcaFrequency 타입으로 제한되므로 존재하는 빈도만 테스트 - expect(getDcaWeeks('weekly_1')).toBe(1); + it('should return at least 1 period when the range is shorter than one interval', () => { + // 기간이 주기보다 짧아도 첫 투자는 발생하므로 최소 1회 + expect(calculateDcaPeriods('2025-01-01', '2025-01-05', 'monthly_1')).toBe(1); + // 시작일 = 종료일인 경우에도 최소 1회 (Math.max(1, ...)) + expect(calculateDcaPeriods('2025-01-01', '2025-01-01', 'weekly_1')).toBe(1); }); }); @@ -215,7 +217,7 @@ describe('portfolioCalculations', () => { { amount: 10000, investmentType: 'dca', - dcaFrequency: 'weekly_4', + dcaFrequency: 'monthly_1', }, { amount: 10000, @@ -224,8 +226,12 @@ describe('portfolioCalculations', () => { ]; const total = getDcaAdjustedTotal(portfolio, startDate, endDate); - const aapl_weight = (portfolio[0].amount / total) * 100; - const googl_weight = (portfolio[1].amount / total) * 100; + expect(portfolio).toHaveLength(2); + const [dcaEntry, lumpSumEntry] = portfolio; + assert.isDefined(dcaEntry); + assert.isDefined(lumpSumEntry); + const aapl_weight = (dcaEntry.amount / total) * 100; + const googl_weight = (lumpSumEntry.amount / total) * 100; // 총액: 110,000 // AAPL: 10,000 / 110,000 = 9.09% @@ -249,7 +255,7 @@ describe('portfolioCalculations', () => { const aapl_amount = getDcaAmountFromWeight( aapl_weight, totalInvestment, - 'weekly_4', + 'monthly_1', startDate, endDate ); @@ -257,12 +263,12 @@ describe('portfolioCalculations', () => { const googl_amount = getDcaAmountFromWeight( googl_weight, totalInvestment, - 'weekly_4', + 'monthly_1', startDate, endDate ); - // 39주 / 4주 = 9, +1 = 10회 + // 279일 / 30일 = 9, +1 = 10회 // AAPL: 60% × $20,000 = $12,000 / 10회 = $1,200 // GOOGL: 40% × $20,000 = $8,000 / 10회 = $800 expect(aapl_amount).toBe(1200); diff --git a/backtest_fe/src/lib/__tests__/chartUtils.test.ts b/backtest_fe/src/lib/__tests__/chartUtils.test.ts index e5741aaa..2183ef56 100644 --- a/backtest_fe/src/lib/__tests__/chartUtils.test.ts +++ b/backtest_fe/src/lib/__tests__/chartUtils.test.ts @@ -25,8 +25,8 @@ const formatChartDate = (dateString: string, format: 'short' | 'long' = 'short') // 짧은 형식: MM/DD return `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}` } else { - // 긴 형식: YYYY-MM-DD - return date.toISOString().split('T')[0] + // 긴 형식: YYYY-MM-DD (ISO 문자열의 앞 10자) + return date.toISOString().slice(0, 10) } } diff --git a/backtest_fe/src/shared/api/base.ts b/backtest_fe/src/shared/api/base.ts index 7990e81b..9127c223 100644 --- a/backtest_fe/src/shared/api/base.ts +++ b/backtest_fe/src/shared/api/base.ts @@ -6,11 +6,3 @@ export const getApiBaseUrl = (): string => { // 빈 문자열 반환: backtestService.ts에서 전체 경로(/api/v1/...)를 사용 return ''; }; - -export const buildApiUrl = (path: string): string => { - const base = getApiBaseUrl(); - if (!path.startsWith('/')) { - return `${base}/${path}`; - } - return `${base}${path}`; -}; diff --git a/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx b/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx index 4bf42755..43c74887 100644 --- a/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx +++ b/backtest_fe/src/shared/components/layout/__tests__/ThemeSelector.test.tsx @@ -137,7 +137,13 @@ describe('ThemeSelector', () => { it('toggles dark mode when the toggle button is clicked', async () => { const user = userEvent.setup() - render() + + // 다크 모드 토글은 opt-in이라 기본값에서는 렌더링되지 않는다 + const { unmount } = render() + expect(screen.queryByRole('button', { name: /라이트|다크/ })).not.toBeInTheDocument() + unmount() + + render() await user.click(screen.getByRole('button', { name: /라이트/ })) diff --git a/backtest_fe/src/shared/config/index.ts b/backtest_fe/src/shared/config/index.ts deleted file mode 100644 index 921c4a2e..00000000 --- a/backtest_fe/src/shared/config/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 환경 변수 및 애플리케이션 설정 - */ - -interface AppConfig { - readonly API_BASE_URL: string; - readonly WS_BASE_URL: string; - readonly NODE_ENV: 'development' | 'production' | 'test'; - readonly IS_DEVELOPMENT: boolean; - readonly IS_PRODUCTION: boolean; - readonly IS_TEST: boolean; - readonly APP_VERSION: string; - readonly BUILD_TIME: string; -} - -// API URL 빌드 함수 -const buildApiUrl = (): string => { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - - // 상대 경로인 경우 현재 origin 사용 - if (baseUrl?.startsWith('/')) { - return `${window.location.origin}${baseUrl}`; - } - - // 절대 URL인 경우 그대로 사용 - if (baseUrl?.startsWith('http')) { - return baseUrl; - } - - // 기본값: 현재 origin의 /api - return `${window.location.origin}/api`; -}; - -// WebSocket URL 빌드 함수 -const buildWsUrl = (): string => { - const baseUrl = buildApiUrl(); - return baseUrl.replace(/^http/, 'ws') + '/ws'; -}; - -export const config: AppConfig = { - API_BASE_URL: buildApiUrl(), - WS_BASE_URL: buildWsUrl(), - NODE_ENV: (import.meta.env.MODE as AppConfig['NODE_ENV']) || 'development', - IS_DEVELOPMENT: import.meta.env.MODE === 'development', - IS_PRODUCTION: import.meta.env.MODE === 'production', - IS_TEST: import.meta.env.MODE === 'test', - APP_VERSION: import.meta.env.VITE_APP_VERSION || '1.0.0', - BUILD_TIME: import.meta.env.VITE_BUILD_TIME || new Date().toISOString(), -}; - -export default config; \ No newline at end of file diff --git a/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts b/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts index 3bb51c2b..0c764b85 100644 --- a/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts +++ b/backtest_fe/src/shared/hooks/__tests__/useForm.test.ts @@ -6,7 +6,9 @@ import { describe, it, expect, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useForm } from '../useForm' -interface TestFormData { +// `useForm` requires `T extends Record`. +// A type alias of an object literal gets an implicit index signature; an interface does not. +type TestFormData = { name: string email: string age: number @@ -151,7 +153,7 @@ describe('useForm', () => { }) it('should create checkbox handlers', () => { - interface CheckboxForm { + type CheckboxForm = { isChecked: boolean } diff --git a/backtest_fe/tsconfig.test.json b/backtest_fe/tsconfig.test.json new file mode 100644 index 00000000..0467e6b0 --- /dev/null +++ b/backtest_fe/tsconfig.test.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + /* vitest.config.ts sets `globals: true`, so describe/it/expect/vi are ambient. */ + /* vite.config.ts / vitest.config.ts use `process` and `__dirname` -> node types. */ + "types": ["vitest/globals", "node", "@testing-library/jest-dom"] + }, + "include": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/__tests__/**/*.ts", + "src/**/__tests__/**/*.tsx", + "src/test/**/*.ts", + "src/test/**/*.tsx", + "src/vite-env.d.ts", + "vite.config.ts", + "vitest.config.ts" + ], + "references": [] +} diff --git a/backtest_fe/vite.config.ts b/backtest_fe/vite.config.ts index 1fb2748e..5b2225c2 100644 --- a/backtest_fe/vite.config.ts +++ b/backtest_fe/vite.config.ts @@ -40,10 +40,6 @@ export default defineConfig(({ mode }) => ({ target: FASTAPI_TARGET, changeOrigin: true, }, - '/api/v1/naver-news': { - target: FASTAPI_TARGET, - changeOrigin: true, - }, } }, build: { diff --git a/backtest_fe/vitest.config.ts b/backtest_fe/vitest.config.ts index 3f0ae2da..1b923561 100644 --- a/backtest_fe/vitest.config.ts +++ b/backtest_fe/vitest.config.ts @@ -17,7 +17,15 @@ export default defineConfig({ pool: 'forks', // Vitest 4: poolOptions 제거됨. singleFork: true === maxWorkers: 1 + isolate: false maxWorkers: 1, - isolate: false, + // isolate: false는 쓰지 않는다. + // + // 모든 테스트 파일이 하나의 happy-dom 환경을 공유하게 되는데, vitest는 + // 직전 실행 소요시간을 캐시해 파일 실행 순서를 조정하므로 순서가 실행마다 + // 바뀐다. 그 결과 스위트가 flaky해진다 — 같은 커밋에서 113 passed와 + // 3 failed가 번갈아 나오는 것을 확인했다(routing / ThemeSelector / + // backtestService.integration, 최대 9건까지 실패). + // 격리 비용보다 결과를 믿을 수 있는 편이 중요하다. + isolate: true, sequence: { concurrent: false, }, diff --git a/compose.dev-prod.yaml b/compose.dev-prod.yaml index 4ae17bab..d763e3f2 100644 --- a/compose.dev-prod.yaml +++ b/compose.dev-prod.yaml @@ -48,7 +48,10 @@ services: env_file: - .env environment: - - "VITE_API_BASE_URL=${VITE_API_BASE_URL:-/api}" + # 비워 두어야 한다. 서비스 레이어가 전체 경로(/api/v1/...)를 넘기므로 + # /api를 넣으면 /api/api/v1/backtest가 되어 프록시에 매칭되지 않는다. + # ':-'가 아닌 '-'를 써서 빈 값이 그대로 유지되도록 한다. + - "VITE_API_BASE_URL=${VITE_API_BASE_URL-}" - "API_PROXY_TARGET=${API_PROXY_TARGET:-http://backtest-be-fast:8000}" - "FASTAPI_PROXY_TARGET=${FASTAPI_PROXY_TARGET:-http://backtest-be-fast:8000}" command: ["npm", "run", "dev"]