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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ docker build --target test ./backtest_be_fast
- **Verify in Docker** (`docker compose exec`, or `docker build --target test`) before declaring work complete.
- **BE markers:** `@pytest.mark.unit` (no DB), `@pytest.mark.integration` (DB), `@pytest.mark.external` (real API)
- **FE:** Vitest + React Testing Library. Playwright E2E exists in `backtest-console` (`playwright.config.ts`, one smoke spec) and needs the dev stack running — it is deliberately NOT in the Docker CI test stage (no browser, no live backend there).
- **Current Docker baseline (2026-10-25):** BE 529 unit tests + 1 e2e golden master (`pytest tests/e2e -m e2e`, no network/DB); the separate console has 356 tests. The older 12 DB integration tests are a historical baseline, not part of the Docker test target. Runtime HTTP verification for the four reads is `tests/integration/verify_reference_reads_http.py` with isolated synthetic fixtures. Any failure is a regression, not pre-existing noise.
- **Current Docker baseline (2026-10-25):** BE 579 unit tests + 1 e2e golden master (`pytest tests/e2e -m e2e`, no network/DB); the separate console has 367 tests. The older 12 DB integration tests are a historical baseline, not part of the Docker test target. Runtime HTTP verification for the four reads is `tests/integration/verify_reference_reads_http.py` with isolated synthetic fixtures. 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.
- **Coverage (BE 2026-08-03 / FE 2026-09-27):** BE 71.6%, FE about 74% statements. Core financial modules are 82-98%; the remaining gaps are `data_fetcher` (41%), the `app/validators/` package (23-32%), and `currency_converter` (57%). The validators look dead but are reached via `backtest_engine.py` → `validation_service` — do not delete them.

Expand Down
18 changes: 18 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@

---

## 2026-10-25 계산 원금·체결·통화·성능 수정

- DCA 예정일을 실제 체결일과 분리했다. 휴장으로 매수가 밀려도 다음 납입·리밸런싱
예정일이 밀리지 않는다. 끝내 체결되지 않은 납입액은 원금이나 손실로 잡지 않는다.
- **A-22, A-23 완료**: 시뮬레이션의 실제 체결·USD 평가 기록으로 개별 결과와 거래 로그를
만든다. 수수료·환율·리밸런싱이 반영되며 개별 최종 평가액 합계가 전체와 일치한다.
개별 손익에서 리밸런싱 자금 이동을 제외한다. 응답용 DCA 재실행을 제거했다.
- 통화 메타데이터가 없을 때 USD로 가정하지 않는다. 지원하지 않는 통화, 유효하지 않은
환율, 과거 시세를 미래 환율로 채우는 경우를 거부한다.
- 보합일은 하락 연속일에 포함하지 않는다. Sharpe는 무위험 수익률 0%에서 일간 시간가중
수익률 평균 / 표준편차 × √252로 통일했다. 연환산 수익률(TWR CAGR)은 별도 유지한다.
- 전략 검증은 형식·날짜·파라미터를 유지하고 종목 존재 여부는 기존 캐시 우선 시세 로더로
확인한다. 중복 Yahoo `.info` 조회를 없애고 실제 실행 시간을 측정한다.
- 사전 정렬된 시뮬레이션 배열을 위치로 읽고 전략 평가 곡선을 Series 단위로 직렬화한다.
- Docker: BE 단위 579개 + golden master 1개, 콘솔 367개, 정적 검사와 스키마 정합성 통과.
별도 로컬 런타임에서 6개 전략, 휴장일 DCA, USD·KRW·현금 혼합, 네 GET을 확인했다.
상세 재현·벤치마크 범위는 [계산 점검 보고서](docs/reviews/calculation-performance-2026-10-25.md).

## 2026-10-25 백테스트 계산 사람 인증

- 계산 POST의 FastAPI 의존성에서 Cloudflare Siteverify를 검증한 뒤 기존 실행기로
Expand Down
18 changes: 3 additions & 15 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
|---|---:|---|
| **P0** | 0 | — |
| **P1** | 0 | — |
| **P2** | 5 | A-21~A-25 — 지표 정확성 3건, 응답 형태 통일 1건, 부가 수집 성능 1건 |
| **P2** | 3 | A-21, A-24, A-25 — 계산 불가 지표, 응답 형태 통일, 부가 수집 성능 |
| **P3** | 9 | A-26~A-32, A-35, A-37 — 응답 크기, 스레드 풀 계측, 거부 사유 지표, FE/BE 상수 동기화, 특성화 테스트가 고정한 동작 판단, 모드 판정 통일, 정리 3건 |
| 저장소 밖 | 5 | 공통 게이트웨이 권고 묶음, 운영 MySQL 확인, 운영 DB 마이그레이션, 운영 상한 재조정, 운영 지표 확인 |
| **합계** | **19** | |
| **합계** | **17** | |

## 권장 처리 순서

