Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions backtest_be_fast/app/domain/portfolio_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="종목별 현금 보유량 (초기 할당분)")
Expand Down
24 changes: 20 additions & 4 deletions backtest_be_fast/app/services/portfolio/portfolio_dca_manager.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -20,27 +20,35 @@ 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): 전액 한 번에 투자
- DCA: 첫 달 금액만 투자

**파라미터**:
- 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

Expand All @@ -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]
Expand All @@ -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})")
Comment on lines +84 to 87

if pending_keys is not None:
pending_keys.discard(unique_key)

return trades_executed, daily_cash_inflow

def execute_periodic_purchases(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
31 changes: 20 additions & 11 deletions backtest_be_fast/app/services/portfolio_manager_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions backtest_be_fast/app/services/rebalance_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""리밸런싱 유틸리티"""

Expand Down
Loading