diff --git a/CLAUDE.md b/CLAUDE.md index d2826635..4223b8de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ 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 153 unit tests, FE 104 tests — both fully green. Any failure is a regression, not pre-existing noise. +- **Current baseline:** BE 180 unit tests + 10 integration, FE 112 tests — all 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 diff --git a/TODO.md b/TODO.md index ba9b46a2..6f7a2e4f 100644 --- a/TODO.md +++ b/TODO.md @@ -36,8 +36,8 @@ - [ ] **P1-04 [be]** 〔교차〕 buy&hold 경로 weight 모드 미구현 → ZeroDivisionError가 200으로 — `per_period_amount = 0`("나중에 처리" 주석뿐, `portfolio_manager_service.py:572-574`) → `portfolio_metrics.py:83`에서 0 나눗셈. Codex 재현: `weight=100` 정상 요청이 `float division by zero` 실패(FE는 비중을 금액으로 환산해 보내므로 공개 API 결함이 가려져 있음). 조치(계약 확정): weight 지원 시 총투자금 필수 필드 추가 + 서버 환산, 미지원 시 스키마에서 제거. 어느 쪽이든 0 나눗셈은 422로 사전 차단. - [x] **P1-05 [be]** ✅ 2026-08-02 수정 (`commission=request.commission` 전달; 회귀 테스트 2개 — 0.03 요청이 0.002로 바뀌던 것 확인 후 수정) 〔교차〕 전략 포트폴리오에서 사용자 수수료 무시 — 종목별 `BacktestRequest` 생성 시 `commission` 미전달(`portfolio_manager_service.py:354-361`) → 스키마 기본 0.002 적용. Codex 재현: 수수료 3% 요청이 내부 실행에서 0.2%로 바뀜. 조치: `commission=request.commission` 전달 + 회귀 테스트(P2-36). - [x] **P1-06 [be]** ✅ 2026-08-02 수정 (거래가능 풀 = TPV − 상장폐지 가치. 측정된 왜곡: 2종목·수수료0에서 총액 $200→$300(+50%), 3종목·수수료2%에서 수수료가 $0.10 대신 $1.10(11배). "리밸런싱 전후 총액 = 수수료만큼만 감소" 불변식 테스트 5개. 후속: P3-27) — 상장폐지 종목 보유 시 리밸런싱마다 유령 자산 생성 — 조정 타깃 비중은 거래가능 종목 합 1.0으로 재정규화되고 각자 `TPV × target_weight` 배정(`portfolio_rebalancer.py:167`), 상장폐지 종목은 주식 수 유지(`:197-198,213`)하면서 그 가치가 TPV에도 포함(`portfolio_simulation_engine.py:131-134`의 마지막 유효가 주입) → 리밸런싱 직후 총액 = TPV + 상장폐지분(중복 계상). 조치: 거래가능 자산에는 `(TPV − 상장폐지 가치)`만 배분. 구조적 해법은 P2-14 참조. -- [ ] **P1-07 [be]** ⚠️ 배치 2에서 착수했으나 미완료(에이전트 중단, 코드 변경 없음). 남은 P1 중 최대 구조 변경이므로 단독 작업으로 진행할 것. **완료 기준: 평평한 가격·수수료 0 DCA의 총수익률 = 0.0%** 〔교차〕 DCA 계획 회차 vs 실행 회차 불일치 → 미집행 투자금이 손실처럼 증발 — 분모는 30일 근사 계획 총액(`portfolio_manager_service.py:551-563`), 실제 매수는 Nth-weekday 달력 + 가격 없으면 스킵(초회 매수 미카운트, `portfolio_dca_manager.py:149-177`). Codex 재현: 2024년 전체·월 $1,000·고정 주가 $100·수수료 0에서 실제 12회 매수인데 분모 $13,000 → 총수익률 −7.69%. FE도 30일 근사를 별도 계산(`calculateDcaPeriods.ts`)해 BE와 어긋날 수 있음. 조치: 실제 스케줄 생성기를 단일 소스로, 미집행 회차는 현금 계상, 납입 누계/평가금 시계열 분리(시간가중수익률 검토), FE 표시값 동기화. **완료 조건: 고정 주가·수수료 0 DCA의 총수익률 = 0%.** -- [ ] **P1-08 [be]** 시뮬레이션 첫날 가격 없는 자산은 영원히 미매수 → 자본 증발 — `execute_initial_purchases`가 스킵 후 재시도 없음(`portfolio_dca_manager.py:55-58`; `is_first_day` 1회성, `portfolio_simulation_engine.py:353-366`; 선두 ffill 불가). 혼합 KR/US 포트폴리오에서 한쪽 휴장일 시작이면 재현(예: 2024-07-04). 조치: 첫 가격 등장일에 초기 매수 재시도. +- [x] **P1-07 [be]** ✅ 2026-08-02 수정 (`generate_periodic_schedule`로 시뮬레이션과 동일한 Nth-weekday 예정일을 생성해 `dca_periods = 1 + 예정일 수`로 산출, 초회 매수를 `executed_count`에 계상. 완료 기준 충족: 고정가·수수료0 DCA 총수익률 0.0% — 수정 전 월간 −7.69%, 분기 −20%. 테스트 14개) 〔교차〕 DCA 계획 회차 vs 실행 회차 불일치 → 미집행 투자금이 손실처럼 증발 — 분모는 30일 근사 계획 총액(`portfolio_manager_service.py:551-563`), 실제 매수는 Nth-weekday 달력 + 가격 없으면 스킵(초회 매수 미카운트, `portfolio_dca_manager.py:149-177`). Codex 재현: 2024년 전체·월 $1,000·고정 주가 $100·수수료 0에서 실제 12회 매수인데 분모 $13,000 → 총수익률 −7.69%. FE도 30일 근사를 별도 계산(`calculateDcaPeriods.ts`)해 BE와 어긋날 수 있음. 조치: 실제 스케줄 생성기를 단일 소스로, 미집행 회차는 현금 계상, 납입 누계/평가금 시계열 분리(시간가중수익률 검토), FE 표시값 동기화. **완료 조건: 고정 주가·수수료 0 DCA의 총수익률 = 0%.** +- [x] **P1-08 [be]** ✅ 2026-08-02 수정 (`PortfolioState.pending_initial_keys`로 초기 매수 대상을 추적해 각 종목이 처음 가격을 갖는 날 매수. 수정 전 2종목 중 1종목 누락 시 −50% 보고) — 시뮬레이션 첫날 가격 없는 자산은 영원히 미매수 → 자본 증발 — `execute_initial_purchases`가 스킵 후 재시도 없음(`portfolio_dca_manager.py:55-58`; `is_first_day` 1회성, `portfolio_simulation_engine.py:353-366`; 선두 ffill 불가). 혼합 KR/US 포트폴리오에서 한쪽 휴장일 시작이면 재현(예: 2024-07-04). 조치: 첫 가격 등장일에 초기 매수 재시도. - [x] **P1-09 [be/fe]** ✅ 2026-08-02 수정 (catch-all 3곳 → 로깅 후 재-raise, 엔드포인트 통과 분기 제거. 검증: 일반 예외→500이며 유출 문자열 부재·불투명 에러 ID 존재, ValidationError→422, DataNotFoundError→404, 성공 형태 불변. 잘못된 계약을 고정하던 통합 테스트 1건도 422 기대로 수정) 〔교차〕 모든 포트폴리오 실패가 HTTP 200 + 원시 예외 문자열 — 매니저 catch-all(`portfolio_manager_service.py:265-271,497-506,913-922`)을 엔드포인트가 그대로 통과(`backtest.py:78-79`) → `@handle_portfolio_errors`의 4xx/5xx 매핑·에러 ID 체계 무력화, 내부 문자열 노출. FE는 이 오류 payload를 성공 결과처럼 저장할 수 있음(Codex). 조치: 매니저 재-raise → 데코레이터 매핑, 성공/오류 응답 타입 분리, FE에서 오류 payload 저장 차단. **완료 조건: 실패 응답이 200으로 반환되지 않는다.** - [x] **P1-10 [be]** ✅ 2026-08-02 수정 (종목별 마지막 관측값 추적으로 진짜 forward fill. 스파이크 아티팩트 +36%→−26% 반전 제거 확인. 라이브 코드임을 호출 경로로 검증 — `portfolio_manager_service.py:422`→통계 산출) — 전략 포트폴리오 equity curve 중간 갭을 '직전 값'이 아닌 '최종 값'으로 채움 — 주석은 forward fill, 실제는 `result.get('final_value', ...)`(`portfolio_calculator_service.py:177-179`) → 혼합 시장 휴일에 기말 가치 스파이크 주입, 파생 통계(`Annual_Volatility`/`Profit_Factor`/`Positive_Days`) 오염. 조치: 직전일 값 carry. diff --git a/backtest_be_fast/app/domain/portfolio_domain.py b/backtest_be_fast/app/domain/portfolio_domain.py index 7f0fa147..e368ab12 100644 --- a/backtest_be_fast/app/domain/portfolio_domain.py +++ b/backtest_be_fast/app/domain/portfolio_domain.py @@ -40,6 +40,10 @@ class PortfolioState(BaseModel): prev_portfolio_value: float = Field(default=0.0, description="전일 포트폴리오 총 가치") prev_date: Optional[date] = Field(default=None, description="전일 날짜") is_first_day: bool = Field(default=True, description="첫 날 여부") + pending_initial_keys: Set[str] = Field( + default_factory=set, + description="아직 초기 매수가 이뤄지지 않은 종목 (첫날 가격이 없으면 다음 거래일에 재시도)" + ) available_cash: float = Field(default=0.0, description="가용 현금") cash_holdings: Dict[str, float] = Field(default_factory=dict, description="종목별 현금 보유량 (초기 할당분)") diff --git a/backtest_be_fast/app/services/portfolio/portfolio_dca_manager.py b/backtest_be_fast/app/services/portfolio/portfolio_dca_manager.py index af4fe41e..b0af6c40 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_dca_manager.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_dca_manager.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Tuple +from typing import Dict, Optional, Set, Tuple from datetime import datetime, date import pandas as pd @@ -20,10 +20,11 @@ def execute_initial_purchases( current_prices: Dict[str, float], dca_info: Dict[str, DcaStrategyInfo], shares: Dict[str, float], - commission: float + commission: float, + pending_keys: Optional[Set[str]] = None ) -> Tuple[int, float]: """ - 첫 날 초기 매수를 실행합니다 (일시불 또는 DCA 첫 투자). + 초기 매수를 실행합니다 (일시불 또는 DCA 첫 투자). **역할**: - 일시불(lump_sum): 전액 한 번에 투자 @@ -31,16 +32,23 @@ def execute_initial_purchases( **파라미터**: - current_date: 현재 시뮬레이션 날짜 - - stock_amounts: 종목별 투자 금액 + - stock_amounts: 종목별 투자 금액 (pending_keys가 주어지면 그 종목만 대상) - current_prices: 종목별 USD 변환 가격 - dca_info: 종목 정보 (DcaStrategyInfo 모델) - shares: 종목별 보유 주식 수 - commission: 거래 수수료 (0.002 = 0.2%) + - pending_keys: 아직 초기 매수가 이뤄지지 않은 종목 집합. + 매수에 성공한 종목은 이 집합에서 제거된다. 첫날 가격이 없는 종목 + (예: 한쪽 시장 휴장일에 시작)은 집합에 남아 다음 거래일에 재시도된다. **반환**: - trades_executed: 실행된 거래 수 - daily_cash_inflow: 당일 현금 유입 (투자 금액) """ + if pending_keys is not None: + stock_amounts = { + key: amount for key, amount in stock_amounts.items() if key in pending_keys + } trades_executed = 0 daily_cash_inflow = 0.0 @@ -53,6 +61,8 @@ def execute_initial_purchases( investment_type = info.investment_type if unique_key not in current_prices: + # 이 종목은 아직 가격이 없다 (예: 다른 시장의 휴장일에 시작). + # pending_keys에 남겨 두면 호출자가 다음 날 다시 시도한다. continue price = current_prices[unique_key] @@ -71,8 +81,14 @@ def execute_initial_purchases( shares[unique_key] = invest_amount / price trades_executed += 1 # 첫 DCA 매수 거래 daily_cash_inflow += monthly_amount # DCA 첫 투자 유입 + # 초회 매수도 납입 1회로 계상한다. dca_periods가 "총 납입 횟수" + # (초회 포함)이므로, 이렇게 해야 정기 매수가 남은 횟수만 집행한다. + info.executed_count = 1 logger.info(f"{current_date.date()}: {unique_key} DCA 첫 투자 (금액: ${monthly_amount:,.2f}, interval_weeks: {info.dca_frequency})") + if pending_keys is not None: + pending_keys.discard(unique_key) + return trades_executed, daily_cash_inflow def execute_periodic_purchases( diff --git a/backtest_be_fast/app/services/portfolio/portfolio_simulation_engine.py b/backtest_be_fast/app/services/portfolio/portfolio_simulation_engine.py index f7ce437d..59894415 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_simulation_engine.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_simulation_engine.py @@ -73,7 +73,8 @@ def initialize_portfolio_state( shares={key: 0.0 for key in stock_amounts.keys()}, available_cash=cash_amount, cash_holdings={k: v for k, v in amounts.items() if dca_info[k].asset_type == 'cash'}, - delisted_stocks=set() + delisted_stocks=set(), + pending_initial_keys=set(stock_amounts.keys()) ) def detect_and_update_delisting( @@ -350,18 +351,26 @@ async def execute_simulation( f"[{', '.join(delisted_symbols)}]" ) - # 2.4 DCA 실행 (첫날 매수 또는 정기 매수) - if state.is_first_day: + # 2.4 DCA 실행 (초기 매수 또는 정기 매수) + # + # 초기 매수는 "첫날 한 번"이 아니라 "각 종목이 처음 가격을 갖는 날"에 + # 이뤄져야 한다. 혼합 시장 포트폴리오에서는 시작일에 한쪽 시장이 + # 휴장이라 가격이 없을 수 있는데, 과거에는 그 종목을 건너뛴 뒤 다시 + # 시도하지 않아 포지션이 영영 열리지 않고 투자금만 분모에 남았다. + if state.pending_initial_keys: trades, cash_inflow = self.dca_manager.execute_initial_purchases( current_date=current_date, stock_amounts=stock_amounts, current_prices=current_prices, dca_info=dca_info, shares=state.shares, - commission=commission + commission=commission, + pending_keys=state.pending_initial_keys ) state.total_trades += trades daily_cash_inflow += cash_inflow + + if state.is_first_day: state.is_first_day = False state.prev_date = current_date diff --git a/backtest_be_fast/app/services/portfolio_manager_service.py b/backtest_be_fast/app/services/portfolio_manager_service.py index 51c5cbd5..8dd5ed27 100644 --- a/backtest_be_fast/app/services/portfolio_manager_service.py +++ b/backtest_be_fast/app/services/portfolio_manager_service.py @@ -21,7 +21,12 @@ from app.services.backtest_service import backtest_service from app.repositories.stock_repository import get_stock_repository from app.services.dca_calculator import DcaCalculator -from app.services.rebalance_helper import RebalanceHelper, get_next_nth_weekday, get_weekday_occurrence +from app.services.rebalance_helper import ( + RebalanceHelper, + generate_periodic_schedule, + get_next_nth_weekday, + get_weekday_occurrence, +) from app.services.portfolio_calculator_service import portfolio_calculator from app.services.portfolio.portfolio_dca_manager import PortfolioDcaManager from app.services.portfolio.portfolio_rebalancer import PortfolioRebalancer @@ -542,20 +547,24 @@ async def run_buy_and_hold_portfolio_backtest(self, request: PortfolioBacktestRe dca_frequency = getattr(item, 'dca_frequency', 'monthly_1') # DCA 투자 횟수 계산 (Nth Weekday 방식) + # + # 시뮬레이션이 실제로 매수하는 날짜를 그대로 생성해서 센다. + # 과거에는 "월 = 30일" 근사로 추정했는데, 이 값이 총 투자금 + # (= 수익률의 분모)이 되기 때문에 실제 집행 횟수와 어긋나면 + # 집행되지 않은 납입금이 손실로 보고됐다. (2024년 전체·월간 + # 기준 13회로 추정되지만 실제로는 12회 → -7.69%) if investment_type == 'dca': period_info = FREQUENCY_MAP.get(dca_frequency, FREQUENCY_MAP['monthly_1']) period_type, interval = period_info - # 근사 계산으로 DCA 횟수 추정 - if period_type == 'weekly': - approx_days_per_period = interval * 7 - elif period_type == 'monthly': - approx_days_per_period = interval * 30 # 월 평균 30일 - else: - approx_days_per_period = 30 - - # 백테스트 기간 동안 몇 번 투자할지 계산 - dca_periods = max(1, (backtest_days // approx_days_per_period) + 1) + # 초회 매수 1회 + 이후 정기 매수 예정일 수 + periodic_dates = generate_periodic_schedule( + start_date=start_date_obj, + end_date=end_date_obj, + period_type=period_type, + interval=interval, + ) + dca_periods = 1 + len(periodic_dates) else: dca_periods = 1 diff --git a/backtest_be_fast/app/services/rebalance_helper.py b/backtest_be_fast/app/services/rebalance_helper.py index 38af6b75..e18dcdcf 100644 --- a/backtest_be_fast/app/services/rebalance_helper.py +++ b/backtest_be_fast/app/services/rebalance_helper.py @@ -159,6 +159,66 @@ def get_next_nth_weekday( return current_date +def generate_periodic_schedule( + start_date: datetime, + end_date: datetime, + period_type: str, + interval: int, + original_nth: int = None +) -> list: + """ + start_date 이후 end_date까지의 정기 실행일 목록을 생성 + + 시뮬레이션이 실제로 매수하는 날짜와 동일한 규칙(Nth Weekday)을 사용하므로, + 납입 횟수 추정과 실제 집행이 어긋나지 않는다. start_date 당일은 포함하지 + 않는다 (초회 매수는 별도로 처리되기 때문). + + Parameters + ---------- + start_date : datetime + 시작 날짜 (초회 매수일, 결과에 포함되지 않음) + end_date : datetime + 종료 날짜 (포함) + period_type : str + 주기 타입 ('weekly', 'monthly') + interval : int + 간격 (주 단위 또는 월 단위) + original_nth : int, optional + 원본 "몇 번째 요일" 값. None이면 start_date에서 계산 + + Returns + ------- + list[datetime] + 정기 실행 예정일 목록 (오름차순) + + Examples + -------- + >>> dates = generate_periodic_schedule( + ... datetime(2024, 1, 1), datetime(2024, 12, 31), 'monthly', 1 + ... ) + >>> len(dates) # 2월~12월 11회 (1월은 초회 매수로 처리) + 11 + """ + if period_type not in ('weekly', 'monthly') or interval <= 0: + return [] + + if original_nth is None: + original_nth = get_weekday_occurrence(start_date) + + schedule = [] + cursor = start_date + # 방어: 일 단위로도 끝나야 하므로 상한을 둔다 (무한 루프 방지) + max_iterations = (end_date - start_date).days + 2 + + for _ in range(max_iterations): + cursor = get_next_nth_weekday(cursor, period_type, interval, original_nth) + if cursor > end_date: + break + schedule.append(cursor) + + return schedule + + class RebalanceHelper: """리밸런싱 유틸리티""" diff --git a/backtest_be_fast/tests/unit/test_dca_schedule_alignment.py b/backtest_be_fast/tests/unit/test_dca_schedule_alignment.py new file mode 100644 index 00000000..6e7605d8 --- /dev/null +++ b/backtest_be_fast/tests/unit/test_dca_schedule_alignment.py @@ -0,0 +1,293 @@ +"""DCA 계획 회차와 실제 실행 회차의 정합성 테스트 (P1-07 / P1-08) + +**배경**: +과거 구현은 백테스트 기간을 "월 = 30일" 근사로 나눠 납입 횟수를 추정하고 +그 값으로 총 투자금(= 수익률의 분모)을 계산했다. 그러나 실제 매수는 +Nth-weekday 달력을 따르므로 두 값이 어긋났고, 집행되지 않은 납입금이 +손실처럼 보고됐다 (2024년 전체·월 $1,000·고정가 $100·수수료 0에서 +실제 12회 매수인데 분모는 $13,000 → 총수익률 -7.69%). + +이 테스트들은 "고정 가격·수수료 0이면 총수익률은 0%"라는 불변식을 +기준으로 계획/실행 정합성을 고정한다. +""" +import pytest +import pandas as pd +from pydantic import ValidationError as PydanticValidationError +from unittest.mock import AsyncMock, patch + +from datetime import datetime + +from app.schemas.schemas import PortfolioBacktestRequest +from app.services.portfolio_manager_service import PortfolioManagerService +from app.services.rebalance_helper import generate_periodic_schedule + +pytestmark = pytest.mark.unit + +FLAT_PRICE = 100.0 + + +def _flat_price_frame(start: str, end: str, price: float = FLAT_PRICE) -> pd.DataFrame: + """영업일 기준 고정 가격 프레임 (가격이 변하지 않으므로 수익률은 0이어야 한다).""" + index = pd.bdate_range(start=start, end=end) + return pd.DataFrame({'Close': [price] * len(index)}, index=index) + + +def _run_backtest(request: PortfolioBacktestRequest, frames: dict) -> dict: + """stock_repository를 mock한 채 buy&hold 포트폴리오 백테스트를 실행한다.""" + import asyncio + + service = PortfolioManagerService() + + with patch.object( + service.data_loader, 'load_stock_data_parallel', new=AsyncMock(return_value=frames) + ), patch.object( + service.data_loader, 'load_ticker_currencies', + new=AsyncMock(return_value={symbol: 'USD' for symbol in frames}) + ), patch.object( + service.data_loader, 'load_exchange_rates', new=AsyncMock(return_value={}) + ): + return asyncio.run(service.run_buy_and_hold_portfolio_backtest(request)) + + +def _total_return(result: dict) -> float: + assert result['status'] == 'success', result + return result['data']['portfolio_result']['total_return_pct'] + + +def _final_value(result: dict) -> float: + assert result['status'] == 'success', result + return result['data']['portfolio_result']['total_equity'] + + +class TestDcaDenominatorMatchesActualSchedule: + """계획 납입 횟수가 실제 Nth-weekday 스케줄과 일치해야 한다.""" + + @pytest.mark.parametrize('frequency', ['monthly_1', 'monthly_3', 'weekly_1']) + def test_flat_price_zero_commission_dca_returns_zero_percent(self, frequency): + """고정 가격·수수료 0인 DCA의 총수익률은 주기와 무관하게 0%여야 한다. + + 수정 전에는 30일 근사로 부풀려진 분모 때문에 음수 수익률이 보고됐다 + (monthly_1 기준 -7.69%). + """ + start, end = '2024-01-01', '2024-12-31' + request = PortfolioBacktestRequest( + portfolio=[{ + 'symbol': 'AAPL', + 'amount': 1000.0, + 'investment_type': 'dca', + 'dca_frequency': frequency, + }], + start_date=start, + end_date=end, + commission=0.0, + rebalance_frequency='none', + strategy='buy_hold_strategy', + ) + + result = _run_backtest(request, {'AAPL': _flat_price_frame(start, end)}) + + assert _total_return(result) == pytest.approx(0.0, abs=1e-6) + + def test_barely_longer_than_one_interval_still_returns_zero(self): + """주기를 겨우 넘기는 짧은 기간(납입 2회)에서도 0%여야 한다. + + 근사 계산의 오차가 가장 크게 드러나는 구간이다. 주기보다 더 짧은 + 기간은 스키마가 이미 거부하므로(아래 별도 테스트) 여기서는 다루지 않는다. + """ + start, end = '2024-01-01', '2024-02-20' + request = PortfolioBacktestRequest( + portfolio=[{ + 'symbol': 'AAPL', + 'amount': 1000.0, + 'investment_type': 'dca', + 'dca_frequency': 'monthly_1', + }], + start_date=start, + end_date=end, + commission=0.0, + rebalance_frequency='none', + strategy='buy_hold_strategy', + ) + + result = _run_backtest(request, {'AAPL': _flat_price_frame(start, end)}) + + assert _total_return(result) == pytest.approx(0.0, abs=1e-6) + + def test_period_shorter_than_dca_interval_is_rejected_by_schema(self): + """주기보다 짧은 기간은 스키마 검증 단계에서 거부된다 (기존 동작 고정).""" + with pytest.raises(PydanticValidationError): + PortfolioBacktestRequest( + portfolio=[{ + 'symbol': 'AAPL', + 'amount': 1000.0, + 'investment_type': 'dca', + 'dca_frequency': 'monthly_1', + }], + start_date='2024-01-01', + end_date='2024-01-15', + commission=0.0, + rebalance_frequency='none', + strategy='buy_hold_strategy', + ) + + def test_lump_sum_is_unaffected(self): + """일시불 경로는 이 변경의 영향을 받지 않아야 한다 (회귀 가드).""" + start, end = '2024-01-01', '2024-12-31' + request = PortfolioBacktestRequest( + portfolio=[{ + 'symbol': 'AAPL', + 'amount': 10000.0, + 'investment_type': 'lump_sum', + }], + start_date=start, + end_date=end, + commission=0.0, + rebalance_frequency='none', + strategy='buy_hold_strategy', + ) + + result = _run_backtest(request, {'AAPL': _flat_price_frame(start, end)}) + + assert _total_return(result) == pytest.approx(0.0, abs=1e-6) + assert _final_value(result) == pytest.approx(10000.0, abs=1e-6) + + +class TestPeriodicScheduleGenerator: + """납입 예정일 생성기는 시뮬레이션과 동일한 Nth-weekday 규칙을 따라야 한다.""" + + def test_monthly_full_year_yields_eleven_periodic_dates(self): + """1년 월간 납입은 초회를 제외하고 11회다 (총 12회).""" + dates = generate_periodic_schedule( + start_date=datetime(2024, 1, 1), + end_date=datetime(2024, 12, 31), + period_type='monthly', + interval=1, + ) + assert len(dates) == 11 + assert all(datetime(2024, 1, 1) < d <= datetime(2024, 12, 31) for d in dates) + assert dates == sorted(dates) + + def test_quarterly_full_year_yields_three_periodic_dates(self): + dates = generate_periodic_schedule( + start_date=datetime(2024, 1, 1), + end_date=datetime(2024, 12, 31), + period_type='monthly', + interval=3, + ) + assert len(dates) == 3 + + def test_weekly_preserves_weekday(self): + """주간 납입은 시작일과 같은 요일을 유지해야 한다.""" + start = datetime(2024, 3, 6) # 수요일 + dates = generate_periodic_schedule( + start_date=start, end_date=datetime(2024, 4, 30), + period_type='weekly', interval=1, + ) + assert dates + assert all(d.weekday() == start.weekday() for d in dates) + + def test_end_before_first_interval_yields_empty(self): + """첫 주기 도래 전에 끝나면 정기 납입은 없다 (초회만).""" + dates = generate_periodic_schedule( + start_date=datetime(2024, 1, 1), end_date=datetime(2024, 1, 10), + period_type='monthly', interval=1, + ) + assert dates == [] + + def test_unknown_period_type_yields_empty(self): + assert generate_periodic_schedule( + datetime(2024, 1, 1), datetime(2024, 12, 31), 'daily', 1 + ) == [] + + +class TestScheduleHoldsWithDataGaps: + """가격 데이터에 공백이 있어도 계획 회차와 실행 회차가 일치해야 한다.""" + + def test_gap_covering_a_scheduled_date_still_matches_denominator(self): + """납입 예정일이 데이터 공백에 걸려도 총수익률은 0%여야 한다. + + 시뮬레이션은 공백 다음 거래일에 해당 회차를 집행한다. 분모(계획 회차)가 + 실제 스케줄에서 파생되므로 이 경우에도 어긋나지 않는다. + """ + start, end = '2024-01-01', '2024-12-31' + frame = _flat_price_frame(start, end) + + # 2월 납입 예정일이 포함되도록 2월 초 2주 구간의 가격을 제거한다. + gap = (frame.index >= '2024-02-01') & (frame.index <= '2024-02-14') + frame_with_gap = frame[~gap] + + request = PortfolioBacktestRequest( + portfolio=[{ + 'symbol': 'AAPL', + 'amount': 1000.0, + 'investment_type': 'dca', + 'dca_frequency': 'monthly_1', + }], + start_date=start, + end_date=end, + commission=0.0, + rebalance_frequency='none', + strategy='buy_hold_strategy', + ) + + result = _run_backtest(request, {'AAPL': frame_with_gap}) + + # 회차가 유실되면 그만큼 분모에 못 미쳐 음수 수익률이 된다. + assert _total_return(result) == pytest.approx(0.0, abs=1e-6) + + +class TestInitialPurchaseRetriedWhenFirstDayPriceMissing: + """첫날 가격이 없는 종목도 첫 거래 가능일에 매수되어야 한다 (P1-08).""" + + def test_asset_without_first_day_price_is_still_purchased(self): + """한쪽 시장이 휴장인 날 시작해도 해당 종목의 자본이 증발하면 안 된다. + + 수정 전에는 execute_initial_purchases가 그 종목을 건너뛴 뒤 재시도하지 + 않아, 금액은 분모에 남고 포지션은 열리지 않아 수익률이 과소보고됐다. + """ + start, end = '2024-01-01', '2024-06-30' + + # A는 전 구간 가격 존재, B는 첫 영업일 하루가 비어 있다. + frame_a = _flat_price_frame(start, end) + frame_b = _flat_price_frame(start, end).iloc[1:] + + request = PortfolioBacktestRequest( + portfolio=[ + {'symbol': 'AAA', 'amount': 5000.0, 'investment_type': 'lump_sum'}, + {'symbol': 'BBB', 'amount': 5000.0, 'investment_type': 'lump_sum'}, + ], + start_date=start, + end_date=end, + commission=0.0, + rebalance_frequency='none', + strategy='buy_hold_strategy', + ) + + result = _run_backtest(request, {'AAA': frame_a, 'BBB': frame_b}) + + # B가 매수되지 않으면 최종 평가액이 5,000에 머물러 -50%가 된다. + assert _total_return(result) == pytest.approx(0.0, abs=1e-6) + assert _final_value(result) == pytest.approx(10000.0, abs=1e-6) + + def test_all_assets_priced_on_first_day_unchanged(self): + """모든 종목이 첫날 가격을 가지면 기존과 동일하게 동작해야 한다 (회귀 가드).""" + start, end = '2024-01-01', '2024-06-30' + request = PortfolioBacktestRequest( + portfolio=[ + {'symbol': 'AAA', 'amount': 5000.0, 'investment_type': 'lump_sum'}, + {'symbol': 'BBB', 'amount': 5000.0, 'investment_type': 'lump_sum'}, + ], + start_date=start, + end_date=end, + commission=0.0, + rebalance_frequency='none', + strategy='buy_hold_strategy', + ) + + result = _run_backtest( + request, + {'AAA': _flat_price_frame(start, end), 'BBB': _flat_price_frame(start, end)}, + ) + + assert _total_return(result) == pytest.approx(0.0, abs=1e-6) + assert _final_value(result) == pytest.approx(10000.0, abs=1e-6)