1. **저장소 밖 2** — 공통 게이트웨이 방어와 운영 지표. 배포 태그는 Actions digest 배포로 대체했다(HISTORY.md 2026-10-09).
2. **A-21, A-22, A-23** — 사용자에게 나가는 지표의 정확성.
2. **A-21** — 사용자에게 나가는 지표의 정확성.
3. **A-24** — 응답 형태 통일. FE 타입 불일치를 함께 정리한다.
4. **A-25, A-26, A-27** — 부가 수집 성능·크기. 저장소 밖 6(운영 지표)으로 실측한 뒤 결정.
5. 나머지 P3.
Expand Down Expand Up @@ -62,18 +62,6 @@
`strategy_details`로 나간다. `profit_factor`는 배치9에서 `optional_finite_float`로 None 처리했으니
같은 방식으로 맞춘다. FE 타입(`StrategyStats`)도 null 허용으로.

- [ ] **A-22 [be]** buy&hold `individual_results`의 하드코딩 값 〔배치9 be-followups〕

`sharpe_ratio: 0.0` 고정, `trades`가 DCA 매수 횟수와 무관하게 1, 현금 판별이 `asset_type`이
아니라 `symbol == 'CASH'`(`portfolio_manager_service._format_individual_results_list`).
FE는 지금 이 필드들을 표시하지 않지만 API 계약상 틀린 값이다.

- [ ] **A-23 [be]** buy&hold 종목별 수익률·최종 평가금이 수수료를 빼지 않은 것으로 보임 〔배치9 관찰, 원인 미검증〕

골든 마스터에서 종목 10.53% vs 포트폴리오 10.30%, `final_equity` 9947.30 vs 9927.41.
`individual_returns.return`은 시작가·종료가 비율로만 계산한다(`portfolio_manager_service`). 재현
테스트로 원인을 확인하고, 종목별 값도 수수료 반영 기준으로 맞출지 결정.

- [ ] **A-24 [be/fe]** 응답 형태가 경로마다 다름 〔배치9 fe-followups〕

`individual_returns`의 키가 전략 경로는 심볼, buy&hold는 unique_key, 현금은 `CASH`이고 필드 구성도
Expand Down
1 change: 1 addition & 0 deletions backtest_be_fast/app/domain/portfolio_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class DcaStrategyInfo(BaseModel):
# 실행 상태 관리
executed_count: int = Field(default=0, description="현재까지 실행된 DCA 횟수")
last_dca_date: Optional[date] = Field(default=None, description="마지막 DCA 실행 날짜")
next_scheduled_date: Optional[date] = None
original_nth_weekday: Optional[int] = Field(default=None, description="최초 DCA 실행 시의 n번째 요일 정보")

model_config = {
Expand Down
14 changes: 7 additions & 7 deletions backtest_be_fast/app/repositories/yfinance_ticker_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def get_ticker_info_from_db(self, ticker: str) -> Dict[str, Any]:
engine = self._get_engine()
default_info = {
'symbol': ticker.upper(),
'currency': 'USD',
'currency': None,
'company_name': ticker.upper(),
'exchange': 'Unknown',
'first_trade_date': None
Expand Down Expand Up @@ -70,7 +70,7 @@ def get_ticker_info_from_db(self, ticker: str) -> Dict[str, Any]:

return {
'symbol': ticker,
'currency': info.get('currency', 'USD'),
'currency': info.get('currency'),
'company_name': info.get('company_name', ticker),
'exchange': info.get('exchange', 'Unknown'),
'first_trade_date': info.get('first_trade_date', None)
Expand Down Expand Up @@ -123,7 +123,7 @@ def get_ticker_info_batch_from_db(self, tickers: List[str]) -> Dict[str, Dict[st

result[ticker] = {
'symbol': ticker,
'currency': info.get('currency', 'USD'),
'currency': info.get('currency'),
'company_name': info.get('company_name', ticker),
'exchange': info.get('exchange', 'Unknown'),
'first_trade_date': first_trade_date
Expand All @@ -132,15 +132,15 @@ def get_ticker_info_batch_from_db(self, tickers: List[str]) -> Dict[str, Dict[st
self.logger.warning(f"info_json 파싱 실패: {ticker} - {e}")
result[ticker] = {
'symbol': ticker,
'currency': 'USD',
'currency': None,
'company_name': ticker,
'exchange': 'Unknown',
'first_trade_date': None
}
else:
result[ticker] = {
'symbol': ticker,
'currency': 'USD',
'currency': None,
'company_name': ticker,
'exchange': 'Unknown',
'first_trade_date': None
Expand All @@ -159,7 +159,7 @@ def get_ticker_info_batch_from_db(self, tickers: List[str]) -> Dict[str, Dict[st
if ticker not in found_tickers:
result[ticker] = {
'symbol': ticker,
'currency': 'USD',
'currency': None,
'company_name': ticker,
'exchange': 'Unknown',
'first_trade_date': None
Expand All @@ -173,7 +173,7 @@ def get_ticker_info_batch_from_db(self, tickers: List[str]) -> Dict[str, Dict[st
return {
ticker.upper(): {
'symbol': ticker.upper(),
'currency': 'USD',
'currency': None,
'company_name': ticker.upper(),
'exchange': 'Unknown'
}
Expand Down
30 changes: 23 additions & 7 deletions backtest_be_fast/app/services/backtest_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""
import asyncio
import logging
from time import perf_counter
from datetime import datetime, timedelta, date
from typing import Dict, Any, Optional, List, Type
from uuid import uuid4
Expand All @@ -21,6 +22,7 @@
from app.core.exceptions import ValidationError
from app.constants.currencies import SUPPORTED_CURRENCIES
from app.utils.currency_converter import currency_converter
from app.utils.metrics_math import annualized_volatility, safe_sharpe_ratio
from app.utils.type_converters import optional_finite_float, safe_float, safe_int


Expand All @@ -42,7 +44,11 @@ def __init__(
async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
"""백테스트 실행"""
try:
self.validation_service.validate_backtest_request(request)
started = perf_counter()
# Price loading below validates existence using the cache-first data
# path. A second Yahoo .info call must not reject cached price data.
await asyncio.to_thread(self.validation_service.validate_backtest_request,
request, check_symbol_exists=False)

self.logger.info(
"백테스트 시작: %s, %s ~ %s",
Expand All @@ -58,7 +64,10 @@ async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
self.logger.debug(f"데이터 컬럼: {list(data.columns)}")
self.logger.info(f"데이터 범위: {data.index[0]} ~ {data.index[-1]}")

data = await self._convert_to_usd(request.ticker, data, request.start_date, request.end_date)
try:
data = await self._convert_to_usd(request.ticker, data, request.start_date, request.end_date)
except ValueError as exc:
raise ValidationError(str(exc)) from exc

strategy_name = request.strategy.value if hasattr(request.strategy, 'value') else str(request.strategy)
strategy_class = self._build_strategy(strategy_name, request.strategy_params)
Expand Down Expand Up @@ -98,7 +107,9 @@ async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
# 것처럼 "무효한 결과" 분기를 두고 실제로 실행된 거래가 없는데도
# `_create_fallback_result()`로 Win Rate 100%/거래 1건 같은 조작된 통계를
# HTTP 200 성공으로 반환했다(P3-21). 그 분기는 도달 불가능한 죽은 코드였다.
return self._convert_result_to_response(result, request)
response = await asyncio.to_thread(self._convert_result_to_response, result, request)
response.execution_time_seconds = perf_counter() - started
return response

except Exception as e:
self.logger.error(f"백테스트 전체 프로세스 오류: {e}")
Expand Down Expand Up @@ -222,13 +233,18 @@ def _convert_result_to_response(self, stats: pd.Series, request: BacktestRequest
try:
# DataFrame을 Dict[str, float]로 변환
equity_curve_dict = {}
for idx, row in equity_curve_df.iterrows():
for idx, equity in equity_curve_df['Equity'].items():
date_str = idx.strftime('%Y-%m-%d') if hasattr(idx, 'strftime') else str(idx)
equity_curve_dict[date_str] = float(row['Equity'])
equity_curve_dict[date_str] = float(equity)
except Exception as e:
self.logger.warning(f"equity curve 변환 실패: {e}")
equity_curve_dict = None

sharpe = safe_float(stats.get('Sharpe Ratio', 0.0))
if isinstance(equity_curve_df, pd.DataFrame) and not equity_curve_df.empty:
daily = equity_curve_df['Equity'].pct_change(fill_method=None).fillna(0.0)
sharpe = safe_sharpe_ratio(float(daily.mean()) * 252 * 100, annualized_volatility(daily))

# 참고(P3-19): 여기에는 과거 request.benchmark_ticker가 설정된 경우
# Alpha/Beta를 계산하는 벤치마크 비교 블록이 있었다. BacktestRequest에
# benchmark_ticker 필드 자체가 없어(schemas/requests.py) 어떤 라이브 호출도
Expand Down Expand Up @@ -258,7 +274,7 @@ def _convert_result_to_response(self, stats: pd.Series, request: BacktestRequest
volatility_pct=safe_float(
stats.get('Volatility (Ann.) [%]', stats.get('Volatility [%]', 0.0))
),
sharpe_ratio=safe_float(stats.get('Sharpe Ratio', 0.0)),
sharpe_ratio=sharpe,
sortino_ratio=safe_float(stats.get('Sortino Ratio', 0.0)),
calmar_ratio=safe_float(stats.get('Calmar Ratio', 0.0)),
max_drawdown_pct=safe_float(stats.get('Max. Drawdown [%]', 0.0)),
Expand All @@ -277,7 +293,7 @@ def _convert_result_to_response(self, stats: pd.Series, request: BacktestRequest
sqn=safe_float(stats.get('SQN', 0.0)) if 'SQN' in stats else None,
trade_log=trade_log,
equity_curve=equity_curve_dict, # 일일 자산 가치
execution_time_seconds=0.5, # 추후 실제 시간 측정 추가
execution_time_seconds=0.0, # run_backtest fills the measured end-to-end duration
timestamp=datetime.now()
)
except Exception as e:
Expand Down
Loading