From a00a8587388c0f9afe770af230b5eaf371cca988 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 25 Oct 2026 23:35:02 +0900 Subject: [PATCH] fix(be): reconcile contributions and executed portfolio returns --- AGENTS.md | 2 +- HISTORY.md | 18 + TODO.md | 18 +- .../app/domain/portfolio_domain.py | 1 + .../app/repositories/yfinance_ticker_info.py | 14 +- .../app/services/backtest_engine.py | 30 +- .../portfolio/portfolio_asset_ledger.py | 112 +++ .../portfolio/portfolio_data_loader.py | 24 +- .../portfolio/portfolio_dca_manager.py | 151 +--- .../services/portfolio/portfolio_execution.py | 4 +- .../services/portfolio/portfolio_inputs.py | 2 +- .../services/portfolio/portfolio_metrics.py | 4 +- .../portfolio/portfolio_price_alignment.py | 35 +- .../portfolio/portfolio_rebalancer.py | 5 + .../portfolio/portfolio_response_builder.py | 124 +-- .../portfolio/portfolio_simulation_engine.py | 60 +- .../services/portfolio_calculator_service.py | 4 +- .../app/services/portfolio_manager_service.py | 7 +- .../app/services/validation_service.py | 4 +- .../app/utils/currency_converter.py | 25 +- backtest_be_fast/app/utils/data_fetcher.py | 2 +- backtest_be_fast/app/utils/metrics_math.py | 6 +- .../app/validators/backtest_validator.py | 7 +- .../tests/e2e/data/expected_dca_output.json | 35 +- .../portfolio_manager_response_snapshot.json | 784 ++++++++++-------- ...test_calculation_accounting_regressions.py | 96 +++ .../test_calculation_currency_validation.py | 94 +++ .../tests/unit/test_currency_converter.py | 17 +- .../tests/unit/test_metrics_definitions.py | 10 +- .../test_portfolio_manager_batch4_fixes.py | 9 +- .../unit/test_portfolio_manager_helpers.py | 18 +- .../unit/test_simulation_engine_edge_cases.py | 9 +- .../test_yfinance_repository_news_and_info.py | 6 +- .../calculation-performance-2026-10-25.md | 84 ++ 34 files changed, 1071 insertions(+), 750 deletions(-) create mode 100644 backtest_be_fast/app/services/portfolio/portfolio_asset_ledger.py create mode 100644 backtest_be_fast/tests/unit/test_calculation_accounting_regressions.py create mode 100644 backtest_be_fast/tests/unit/test_calculation_currency_validation.py create mode 100644 docs/reviews/calculation-performance-2026-10-25.md diff --git a/AGENTS.md b/AGENTS.md index fc71a1f2..80227a1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/HISTORY.md b/HISTORY.md index a83f456a..a4ddfbd2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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를 검증한 뒤 기존 실행기로 diff --git a/TODO.md b/TODO.md index cd5a2c57..da5ce593 100644 --- a/TODO.md +++ b/TODO.md @@ -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. @@ -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`이고 필드 구성도 diff --git a/backtest_be_fast/app/domain/portfolio_domain.py b/backtest_be_fast/app/domain/portfolio_domain.py index e368ab12..242233e4 100644 --- a/backtest_be_fast/app/domain/portfolio_domain.py +++ b/backtest_be_fast/app/domain/portfolio_domain.py @@ -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 = { diff --git a/backtest_be_fast/app/repositories/yfinance_ticker_info.py b/backtest_be_fast/app/repositories/yfinance_ticker_info.py index 4c3dabed..d1a5390e 100644 --- a/backtest_be_fast/app/repositories/yfinance_ticker_info.py +++ b/backtest_be_fast/app/repositories/yfinance_ticker_info.py @@ -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 @@ -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) @@ -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 @@ -132,7 +132,7 @@ 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 @@ -140,7 +140,7 @@ def get_ticker_info_batch_from_db(self, tickers: List[str]) -> Dict[str, Dict[st else: result[ticker] = { 'symbol': ticker, - 'currency': 'USD', + 'currency': None, 'company_name': ticker, 'exchange': 'Unknown', 'first_trade_date': None @@ -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 @@ -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' } diff --git a/backtest_be_fast/app/services/backtest_engine.py b/backtest_be_fast/app/services/backtest_engine.py index 0f013e19..1e31914f 100644 --- a/backtest_be_fast/app/services/backtest_engine.py +++ b/backtest_be_fast/app/services/backtest_engine.py @@ -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 @@ -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 @@ -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", @@ -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) @@ -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}") @@ -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) 어떤 라이브 호출도 @@ -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)), @@ -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: diff --git a/backtest_be_fast/app/services/portfolio/portfolio_asset_ledger.py b/backtest_be_fast/app/services/portfolio/portfolio_asset_ledger.py new file mode 100644 index 00000000..ff92acd6 --- /dev/null +++ b/backtest_be_fast/app/services/portfolio/portfolio_asset_ledger.py @@ -0,0 +1,112 @@ +"""Per-asset attribution from the same executions used to value the portfolio. + +All amounts and prices are USD. Rebalancing transfers capital between assets; +they are neither outside contributions nor investment profit. +""" +from typing import Dict + +import pandas as pd + +from app.utils.metrics_math import annualized_volatility, safe_sharpe_ratio + + +class PortfolioAssetLedger: + def __init__(self, amounts, dca_info): + self.info = dca_info + self.planned = amounts.copy() + self.contributed = {key: amount if dca_info[key].asset_type == 'cash' else 0.0 + for key, amount in amounts.items()} + self.profit = dict.fromkeys(amounts, 0.0) + self.previous = self.contributed.copy() + self.returns = {key: [] for key in amounts} + self.trades = {key: [] for key in amounts} + self.buy_notional = dict.fromkeys(amounts, 0.0) + self.buy_sizes = dict.fromkeys(amounts, 0.0) + self.final_prices = {} + self.values = self.contributed.copy() + + @staticmethod + def values_at(shares, cash, prices) -> Dict[str, float]: + return {**{key: size * prices.get(key, 0.0) for key, size in shares.items()}, **cash} + + def record_purchases(self, day, before_shares, after_shares, prices, counts_before): + inflows = {} + for key, size in after_shares.items(): + delta = size - before_shares[key] + if delta <= 0: + continue + info = self.info[key] + amount = (info.executed_count - counts_before[key]) * info.monthly_amount if info.investment_type == 'dca' else self.planned[key] + inflows[key] = amount + self.contributed[key] += amount + count = info.executed_count - counts_before[key] if info.investment_type == 'dca' else 1 + for _ in range(count): + self.add_trade(key, day, prices[key], delta / count) + return inflows + + def add_trade(self, key, day, price, size): + if abs(size) <= 1e-12: + return + self.trades[key].append({ + 'EntryTime': day.isoformat(), 'EntryPrice': float(price), 'Size': float(abs(size)), + 'Type': 'BUY' if size > 0 else 'SELL', 'ExitTime': None, 'ExitPrice': None, + 'PnL': None, 'ReturnPct': None, 'Duration': None, + }) + if size > 0: + self.buy_notional[key] += float(price * size) + self.buy_sizes[key] += float(size) + + def record_day(self, before, after, inflows, transfers, prices): + for key in self.contributed: + pre = before.get(key, 0.0) + final = after.get(key, 0.0) + inflow = inflows.get(key, 0.0) + transfer = transfers.get(key, 0.0) + self.profit[key] += final - pre - inflow - transfer + # Market change before the close, then costs at the close. Transfers + # are removed, so receiving another asset's proceeds is not a gain. + capital = pre + inflow + daily = ((pre / self.previous[key]) * ((final - transfer) / capital) - 1 + if self.previous[key] > 0 and capital > 0 else 0.0) + self.returns[key].append(daily) + self.previous[key] = final + self.values[key] = final + if key in prices: + self.final_prices[key] = float(prices[key]) + + def results(self, total): + individual, details = {}, {} + for key, info in self.info.items(): + amount = self.contributed[key] + daily = pd.Series(self.returns[key], dtype=float) + individual[key] = { + 'symbol': info.symbol, 'weight': amount / total if total else 0.0, + 'amount': amount, 'return': self.profit[key] / amount * 100 if amount else 0.0, + 'start_price': self.buy_notional[key] / self.buy_sizes[key] if self.buy_sizes[key] else (1.0 if info.asset_type == 'cash' else None), + 'end_price': self.final_prices.get(key, 1.0 if info.asset_type == 'cash' else None), + 'investment_type': info.investment_type, + 'dca_periods': info.executed_count if info.investment_type == 'dca' else None, + 'final_value': self.values[key], 'trades': len(self.trades[key]), + 'sharpe_ratio': safe_sharpe_ratio(float(daily.mean()) * 252 * 100, annualized_volatility(daily)), + } + if info.asset_type == 'cash': + individual[key]['asset_type'] = 'cash' + else: + details[key] = {'trade_log': self.trades[key]} + # Keep the public CASH entry aggregated, including duplicate cash names. + cash_keys = [key for key, info in self.info.items() if info.asset_type == 'cash'] + if cash_keys: + cash = individual[cash_keys[0]].copy() + cash['symbol'] = 'CASH' + cash['asset_type'] = 'cash' + for field in ('weight', 'amount', 'final_value', 'trades'): + cash[field] = sum(individual[key][field] for key in cash_keys) + cash['return'] = sum(self.profit[key] for key in cash_keys) / cash['amount'] * 100 if cash['amount'] else 0.0 + for key in cash_keys: + del individual[key] + individual = {'CASH': cash, **individual} + return individual, details + + def record_market_profit(self, before): + for key, value in before.items(): + self.profit[key] += value - self.previous[key] diff --git a/backtest_be_fast/app/services/portfolio/portfolio_data_loader.py b/backtest_be_fast/app/services/portfolio/portfolio_data_loader.py index 84bf5bef..65c0ab68 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_data_loader.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_data_loader.py @@ -72,19 +72,19 @@ async def load_ticker_currencies( 종목별 통화 정보를 배치로 조회합니다. key: symbol, value: currency_code """ + from app.constants.currencies import SUPPORTED_CURRENCIES + try: - ticker_info_dict = await asyncio.to_thread( - self.stock_repository.get_tickers_info_batch, symbols - ) - # symbol -> currency 맵핑으로 변환 - ticker_currencies = { - symbol: info.get('currency', 'USD') - for symbol, info in ticker_info_dict.items() - } - return ticker_currencies - except Exception as e: - logger.warning(f"티커 정보 배치 조회 실패: {e}, 모두 USD로 가정") - return {symbol: 'USD' for symbol in symbols} + info = await asyncio.to_thread(self.stock_repository.get_tickers_info_batch, symbols) + except Exception as exc: + raise ValueError("종목 통화 정보를 조회할 수 없습니다. 잠시 후 다시 시도해 주세요.") from exc + currencies = {} + for symbol in symbols: + currency = info.get(symbol, {}).get('currency') + if currency not in SUPPORTED_CURRENCIES: + raise ValueError(f"{symbol}: 통화 정보를 확인할 수 없거나 지원하지 않는 통화입니다: {currency}") + currencies[symbol] = currency + return currencies async def load_exchange_rates( self, 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 16690a9e..51576d65 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_dca_manager.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_dca_manager.py @@ -1,4 +1,5 @@ import logging +import math from typing import Dict, Optional, Set, Tuple from datetime import datetime, date import pandas as pd @@ -18,7 +19,7 @@ def _is_tradeable_price(price) -> bool: NaN도 같은 이유로 배제한다 (NaN과의 비교는 항상 False). """ try: - return price is not None and float(price) > 0 + return price is not None and math.isfinite(float(price)) and float(price) > 0 except (TypeError, ValueError): return False @@ -120,12 +121,6 @@ def execute_initial_purchases( # 초회 매수도 납입 1회로 계상한다. dca_periods가 "총 납입 횟수" # (초회 포함)이므로, 이렇게 해야 정기 매수가 남은 횟수만 집행한다. info.executed_count = 1 - # [P2-14] 정기 매수의 기준일(reference_date)을 "이론상 시작일"이 - # 아니라 "실제로 돈이 들어간 날"로 앵커링한다. 초기 매수가 지연된 - # 경우(예: 이 종목의 시장이 한동안 휴장) 이걸 빼먹으면 - # execute_periodic_purchases가 여전히 start_date_obj 기준으로 - # 이미 지나버린 정기 매수 목표일을 계산해, 지연된 초기 매수와 - # 같은 날 정기 매수까지 겹쳐 이중 집행될 수 있다. current_date_val = current_date.date() if isinstance(current_date, datetime) else current_date info.last_dca_date = current_date_val logger.info(f"{current_date.date()}: {unique_key} DCA 첫 투자 (금액: ${monthly_amount:,.2f}, interval_weeks: {info.dca_frequency})") @@ -146,54 +141,10 @@ def execute_periodic_purchases( start_date_obj: datetime, tradeable_keys: Optional[Set[str]] = None ) -> Tuple[int, float]: - """ - 주기적 DCA 투자를 실행합니다 (Nth Weekday 기반). - - **역할**: - - 설정된 주기(weekly, biweekly, monthly 등)에 따라 자동 매수 - - Nth Weekday 방식으로 요일 패턴 유지 - - DCA 투자 횟수 제한 준수 - - **파라미터**: - - current_date: 현재 시뮬레이션 날짜 - - stock_amounts: 종목별 투자 금액 - - current_prices: 종목별 USD 변환 가격 (밸류에이션용, ffill 포함 가능) - - dca_info: 종목 정보 (DcaStrategyInfo 모델) - - shares: 종목별 보유 주식 수 - - commission: 거래 수수료 - - start_date_obj: 시뮬레이션 시작 날짜 - - tradeable_keys: [P2-14] 오늘 RAW 데이터로 실제 관측된 종목 집합. - None이면(하위 호환) current_prices 유무만으로 판단한다. 값이 주어지면 - 이 집합에 없는 종목은(current_prices에 ffill된 값이 있더라도) 오늘 - 매수하지 않고 다음 관측일까지 대기한다. - - **반환**: - - trades_executed: 실행된 거래 수 - - daily_cash_inflow: 당일 현금 유입 (투자 금액) - - **트리거 판정 방식 (레벨 기반)**: - 예전에는 "current >= next_dca_date AND prev < next_dca_date" 라는 엣지 - 검출 방식을 썼다. 이 방식은 그 순간에 매수가 실제로 집행된다는 전제 - 하에서만 안전하다 -- next_dca_date가 매수 성공 시에만 미래로 전진하기 - 때문에, 만약 그 날 종목이 관측되지 않아 매수를 건너뛰면 prev/next - 관계가 다시는 "prev < next"를 만족하지 못해 그 회차가 영영 유실된다. - [P2-14]에서 거래를 tradeable_keys로 게이팅하면 이 유실이 실제로 - 발생하므로, "current >= next_dca_date" 레벨 조건만으로 판단하도록 - 바꿨다. next_dca_date는 매수가 실제로 집행된 날에만 미래로 전진하므로 - (get_next_nth_weekday는 항상 기준일보다 엄격히 미래인 날짜를 반환), - 이 레벨 조건은 매수가 실행될 때까지 계속 참을 유지해 자동으로 - 재시도되고, 실행되는 순간 즉시 거짓이 되어 같은 날 두 번 집행되지 - 않는다. + """Execute due contributions on observed quotes, retaining the planned calendar. - [P2-14] `original_nth_weekday`를 잠그는 조건에서 `last_dca_date is - None`을 제거한 이유: 초기 매수(execute_initial_purchases)가 이제 - DCA 첫 회차에서도 last_dca_date를 설정하므로, 그 조건을 그대로 두면 - 이 메서드가 처음 호출되는 시점에 이미 last_dca_date가 채워져 있어 - 원본 Nth 값이 영영 잠기지 않는다 (매번 reference_date의 요일에서 - 다시 계산되어, get_next_nth_weekday가 참조일 자체의 요일을 쓰는 - 특성과 맞물려 스케줄이 표류할 수 있다). original_nth_weekday 자신의 - None 여부만으로 "아직 한 번도 잠기지 않았음"을 판단하는 것으로 - 충분하다. + Missing quotes defer funding until a real execution. A contribution with + no execution before the end is not invested capital and is not a loss. """ trades_executed = 0 daily_cash_inflow = 0.0 @@ -207,73 +158,35 @@ def execute_periodic_purchases( if info.investment_type != 'dca': continue - # Nth Weekday 기반 DCA 실행 - dca_frequency = info.dca_frequency - period_info = FREQUENCY_MAP.get(dca_frequency) - - if period_info is None: - logger.error(f"{symbol}: 알 수 없는 DCA 주기 '{dca_frequency}'") + if info.executed_count == 0 or info.executed_count >= info.dca_periods: continue - - period_type, interval = period_info - - # 첫 실행 시 original_nth 값 저장 (한 번 잠기면 이후 재계산하지 - # 않는다 -- last_dca_date는 이제 초기 매수 시점에도 설정될 수 - # 있으므로 이 조건에서 제외한다. original_nth_weekday 자체가 - # "아직 한 번도 잠기지 않았음"의 충분한 신호다). + current = current_date.date() if isinstance(current_date, datetime) else current_date + # Never double-fund an initial purchase on its deferred execution day. + if info.last_dca_date == current: + continue + period_type, interval = FREQUENCY_MAP[info.dca_frequency] if info.original_nth_weekday is None: info.original_nth_weekday = get_weekday_occurrence(start_date_obj) - logger.debug(f"{symbol}: 원본 Nth 값 설정 = {info.original_nth_weekday}번째 {['월','화','수','목','금','토','일'][start_date_obj.weekday()]}요일") - - # 다음 DCA 날짜 계산 (original_nth 유지, 마지막 "실제 집행일"에서부터) - reference_date = info.last_dca_date or start_date_obj - original_nth = info.original_nth_weekday - next_dca_date = get_next_nth_weekday(reference_date, period_type, interval, original_nth) - - current_date_val = current_date.date() if isinstance(current_date, datetime) else current_date - next_dca_date_val = next_dca_date.date() if isinstance(next_dca_date, datetime) else next_dca_date - - if current_date_val >= next_dca_date_val: - # 투자 횟수 확인 - executed_count = info.executed_count - - if executed_count < info.dca_periods: - is_tradeable = tradeable_keys is None or symbol in tradeable_keys - has_valid_price = ( - symbol in current_prices - and _is_tradeable_price(current_prices[symbol]) - ) - if has_valid_price and is_tradeable: - price = current_prices[symbol] - period_amount = info.monthly_amount # 회당 투자 금액 - invest_amount = period_amount * (1 - commission) - shares[symbol] += invest_amount / price - trades_executed += 1 # DCA 추가 매수 거래 - daily_cash_inflow += period_amount # DCA 추가 투자 유입 기록 - - # 실행 횟수 및 마지막 실행 날짜 업데이트 - info.executed_count = executed_count + 1 - info.last_dca_date = current_date_val # Use pre-converted value for consistency - - # 다음 예정일 계산 (로그용) - next_scheduled = get_next_nth_weekday(current_date, period_type, interval, original_nth) - current_nth = get_weekday_occurrence(current_date) - logger.info( - f"{current_date.date()}: {symbol} DCA 추가 매수 실행! " - f"(주기 {info.executed_count}/{info.dca_periods}, " - f"금액: ${period_amount:,.2f}, " - f"실행: {current_nth}번째 {['월','화','수','목','금','토','일'][current_date.weekday()]}요일, " - f"다음 예정: {next_scheduled.date()})" - ) - else: - # [P2-14] 예정일에 도달했지만 오늘 관측되지 않았다(또는 - # 가격이 아예 없다). executed_count/last_dca_date를 - # 갱신하지 않으므로 next_dca_date가 그대로 유지되어 - # 다음 날에도 이 레벨 조건이 계속 참이 되고, 실제로 - # 관측되는 날 자동으로 재시도된다 (회차 유실 없음). - logger.debug( - f"{current_date.date()}: {symbol} DCA 매수 예정일 도달했지만 " - f"당일 데이터 없음 (주기 {executed_count + 1}/{info.dca_periods}, 다음 거래일 재시도)" - ) + if info.next_scheduled_date is None: + planned = start_date_obj + for _ in range(info.executed_count): + planned = get_next_nth_weekday(planned, period_type, interval, info.original_nth_weekday) + info.next_scheduled_date = planned.date() + if current < info.next_scheduled_date: + continue + if (tradeable_keys is not None and symbol not in tradeable_keys) or not _is_tradeable_price(current_prices.get(symbol)): + continue + # Catch up overdue scheduled contributions at the first real quote. + # Advance the planned cursor, never the holiday-shifted trade date. + while info.executed_count < info.dca_periods and current >= info.next_scheduled_date: + amount = info.monthly_amount + shares[symbol] += amount * (1 - commission) / current_prices[symbol] + trades_executed += 1 + daily_cash_inflow += amount + info.executed_count += 1 + info.last_dca_date = current + planned = datetime.combine(info.next_scheduled_date, datetime.min.time()) + info.next_scheduled_date = get_next_nth_weekday( + planned, period_type, interval, info.original_nth_weekday).date() return trades_executed, daily_cash_inflow diff --git a/backtest_be_fast/app/services/portfolio/portfolio_execution.py b/backtest_be_fast/app/services/portfolio/portfolio_execution.py index 82b806a0..cc517420 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_execution.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_execution.py @@ -222,7 +222,7 @@ async def simulate_buy_hold( keyed_ticker_currencies = {} for unique_key in stock_amounts.keys(): symbol = dca_info[unique_key].symbol - keyed_ticker_currencies[unique_key] = ticker_currencies.get(symbol, 'USD') + keyed_ticker_currencies[unique_key] = ticker_currencies.get(symbol) # 2. Exchange Rates 로드 required_currencies = list(set(keyed_ticker_currencies.values()) - {'USD'}) @@ -245,7 +245,7 @@ async def simulate_buy_hold( total_amount=total_amount, portfolio_data=portfolio_data, dca_info=dca_info, - ticker_currencies=ticker_currencies, + ticker_currencies=keyed_ticker_currencies, exchange_rates_by_currency=exchange_rates_by_currency, rebalance_frequency=rebalance_frequency, commission=commission diff --git a/backtest_be_fast/app/services/portfolio/portfolio_inputs.py b/backtest_be_fast/app/services/portfolio/portfolio_inputs.py index bf7ee287..097f3fb8 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_inputs.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_inputs.py @@ -95,7 +95,7 @@ def count_dca_periods(investment_type: str, dca_frequency: str, class BuyHoldAllocation: """buy&hold 시뮬레이션 입력. - amounts: unique_key → 실제 총 투자 금액 (DCA는 회당 금액 × 횟수) + amounts: unique_key → 예정 총 투자 금액 (DCA는 회당 금액 × 횟수) dca_info: unique_key → DcaStrategyInfo cash_amount: 현금 자산 합계 symbols_to_load: 가격을 로드할 주식 심볼 (요청 순서) diff --git a/backtest_be_fast/app/services/portfolio/portfolio_metrics.py b/backtest_be_fast/app/services/portfolio/portfolio_metrics.py index 74aa1eb8..51f4b3be 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_metrics.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_metrics.py @@ -173,12 +173,12 @@ def calculate_portfolio_statistics( ) # 무위험 수익률을 0으로 가정한 샤프 비율 - sharpe_ratio = safe_sharpe_ratio(annual_return, annual_volatility) + sharpe_ratio = safe_sharpe_ratio(float(daily_returns.mean()) * 252 * 100, annual_volatility) # 최대 연속 상승/하락일 daily_changes = daily_returns > 0 consecutive_gains = PortfolioMetrics._get_max_consecutive(daily_changes, True) - consecutive_losses = PortfolioMetrics._get_max_consecutive(daily_changes, False) + consecutive_losses = PortfolioMetrics._get_max_consecutive(daily_returns < 0, True) # Profit Factor: 일간 이익 합 / 일간 손실 합. 손실일이 없으면 None (A-09) profit_factor = daily_profit_factor(daily_returns) diff --git a/backtest_be_fast/app/services/portfolio/portfolio_price_alignment.py b/backtest_be_fast/app/services/portfolio/portfolio_price_alignment.py index 090cad1d..429ef329 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_price_alignment.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_price_alignment.py @@ -11,6 +11,8 @@ from typing import Dict, Set, Tuple import pandas as pd +import numpy as np +from app.constants.currencies import SUPPORTED_CURRENCIES from app.domain.portfolio_domain import DcaStrategyInfo @@ -48,9 +50,9 @@ def pre_calculate_prices( rates_series.index = pd.to_datetime(rates_series.index) # Reindex & FFill - # [Copilot Suggestion] ffill만 하면 시뮬레이션 시작일보다 환율 데이터가 늦게 시작될 경우 앞부분이 NaN이 됨. - # bfill을 추가하여 앞부분 결측치도 보완 (최초 환율로 메꿈) - aligned_rate = rates_series.reindex(date_range).ffill().bfill() + # Keep observations before the requested range for forward-fill; never + # use a future quote to invent an earlier FX conversion. + aligned_rate = rates_series.sort_index().reindex(date_range, method='ffill') aligned_exchange_rates[currency] = aligned_rate # 2. 주가 데이터 정렬 & 환율 적용 @@ -68,12 +70,18 @@ def pre_calculate_prices( price_series = df['Close'].reindex(date_range).ffill() # 환율 변환 - currency = ticker_currencies.get(unique_key, 'USD') + currency = ticker_currencies.get(unique_key) + if currency not in SUPPORTED_CURRENCIES: + raise ValueError(f"{symbol}: 통화 정보를 확인할 수 없거나 지원하지 않는 통화입니다: {currency}") if currency != 'USD' and currency in aligned_exchange_rates: # 벡터 연산으로 전체 기간 환율 적용 # [Copilot Suggestion] 벡터 연산 (환율 적용) - apply 제거 및 Vectorization 적용 exchange_rates = aligned_exchange_rates[currency] + needed = price_series.notna() + if not ((exchange_rates[needed] > 0) & np.isfinite(exchange_rates[needed])).all(): + raise ValueError(f"{symbol}: 해당 날짜의 유효한 {currency} 환율이 없습니다.") + # 주요 통화(EUR, GBP 등)는 직접 곱하기, 그 외(KRW, JPY 등)는 나누기 역수 # CurrencyConverter.get_conversion_multiplier 로직을 벡터화 if currency in ['EUR', 'GBP', 'AUD', 'CAD', 'CHF']: @@ -81,12 +89,7 @@ def pre_calculate_prices( price_series = price_series * exchange_rates else: # Inverse (1 / rate) - # 0 또는 NaN인 경우 1.0으로 처리 (Division by Zero 방지) - valid_mask = (exchange_rates > 0) & (pd.notnull(exchange_rates)) - multipliers = pd.Series(1.0, index=exchange_rates.index) - multipliers[valid_mask] = 1.0 / exchange_rates[valid_mask] - - price_series = price_series * multipliers + price_series = price_series / exchange_rates elif currency != 'USD': # [P2-02] 환율 데이터가 없다고 원본(비USD) 가격을 그대로 흘려보내면 # 안 된다 -- 이후 시뮬레이션이 이 가격을 USD 현금과 그대로 합산해, @@ -109,7 +112,8 @@ def get_daily_prices_from_aligned( aligned_prices: Dict[str, pd.Series], aligned_exchange_rates: Dict[str, pd.Series], ticker_currencies: Dict[str, str], - last_valid_exchange_rates: Dict[str, float] + last_valid_exchange_rates: Dict[str, float], + position: int = None, ) -> Tuple[Dict[str, float], Dict[str, float]]: """ [성능 최적화] 미리 계산된 데이터에서 O(1)로 당일 가격 조회 @@ -119,7 +123,7 @@ def get_daily_prices_from_aligned( # 1. 환율 캐시 업데이트 for currency, rates_series in aligned_exchange_rates.items(): try: - rate = rates_series.at[current_date] + rate = rates_series[position] if position is not None else rates_series.at[current_date] if pd.notnull(rate): last_valid_exchange_rates[currency] = rate except KeyError: @@ -128,7 +132,7 @@ def get_daily_prices_from_aligned( # 2. 가격 조회 for unique_key, price_series in aligned_prices.items(): try: - price = price_series.at[current_date] + price = price_series[position] if position is not None else price_series.at[current_date] # NaN 체크 (해당 날짜 데이터 없음 or 상장폐지 등) if pd.notnull(price): current_prices[unique_key] = float(price) @@ -182,7 +186,8 @@ def pre_calculate_tradeable_mask( def get_daily_tradeable_keys( current_date: pd.Timestamp, - aligned_tradeable: Dict[str, pd.Series] + aligned_tradeable: Dict[str, pd.Series], + position: int = None, ) -> Set[str]: """ [P2-14] 미리 계산된 tradeable 마스크에서 O(1)로 당일 관측 여부를 조회합니다. @@ -193,7 +198,7 @@ def get_daily_tradeable_keys( tradeable_today: Set[str] = set() for unique_key, mask_series in aligned_tradeable.items(): try: - if bool(mask_series.at[current_date]): + if bool(mask_series[position] if position is not None else mask_series.at[current_date]): tradeable_today.add(unique_key) except KeyError: pass # 마스크에 해당 날짜가 없으면 관측되지 않은 것으로 취급 diff --git a/backtest_be_fast/app/services/portfolio/portfolio_rebalancer.py b/backtest_be_fast/app/services/portfolio/portfolio_rebalancer.py index a118807d..d8da9d2f 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_rebalancer.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_rebalancer.py @@ -208,6 +208,7 @@ def _report_key(unique_key: str) -> str: # 목표 비중대로 재조정 (조정된 비중 사용, 상장폐지 가치를 제외한 풀 기준) new_shares = {} new_cash_holdings = {} + asset_transfers = {} total_commission_cost = 0 trades_in_rebalance = 0 rebalance_trades = [] # 이번 리밸런싱의 거래 내역 @@ -220,6 +221,7 @@ def _report_key(unique_key: str) -> str: current_value = cash_holdings.get(unique_key, 0) # 현금은 거래 수수료 없이 조정 new_cash_holdings[unique_key] = target_value + asset_transfers[unique_key] = target_value - current_value # 현금 조정 내역 기록 (차이가 있을 때만) if abs(target_value - current_value) / total_portfolio_value > trading_thresholds.REBALANCING_THRESHOLD_PCT: @@ -290,6 +292,8 @@ def _report_key(unique_key: str) -> str: 'price': price }) + asset_transfers[unique_key] = target_value - current_value + # 매도/매수 금액 trade_value = abs(target_value - current_value) commission_cost = trade_value * commission @@ -372,6 +376,7 @@ def _report_key(unique_key: str) -> str: 'weights_before': weights_before, 'weights_after': weights_after, 'commission_cost': total_commission_cost, + 'asset_transfers': asset_transfers, 'updated_shares': updated_shares, 'updated_cash_holdings': updated_cash_holdings, 'updated_available_cash': updated_available_cash diff --git a/backtest_be_fast/app/services/portfolio/portfolio_response_builder.py b/backtest_be_fast/app/services/portfolio/portfolio_response_builder.py index 2eb4e7b7..af6bb35c 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_response_builder.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_response_builder.py @@ -16,7 +16,6 @@ from app.domain.portfolio_domain import DcaStrategyInfo from app.schemas.schemas import PortfolioBacktestRequest -from app.services.dca_calculator import DcaCalculator from app.services.portfolio.portfolio_execution import StrategyRunOutcome from app.services.portfolio.portfolio_statistics_builder import build_cash_only_statistics @@ -46,12 +45,12 @@ def format_individual_results_list( else: # buy_hold results.append({ 'ticker': returns['symbol'] if returns.get('symbol') else key, - 'final_equity': returns['amount'] + (returns['amount'] * returns['return'] / 100), + 'final_equity': returns['final_value'], 'total_return_pct': returns['return'], - 'sharpe_ratio': 0.0, + 'sharpe_ratio': returns['sharpe_ratio'], 'weight': returns['weight'], 'amount': returns['amount'], - 'trades': 1 if returns.get('symbol', '') != 'CASH' else 0, + 'trades': returns['trades'], # buy&hold 포지션은 청산된 거래가 없어 거래 승률을 정의할 수 없다. # 과거에는 수익이면 100, 아니면 0을 지어냈다(A-09 부류) 'win_rate': None @@ -186,117 +185,9 @@ def build_cash_only_response( # buy&hold 시뮬레이션 경로 # --------------------------------------------------------------------------- -def build_buy_hold_individual_returns( - amounts: Dict[str, float], - dca_info: Dict[str, DcaStrategyInfo], - portfolio_data: Dict[str, pd.DataFrame], - cash_amount: float, - total_amount: float, - start_date: str, -) -> Tuple[Dict[str, Any], Dict[str, Any]]: - """개별 종목 수익률(참고용, 현금 포함)과 종목별 거래 로그(strategy_details). - - 현금은 여러 항목이어도 'CASH' 하나로 합쳐 맨 앞에 둔다. 일시불은 첫 가격 - 대비 마지막 가격, DCA는 DcaCalculator의 평균 단가 기준 수익률이다. - """ - individual_returns = {} - strategy_details = {} # 거래 로그를 저장할 딕셔너리 - - if cash_amount > 0: - individual_returns['CASH'] = _cash_individual_return(cash_amount / total_amount, cash_amount) - - # 주식 수익률 추가 (중복 종목 지원) - for unique_key, amount in amounts.items(): - if unique_key.endswith('_CASH') or (unique_key in dca_info and dca_info[unique_key].asset_type == 'cash'): - continue - - symbol = dca_info[unique_key].symbol - if symbol not in portfolio_data: - continue - df = portfolio_data[symbol] - if len(df) == 0: - continue - - investment_type = dca_info[unique_key].investment_type - weight = amount / total_amount - - if investment_type == 'lump_sum': - # 일시불: 시작가 대비 종료가로 수익률 계산 - start_price = df['Close'].iloc[0] - end_price = df['Close'].iloc[-1] - individual_return = (end_price / start_price - 1) * 100 - - # 일시불 매수 거래 로그 생성 - total_shares = amount / start_price - trade_log = [_trade_log_entry(df.index[0].isoformat(), start_price, total_shares, 'BUY')] - - individual_returns[unique_key] = { - 'symbol': symbol, - 'weight': weight, - 'amount': amount, - 'return': individual_return, - 'start_price': start_price, - 'end_price': end_price, - 'investment_type': investment_type, - 'dca_periods': None - } - else: # DCA - # 분할매수: DcaCalculator를 사용하여 수익률 계산 - dca_periods = dca_info[unique_key].dca_periods - period_amount = dca_info[unique_key].monthly_amount # 회당 투자 금액 - dca_frequency = dca_info[unique_key].dca_frequency # DCA 주기 - - total_shares, average_price, individual_return, trade_log = DcaCalculator.calculate_dca_shares_and_return( - df, period_amount, dca_periods, start_date, dca_frequency - ) - - end_price = df['Close'].iloc[-1] - - individual_returns[unique_key] = { - 'symbol': symbol, - 'weight': weight, - 'amount': amount, - 'return': individual_return, - 'start_price': average_price, # DCA의 경우 평균 매수 단가 - 'end_price': end_price, - 'investment_type': investment_type, - 'dca_periods': dca_periods - } - - # strategy_details에 거래 로그 저장 - strategy_details[unique_key] = { - 'trade_log': trade_log - } - - return individual_returns, strategy_details - - -def append_rebalance_trades( - strategy_details: Dict[str, Any], - rebalance_history: List[Dict[str, Any]], - dca_info: Dict[str, DcaStrategyInfo], -) -> None: - """리밸런싱 거래(buy/sell)를 해당 종목의 trade_log 뒤에 붙인다 (현금은 제외).""" - for rebalance_event in rebalance_history: - rebalance_date = rebalance_event['date'] - for trade in rebalance_event['trades']: - symbol = trade['symbol'] - action = trade['action'] - - # unique_key 찾기 (symbol로 매칭) - unique_key = None - for key in dca_info.keys(): - if dca_info[key].symbol == symbol: - unique_key = key - break - - if unique_key and unique_key in strategy_details: - # 거래 타입 결정 (buy/sell만 처리, 현금은 제외) - if action in ['buy', 'sell']: - strategy_details[unique_key]['trade_log'].append(_trade_log_entry( - rebalance_date, trade['price'], trade['shares'], - 'BUY' if action == 'buy' else 'SELL', - )) +def build_buy_hold_individual_returns(portfolio_result: pd.DataFrame) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Already valued USD positions and executions; never replay a second simulation.""" + return portfolio_result.attrs['asset_results'] def build_buy_hold_response( @@ -318,7 +209,6 @@ def build_buy_hold_response( rebalance_history = portfolio_result.attrs.get('rebalance_history', []) weight_history = portfolio_result.attrs.get('weight_history', []) - append_rebalance_trades(strategy_details, rebalance_history, dca_info) return { 'status': 'success', @@ -336,7 +226,7 @@ def build_buy_hold_response( 'weight': amount / total_amount, 'amount': amount, 'investment_type': dca_info[unique_key].investment_type, - 'dca_periods': dca_info[unique_key].dca_periods if dca_info[unique_key].investment_type == 'dca' else None, + 'dca_periods': dca_info[unique_key].executed_count if dca_info[unique_key].investment_type == 'dca' else None, 'asset_type': dca_info[unique_key].asset_type } for unique_key, amount in amounts.items() 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 e1f8440a..93b36b0f 100644 --- a/backtest_be_fast/app/services/portfolio/portfolio_simulation_engine.py +++ b/backtest_be_fast/app/services/portfolio/portfolio_simulation_engine.py @@ -10,11 +10,13 @@ from datetime import datetime, date import pandas as pd +from app.services.portfolio.portfolio_asset_ledger import PortfolioAssetLedger from app.services.portfolio.portfolio_dca_manager import PortfolioDcaManager from app.services.portfolio.portfolio_rebalancer import PortfolioRebalancer -from app.services.rebalance_helper import RebalanceHelper, get_weekday_occurrence +from app.services.rebalance_helper import RebalanceHelper, get_weekday_occurrence, generate_periodic_schedule from app.utils.currency_converter import currency_converter from app.constants.data_loading import TradingThresholds +from app.schemas.schemas import FREQUENCY_MAP from app.domain.portfolio_domain import DcaStrategyInfo, PortfolioState from app.services.portfolio.portfolio_metrics import PortfolioMetrics from app.utils.metrics_math import TWR_START_RATIO_ATTR @@ -179,11 +181,12 @@ def _get_daily_prices_from_aligned( aligned_prices: Dict[str, pd.Series], aligned_exchange_rates: Dict[str, pd.Series], ticker_currencies: Dict[str, str], - last_valid_exchange_rates: Dict[str, float] + last_valid_exchange_rates: Dict[str, float], + position: int = None, ) -> Tuple[Dict[str, float], Dict[str, float]]: """[성능 최적화] 미리 계산된 데이터에서 O(1)로 당일 가격 조회 (portfolio_price_alignment 위임).""" return get_daily_prices_from_aligned( - current_date, aligned_prices, aligned_exchange_rates, ticker_currencies, last_valid_exchange_rates + current_date, aligned_prices, aligned_exchange_rates, ticker_currencies, last_valid_exchange_rates, position ) def _pre_calculate_tradeable_mask( @@ -199,10 +202,11 @@ def _pre_calculate_tradeable_mask( def _get_daily_tradeable_keys( self, current_date: pd.Timestamp, - aligned_tradeable: Dict[str, pd.Series] + aligned_tradeable: Dict[str, pd.Series], + position: int = None, ) -> Set[str]: """[P2-14] 오늘 RAW 데이터로 실제 관측된 unique_key 집합 (portfolio_price_alignment 위임).""" - return get_daily_tradeable_keys(current_date, aligned_tradeable) + return get_daily_tradeable_keys(current_date, aligned_tradeable, position) async def execute_simulation( self, @@ -308,14 +312,22 @@ def _execute_simulation_sync( aligned_tradeable = self._pre_calculate_tradeable_mask( date_range, stock_amounts, portfolio_data, dca_info ) - last_valid_exchange_rates = {} # 루프 내 캐싱용 + aligned_prices = {k: series.to_numpy() for k, series in aligned_prices.items()} + aligned_exchange_rates = {k: series.to_numpy() for k, series in aligned_exchange_rates.items()} + aligned_tradeable = {k: series.to_numpy() for k, series in aligned_tradeable.items()} + ledger = PortfolioAssetLedger(amounts, dca_info) + schedule = (generate_periodic_schedule(start_date_obj, end_date_obj, *FREQUENCY_MAP[rebalance_frequency]) + if rebalance_frequency != 'none' else []) + rebalance_schedule = iter(schedule) + next_rebalance = next(rebalance_schedule, None) + last_valid_exchange_rates = {} # 첫 평가일의 "평가금 / 투입 원금" (A-19). 전일 평가금이 없는 날의 수익률은 # 0으로 기록되므로 그날 매수 수수료만큼의 손실이 Daily_Return에 잡히지 않는다. # 연환산 수익률(TWR)이 이 손실을 빠뜨리지 않도록 따로 넘긴다. twr_start_ratio = None # 2. 메인 루프 실행 - for current_date in date_range: + for position, current_date in enumerate(date_range): # A-05: 타임아웃·연결 끊김 후 취소 신호를 하루 단위로 확인한다 # (토큰이 없으면 no-op). check_cancelled() @@ -331,12 +343,12 @@ def _execute_simulation_sync( aligned_prices=aligned_prices, aligned_exchange_rates=aligned_exchange_rates, ticker_currencies=ticker_currencies, - last_valid_exchange_rates=last_valid_exchange_rates + last_valid_exchange_rates=last_valid_exchange_rates, position=position ) # [P2-14] 오늘 RAW 데이터로 실제 관측된 종목 집합. current_prices와 # 달리 ffill로 채워지지 않으므로, 거래 실행 여부와 상장폐지 감지의 # 근거로 쓴다 (밸류에이션은 계속 current_prices/ffill을 사용한다). - tradeable_today = self._get_daily_tradeable_keys(current_date, aligned_tradeable) + tradeable_today = self._get_daily_tradeable_keys(current_date, aligned_tradeable, position) # 2.2 상장폐지 감지 self.detect_and_update_delisting( @@ -355,9 +367,10 @@ def _execute_simulation_sync( state.original_rebalance_nth = get_weekday_occurrence(start_date_obj) self.logger.debug(f"리밸런싱 원본 Nth 값 설정 = {state.original_rebalance_nth}번째 {['월','화','수','목','금','토','일'][start_date_obj.weekday()]}요일") - should_rebalance = RebalanceHelper.is_rebalance_date( - current_date, state.prev_date, rebalance_frequency, start_date_obj, state.last_rebalance_date, state.original_rebalance_nth - ) + should_rebalance = False + while next_rebalance is not None and current_date.date() >= next_rebalance.date(): + should_rebalance = state.prev_date is not None + next_rebalance = next(rebalance_schedule, None) if state.delisted_stocks and (should_rebalance or current_date.weekday() == 0): delisted_symbols = [dca_info[key].symbol for key in state.delisted_stocks if key in dca_info] @@ -372,6 +385,12 @@ def _execute_simulation_sync( state.shares, state.available_cash, current_prices ) + before_values = ledger.values_at(state.shares, state.cash_holdings, current_prices) + ledger.record_market_profit(before_values) + before_shares = state.shares.copy() + counts_before = {key: dca_info[key].executed_count for key in stock_amounts} + transfers = {} + # 2.4 DCA 실행 (초기 매수 또는 정기 매수) # # 초기 매수는 "첫날 한 번"이 아니라 "각 종목이 처음 가격을 갖는 날"에 @@ -411,6 +430,9 @@ def _execute_simulation_sync( daily_cash_inflow += cash_inflow + inflows = ledger.record_purchases( + current_date, before_shares, state.shares, current_prices, counts_before) + # 2.5 리밸런싱 실행 if rebalance_frequency != 'none': if should_rebalance and len(target_weights) > 1: @@ -476,6 +498,9 @@ def _execute_simulation_sync( delisted_stocks=rebalance_excluded_stocks ) + transfers = rebalance_result.get('asset_transfers', {}) + for key, new_size in rebalance_result['updated_shares'].items(): + ledger.add_trade(key, current_date, current_prices.get(key, 0.0), new_size - state.shares[key]) state.shares = rebalance_result['updated_shares'] state.cash_holdings = rebalance_result['updated_cash_holdings'] state.available_cash = rebalance_result['updated_available_cash'] @@ -493,6 +518,9 @@ def _execute_simulation_sync( state.last_rebalance_date = current_date.date() state.total_trades += trades_in_rebalance + after_values = ledger.values_at(state.shares, state.cash_holdings, current_prices) + ledger.record_day(before_values, after_values, inflows, transfers, current_prices) + # 2.6 메트릭 계산 metrics_calculator = PortfolioMetrics() @@ -538,6 +566,14 @@ def _execute_simulation_sync( }) result.set_index('Date', inplace=True) + actual_amount = sum(ledger.contributed.values()) + if actual_amount <= 0: + raise ValueError("기간 내에 실행된 투자가 없습니다.") + # The loop uses a constant scale; publish against actual funded capital. + result['Portfolio_Value'] *= total_amount / actual_amount + result['Cumulative_Return'] = (result['Portfolio_Value'] - 1) * 100 + result.attrs['invested_amounts'] = ledger.contributed + result.attrs['asset_results'] = ledger.results(actual_amount) result.attrs['total_trades'] = state.total_trades result.attrs['rebalance_history'] = state.rebalance_history result.attrs['weight_history'] = state.weight_history diff --git a/backtest_be_fast/app/services/portfolio_calculator_service.py b/backtest_be_fast/app/services/portfolio_calculator_service.py index c2d7c453..82f8590c 100644 --- a/backtest_be_fast/app/services/portfolio_calculator_service.py +++ b/backtest_be_fast/app/services/portfolio_calculator_service.py @@ -63,12 +63,12 @@ def calculate_portfolio_statistics(portfolio_data: pd.DataFrame, total_amount: f ) # 무위험 수익률을 0으로 가정한 샤프 비율 - sharpe_ratio = safe_sharpe_ratio(annual_return, annual_volatility) + sharpe_ratio = safe_sharpe_ratio(float(daily_returns.mean()) * 252 * 100, annual_volatility) # 최대 연속 상승/하락일 daily_changes = daily_returns > 0 consecutive_gains = PortfolioCalculator._get_max_consecutive(daily_changes, True) - consecutive_losses = PortfolioCalculator._get_max_consecutive(daily_changes, False) + consecutive_losses = PortfolioCalculator._get_max_consecutive(daily_returns < 0, True) # Profit Factor: 일간 이익 합 / 일간 손실 합. 손실일이 없으면 None (A-09) profit_factor = daily_profit_factor(daily_returns) diff --git a/backtest_be_fast/app/services/portfolio_manager_service.py b/backtest_be_fast/app/services/portfolio_manager_service.py index 6bbe2673..7278212d 100644 --- a/backtest_be_fast/app/services/portfolio_manager_service.py +++ b/backtest_be_fast/app/services/portfolio_manager_service.py @@ -288,7 +288,7 @@ async def run_buy_and_hold_portfolio_backtest(self, request: PortfolioBacktestRe # ------------------------------------------ builder.add(item) allocation = builder.allocation - amounts = allocation.amounts # 실제 총 투자 금액 (DCA의 경우 회당 금액 × 횟수) + amounts = allocation.amounts # 예정 총 투자 금액 (DCA의 경우 회당 금액 × 횟수) dca_info = allocation.dca_info cash_amount = allocation.cash_amount @@ -325,13 +325,16 @@ async def run_buy_and_hold_portfolio_backtest(self, request: PortfolioBacktestRe request.rebalance_frequency, request.commission ) + amounts = portfolio_result.attrs['invested_amounts'] + total_amount = sum(amounts.values()) + # 통계 계산 logger.info("포트폴리오 통계 계산 중...") statistics = portfolio_calculator.calculate_portfolio_statistics(portfolio_result, total_amount) # 개별 종목 수익률 (참고용, 현금 포함)과 거래 로그 individual_returns, strategy_details = build_buy_hold_individual_returns( - amounts, dca_info, portfolio_data, cash_amount, total_amount, request.start_date + portfolio_result ) # 결과 포맷팅 diff --git a/backtest_be_fast/app/services/validation_service.py b/backtest_be_fast/app/services/validation_service.py index 495e525e..03e550a4 100644 --- a/backtest_be_fast/app/services/validation_service.py +++ b/backtest_be_fast/app/services/validation_service.py @@ -24,10 +24,10 @@ def __init__(self, data_fetcher_instance=None, strategy_service_instance=None): strategy_service=self.strategy_service ) - def validate_backtest_request(self, request: BacktestRequest) -> None: + def validate_backtest_request(self, request: BacktestRequest, *, check_symbol_exists: bool = True) -> None: """백테스트 요청 검증 (BacktestValidator로 위임)""" try: - self.backtest_validator.validate_request(request) + self.backtest_validator.validate_request(request, check_symbol_exists=check_symbol_exists) self.logger.info(f"백테스트 요청 검증 완료: {request.ticker}") except ValueError as ve: diff --git a/backtest_be_fast/app/utils/currency_converter.py b/backtest_be_fast/app/utils/currency_converter.py index 342d9896..4794d509 100644 --- a/backtest_be_fast/app/utils/currency_converter.py +++ b/backtest_be_fast/app/utils/currency_converter.py @@ -12,6 +12,7 @@ from datetime import datetime, date, timedelta from typing import Dict, Optional, Tuple import pandas as pd +import numpy as np from app.constants.currencies import SUPPORTED_CURRENCIES, EXCHANGE_RATE_LOOKBACK_DAYS from app.constants.data_loading import TradingThresholds @@ -80,7 +81,7 @@ async def load_and_prepare_exchange_rates( Note: - buffer_days만큼 시작일을 앞당겨서 데이터 로딩 - - 타임존 제거 후 forward-fill, backward-fill 적용 + - 타임존 제거 후 forward-fill 적용 (미래 환율로 채우지 않음) - target_date_range와 정렬하여 누락 날짜 보정 """ # USD는 변환 불필요 @@ -115,11 +116,13 @@ async def load_and_prepare_exchange_rates( # 타임존 제거 (한국 주식 vs 환율 데이터 타임존 불일치 해결) exchange_data.index = self._remove_timezone(exchange_data.index) - # target_date_range와 정렬 (forward-fill + backward-fill) + # target_date_range와 정렬 (forward-fill only) if target_date_range is not None: target_index_no_tz = self._remove_timezone(target_date_range) exchange_data = exchange_data.reindex(target_index_no_tz, method='ffill') - exchange_data = exchange_data.bfill() + rates = exchange_data['Close'] + if not ((rates > 0) & np.isfinite(rates)).all(): + raise ValueError(f"{currency}: 해당 날짜의 유효한 환율이 없습니다.") logger.info(f"환율 데이터 전처리 완료: {len(exchange_data)}일치") return exchange_data @@ -147,27 +150,24 @@ async def convert_dataframe_to_usd( Note: - USD 통화인 경우 원본 데이터를 그대로 반환 - - 변환 실패 시 경고 로그와 함께 원본 데이터 반환 + - 통화나 환율 확인 실패 시 명시적으로 실패 - Open, High, Low, Close 컬럼에만 변환 적용 """ # 통화 정보 조회 if currency is None: try: ticker_info = await asyncio.to_thread(self.stock_repository.get_ticker_info, ticker) - currency = ticker_info.get('currency', 'USD') + currency = ticker_info.get('currency') logger.info(f"{ticker} 통화: {currency}") except Exception as e: - logger.warning(f"{ticker} 통화 정보 조회 실패: {e}, USD로 가정") - currency = 'USD' + raise ValueError(f"{ticker}: 통화 정보를 조회할 수 없습니다.") from e # USD면 변환 불필요 if currency == 'USD': return data - # 지원하지 않는 통화는 경고 후 원본 반환 if currency not in SUPPORTED_CURRENCIES: - logger.warning(f"지원하지 않는 통화입니다: {currency}., 변환 없이 진행") - return data + raise ValueError(f"{ticker}: 통화 정보를 확인할 수 없거나 지원하지 않는 통화입니다: {currency}") try: # 환율 데이터 로딩 및 전처리 @@ -207,7 +207,10 @@ async def convert_dataframe_to_usd( if currency in ['EUR', 'GBP', 'AUD', 'CAD', 'CHF']: multipliers = exchange_rates_aligned else: - multipliers = 1.0 / exchange_rates_aligned.where(exchange_rates_aligned > 0, 1.0) + multipliers = 1.0 / exchange_rates_aligned + + if not ((exchange_rates_aligned > 0) & np.isfinite(exchange_rates_aligned)).all(): + raise ValueError(f"{ticker}: 유효한 환율 없이 가격을 변환할 수 없습니다.") # 4. NaN이 아닌 행만 변환 valid_mask = multipliers.notna() diff --git a/backtest_be_fast/app/utils/data_fetcher.py b/backtest_be_fast/app/utils/data_fetcher.py index 58361878..972c3f1a 100644 --- a/backtest_be_fast/app/utils/data_fetcher.py +++ b/backtest_be_fast/app/utils/data_fetcher.py @@ -390,7 +390,7 @@ def fetch_ticker_info(self, ticker: str) -> dict: 'industry': info.get('industry', 'Unknown'), 'market_cap': info.get('marketCap', None), 'current_price': info.get('regularMarketPrice', info.get('previousClose', None)), - 'currency': info.get('currency', 'USD'), + 'currency': info.get('currency'), 'exchange': info.get('exchange', 'Unknown'), 'country': info.get('country', 'Unknown'), 'first_trade_date': first_trade_date, diff --git a/backtest_be_fast/app/utils/metrics_math.py b/backtest_be_fast/app/utils/metrics_math.py index dba53ab8..7f3a20b9 100644 --- a/backtest_be_fast/app/utils/metrics_math.py +++ b/backtest_be_fast/app/utils/metrics_math.py @@ -67,13 +67,13 @@ def drawdown_from_returns(daily_returns: pd.Series) -> pd.Series: return drawdown.where(drawdown < -DRAWDOWN_EPSILON, 0.0) -def safe_sharpe_ratio(annual_return: float, annual_volatility: float) -> float: - """변동성이 사실상 0이면 Sharpe를 0으로 둔다 (0 나눗셈·폭발 방지).""" +def safe_sharpe_ratio(annual_excess_return: float, annual_volatility: float) -> float: + """Arithmetic annual excess return / volatility; near-zero volatility gives 0.""" if annual_volatility is None or np.isnan(annual_volatility): return 0.0 if annual_volatility <= VOLATILITY_EPSILON: return 0.0 - return float(annual_return / annual_volatility) + return float(annual_excess_return / annual_volatility) def time_weighted_annual_return( diff --git a/backtest_be_fast/app/validators/backtest_validator.py b/backtest_be_fast/app/validators/backtest_validator.py index c07c11db..86790548 100644 --- a/backtest_be_fast/app/validators/backtest_validator.py +++ b/backtest_be_fast/app/validators/backtest_validator.py @@ -130,7 +130,7 @@ def validate_strategy_params( except ValueError as e: raise ValueError(f"전략 파라미터 오류 ({strategy_name}): {str(e)}") - def validate_request(self, request) -> None: + def validate_request(self, request, *, check_symbol_exists: bool = True) -> None: """ 백테스트 요청 전체 검증 @@ -147,7 +147,10 @@ def validate_request(self, request) -> None: """ try: # 1. 티커 검증 및 정규화 - ticker = self.symbol_validator.validate_and_normalize(request.ticker) + ticker = self.symbol_validator.normalize_ticker(request.ticker) + self.symbol_validator.validate_ticker_format(ticker) + if check_symbol_exists: + self.symbol_validator.validate_ticker_exists(ticker) logger.debug(f"티커 검증 완료: {ticker}") # 2. 날짜 검증 diff --git a/backtest_be_fast/tests/e2e/data/expected_dca_output.json b/backtest_be_fast/tests/e2e/data/expected_dca_output.json index 977ee9ff..b10d04b3 100644 --- a/backtest_be_fast/tests/e2e/data/expected_dca_output.json +++ b/backtest_be_fast/tests/e2e/data/expected_dca_output.json @@ -94,11 +94,11 @@ "individual_results": [ { "amount": 9000.0, - "final_equity": 9947.300096973322, - "sharpe_ratio": 0.0, + "final_equity": 9927.405496779375, + "sharpe_ratio": 6.073172612973097, "ticker": "AAPL", - "total_return_pct": 10.525556633036913, - "trades": 1, + "total_return_pct": 10.304505519770828, + "trades": 9, "weight": 1.0, "win_rate": null } @@ -108,10 +108,13 @@ "amount": 9000.0, "dca_periods": 9, "end_price": 190.0, + "final_value": 9927.405496779375, "investment_type": "dca", - "return": 10.525556633036913, - "start_price": 171.90594265074037, + "return": 10.304505519770828, + "sharpe_ratio": 6.073172612973097, + "start_price": 171.9059426507404, "symbol": "AAPL", + "trades": 9, "weight": 1.0 } }, @@ -146,7 +149,7 @@ "Peak_Value": 9927.405496779375, "Positive_Days": 28, "Profit_Factor": 2.295908173601549, - "Sharpe_Ratio": 13.584536665998447, + "Sharpe_Ratio": 6.073172612973103, "Start": "2023-01-01", "Total_Return": 10.304505519770824, "Total_Trades": 9, @@ -167,7 +170,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 6.578947368421052, + "Size": 6.565789473684211, "Type": "BUY" }, { @@ -178,7 +181,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 6.060606060606061, + "Size": 6.048484848484849, "Type": "BUY" }, { @@ -189,7 +192,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 5.882352941176471, + "Size": 5.870588235294118, "Type": "BUY" }, { @@ -200,7 +203,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 5.813953488372093, + "Size": 5.802325581395348, "Type": "BUY" }, { @@ -211,7 +214,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 5.714285714285715, + "Size": 5.702857142857141, "Type": "BUY" }, { @@ -222,7 +225,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 5.6179775280898845, + "Size": 5.606741573033709, "Type": "BUY" }, { @@ -233,7 +236,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 5.78034682080925, + "Size": 5.76878612716763, "Type": "BUY" }, { @@ -244,7 +247,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 5.58659217877095, + "Size": 5.575418994413404, "Type": "BUY" }, { @@ -255,7 +258,7 @@ "ExitTime": null, "PnL": null, "ReturnPct": null, - "Size": 5.319148936170215, + "Size": 5.308510638297875, "Type": "BUY" } ] diff --git a/backtest_be_fast/tests/unit/data/portfolio_manager_response_snapshot.json b/backtest_be_fast/tests/unit/data/portfolio_manager_response_snapshot.json index 2ccdb59d..ed4bf4bb 100644 --- a/backtest_be_fast/tests/unit/data/portfolio_manager_response_snapshot.json +++ b/backtest_be_fast/tests/unit/data/portfolio_manager_response_snapshot.json @@ -12,7 +12,7 @@ "Total_Return": 6.403374693469477, "Annual_Return": 19.772925797865582, "Annual_Volatility": 16.268265412454756, - "Sharpe_Ratio": 1.215429260376322, + "Sharpe_Ratio": 1.1537121537448483, "Max_Drawdown": -10.996448015888141, "Avg_Drawdown": -5.5789050519201835, "Max_Consecutive_Gains": 13, @@ -26,64 +26,75 @@ }, "individual_returns": { "CASH": { - "weight": 0.17647058823529413, + "symbol": "CASH", + "weight": 0.1764705882352941, "amount": 1500.0, - "return": 0.0, + "return": -0.03173833873236163, "start_price": 1.0, "end_price": 1.0, "investment_type": "lump_sum", + "dca_periods": null, + "final_value": 1483.7649281289903, + "trades": 0, + "sharpe_ratio": -2.8401119713169978, "asset_type": "cash" }, "AAPL": { "symbol": "AAPL", "weight": 0.5882352941176471, "amount": 5000.0, - "return": 9.651085226342392, - "start_price": 100.0, + "return": 10.070223362547823, + "start_price": 99.59617587541356, "end_price": 109.6510852263424, "investment_type": "lump_sum", - "dca_periods": null + "dca_periods": null, + "final_value": 5471.199906341117, + "trades": 4, + "sharpe_ratio": 1.350274085975793 }, "MSFT": { "symbol": "MSFT", "weight": 0.23529411764705882, "amount": 2000.0, - "return": 3.917500960237086, - "start_price": 97.02473340370635, + "return": 2.0625877949248834, + "start_price": 97.59375541349723, "end_price": 100.82567826646401, "investment_type": "dca", - "dca_periods": 4 + "dca_periods": 4, + "final_value": 2089.322014474797, + "trades": 7, + "sharpe_ratio": 0.21199178874413271 } }, "individual_results": [ { "ticker": "CASH", - "final_equity": 1500.0, - "total_return_pct": 0.0, - "sharpe_ratio": 0.0, - "weight": 0.17647058823529413, + "final_equity": 1483.7649281289903, + "total_return_pct": -0.03173833873236163, + "sharpe_ratio": -2.8401119713169978, + "weight": 0.1764705882352941, "amount": 1500.0, - "trades": 1, + "trades": 0, "win_rate": null }, { "ticker": "AAPL", - "final_equity": 5482.554261317119, - "total_return_pct": 9.651085226342392, - "sharpe_ratio": 0.0, + "final_equity": 5471.199906341117, + "total_return_pct": 10.070223362547823, + "sharpe_ratio": 1.350274085975793, "weight": 0.5882352941176471, "amount": 5000.0, - "trades": 1, + "trades": 4, "win_rate": null }, { "ticker": "MSFT", - "final_equity": 2078.350019204742, - "total_return_pct": 3.917500960237086, - "sharpe_ratio": 0.0, + "final_equity": 2089.322014474797, + "total_return_pct": 2.0625877949248834, + "sharpe_ratio": 0.21199178874413271, "weight": 0.23529411764705882, "amount": 2000.0, - "trades": 1, + "trades": 7, "win_rate": null } ], @@ -307,7 +318,7 @@ { "EntryTime": "2024-01-02T00:00:00", "EntryPrice": 100.0, - "Size": 50.0, + "Size": 49.95, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -316,9 +327,9 @@ "Duration": null }, { - "EntryTime": "2024-02-06", + "EntryTime": "2024-02-06T00:00:00", "EntryPrice": 100.98456626761954, - "Size": 6.037616025017478, + "Size": 6.045707361368613, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -327,9 +338,9 @@ "Duration": null }, { - "EntryTime": "2024-03-05", + "EntryTime": "2024-03-05T00:00:00", "EntryPrice": 94.5075360199099, - "Size": 3.760527629486738, + "Size": 3.7559708498196542, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -338,9 +349,9 @@ "Duration": null }, { - "EntryTime": "2024-04-02", + "EntryTime": "2024-04-02T00:00:00", "EntryPrice": 99.12294522545513, - "Size": 2.239608325681097, + "Size": 2.2361866474270826, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -355,7 +366,7 @@ { "EntryTime": "2024-01-02T00:00:00", "EntryPrice": 100.0, - "Size": 5.0, + "Size": 4.995, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -366,7 +377,7 @@ { "EntryTime": "2024-02-06T00:00:00", "EntryPrice": 99.08410391726221, - "Size": 5.046218114032833, + "Size": 5.041171895918798, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -375,9 +386,9 @@ "Duration": null }, { - "EntryTime": "2024-03-05T00:00:00", - "EntryPrice": 93.8109600124437, - "Size": 5.329867639491983, + "EntryTime": "2024-02-06T00:00:00", + "EntryPrice": 99.08410391726221, + "Size": 7.8623840746778555, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -386,9 +397,9 @@ "Duration": null }, { - "EntryTime": "2024-04-02T00:00:00", - "EntryPrice": 95.47059076590945, - "Size": 5.237214895066298, + "EntryTime": "2024-03-05T00:00:00", + "EntryPrice": 93.8109600124437, + "Size": 5.324537771852491, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -397,10 +408,10 @@ "Duration": null }, { - "EntryTime": "2024-02-06", - "EntryPrice": 99.08410391726221, - "Size": 7.865682686905817, - "Type": "BUY", + "EntryTime": "2024-03-05T00:00:00", + "EntryPrice": 93.8109600124437, + "Size": 4.017431343276552, + "Type": "SELL", "ExitTime": null, "ExitPrice": null, "PnL": null, @@ -408,10 +419,10 @@ "Duration": null }, { - "EntryTime": "2024-03-05", - "EntryPrice": 93.8109600124437, - "Size": 4.015595097198421, - "Type": "SELL", + "EntryTime": "2024-04-02T00:00:00", + "EntryPrice": 95.47059076590945, + "Size": 5.231977680171234, + "Type": "BUY", "ExitTime": null, "ExitPrice": null, "PnL": null, @@ -419,9 +430,9 @@ "Duration": null }, { - "EntryTime": "2024-04-02", + "EntryTime": "2024-04-02T00:00:00", "EntryPrice": 95.47059076590945, - "Size": 3.714096961369762, + "Size": 3.7155179930128277, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -1094,7 +1105,7 @@ "Total_Return": 14.221222352302076, "Annual_Return": 66.59342789000122, "Annual_Volatility": 27.339991699127626, - "Sharpe_Ratio": 2.43575157676899, + "Sharpe_Ratio": 1.9403433865500759, "Max_Drawdown": -17.856648383046846, "Avg_Drawdown": -9.077193234077024, "Max_Consecutive_Gains": 13, @@ -1110,40 +1121,46 @@ "AAPL": { "symbol": "AAPL", "weight": 0.6, - "amount": 60.0, - "return": 7.34911910281939, - "start_price": 102.14437355682274, + "amount": 59.99999999999999, + "return": 7.1344208646137455, + "start_price": 102.14437355682276, "end_price": 109.6510852263424, "investment_type": "dca", - "dca_periods": 9 + "dca_periods": 9, + "final_value": 64.28065251876825, + "trades": 9, + "sharpe_ratio": 1.3046520435157292 }, "NVDA": { "symbol": "NVDA", "weight": 0.4, "amount": 40.0, - "return": 25.10162783951362, + "return": 24.85142458383459, "start_price": 100.0, "end_price": 125.10162783951361, "investment_type": "lump_sum", - "dca_periods": null + "dca_periods": null, + "final_value": 49.940569833533836, + "trades": 1, + "sharpe_ratio": 2.2889062415084096 } }, "individual_results": [ { "ticker": "AAPL", - "final_equity": 64.40947146169164, - "total_return_pct": 7.34911910281939, - "sharpe_ratio": 0.0, + "final_equity": 64.28065251876825, + "total_return_pct": 7.1344208646137455, + "sharpe_ratio": 1.3046520435157292, "weight": 0.6, - "amount": 60.0, - "trades": 1, + "amount": 59.99999999999999, + "trades": 9, "win_rate": null }, { "ticker": "NVDA", - "final_equity": 50.04065113580545, - "total_return_pct": 25.10162783951362, - "sharpe_ratio": 0.0, + "final_equity": 49.940569833533836, + "total_return_pct": 24.85142458383459, + "sharpe_ratio": 2.2889062415084096, "weight": 0.4, "amount": 40.0, "trades": 1, @@ -1158,7 +1175,7 @@ { "symbol": "AAPL", "weight": 0.6, - "amount": 60.0, + "amount": 59.99999999999999, "investment_type": "dca", "dca_periods": 9, "asset_type": "stock" @@ -1354,7 +1371,7 @@ { "EntryTime": "2024-01-02T00:00:00", "EntryPrice": 100.0, - "Size": 0.06666666666666667, + "Size": 0.06653333333333333, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1365,7 +1382,7 @@ { "EntryTime": "2024-01-16T00:00:00", "EntryPrice": 105.28777715283165, - "Size": 0.06331852420998112, + "Size": 0.06319188716156116, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1376,7 +1393,7 @@ { "EntryTime": "2024-01-30T00:00:00", "EntryPrice": 93.3286058026949, - "Size": 0.07143218961998216, + "Size": 0.07128932524074216, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1387,7 +1404,7 @@ { "EntryTime": "2024-02-13T00:00:00", "EntryPrice": 109.00399981419791, - "Size": 0.061159835217334146, + "Size": 0.06103751554689946, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1398,7 +1415,7 @@ { "EntryTime": "2024-02-27T00:00:00", "EntryPrice": 97.64783111288504, - "Size": 0.06827255240272279, + "Size": 0.0681360072979173, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1409,7 +1426,7 @@ { "EntryTime": "2024-03-12T00:00:00", "EntryPrice": 101.9694248211904, - "Size": 0.06537907493699285, + "Size": 0.0652483167871189, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1420,7 +1437,7 @@ { "EntryTime": "2024-03-26T00:00:00", "EntryPrice": 108.20230272125693, - "Size": 0.06161298326377451, + "Size": 0.06148975729724698, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1431,7 +1448,7 @@ { "EntryTime": "2024-04-09T00:00:00", "EntryPrice": 95.69499195625474, - "Size": 0.06966578428382347, + "Size": 0.06952645271525587, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1442,7 +1459,7 @@ { "EntryTime": "2024-04-23T00:00:00", "EntryPrice": 111.30356200582102, - "Size": 0.05989625620712846, + "Size": 0.05977646369471423, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1457,7 +1474,7 @@ { "EntryTime": "2024-01-02T00:00:00", "EntryPrice": 100.0, - "Size": 0.4, + "Size": 0.3992, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -1912,14 +1929,14 @@ "End": "2024-04-30", "Duration": "119 days", "Initial_Value": 10000.0, - "Final_Value": 10795.827557772478, - "Peak_Value": 11008.437438463221, - "Total_Return": 7.958275577724772, + "Final_Value": 10795.827557772482, + "Peak_Value": 11008.437438463225, + "Total_Return": 7.958275577724816, "Annual_Return": 26.49500472260793, - "Annual_Volatility": 16.373419117989233, - "Sharpe_Ratio": 1.618171777786978, - "Max_Drawdown": -10.604630432573163, - "Avg_Drawdown": -5.586250500628731, + "Annual_Volatility": 16.373419117989226, + "Sharpe_Ratio": 1.469782910805792, + "Max_Drawdown": -10.604630432573195, + "Avg_Drawdown": -5.586250500628729, "Max_Consecutive_Gains": 13, "Max_Consecutive_Losses": 13, "Total_Trading_Days": 86, @@ -1934,68 +1951,77 @@ "symbol": "005930.KS", "weight": 0.4, "amount": 4000.0, - "return": 7.739524907101858, - "start_price": 70100.0, - "end_price": 75525.4069598784, + "return": 5.650274132109394, + "start_price": 54.145375104879854, + "end_price": 57.303040182001816, "investment_type": "lump_sum", - "dca_periods": null + "dca_periods": null, + "final_value": 4306.487050206977, + "trades": 9, + "sharpe_ratio": 1.3282748562951685 }, "SAP": { "symbol": "SAP", "weight": 0.3, "amount": 3000.0, - "return": 8.300813919756811, - "start_price": 100.0, - "end_price": 108.3008139197568, + "return": 8.214780660158764, + "start_price": 108.02714481633944, + "end_price": 116.96487903333735, "investment_type": "lump_sum", - "dca_periods": null + "dca_periods": null, + "final_value": 3251.402843317581, + "trades": 9, + "sharpe_ratio": 1.4596569171772706 }, "AAPL": { "symbol": "AAPL", "weight": 0.3, "amount": 3000.0, - "return": 9.651085226342392, - "start_price": 100.0, + "return": 10.779105756111388, + "start_price": 99.60407313763429, "end_price": 109.6510852263424, "investment_type": "lump_sum", - "dca_periods": null + "dca_periods": null, + "final_value": 3237.937664247924, + "trades": 9, + "sharpe_ratio": 1.3528198410903929 } }, "individual_results": [ { "ticker": "005930.KS", - "final_equity": 4309.580996284074, - "total_return_pct": 7.739524907101858, - "sharpe_ratio": 0.0, + "final_equity": 4306.487050206977, + "total_return_pct": 5.650274132109394, + "sharpe_ratio": 1.3282748562951685, "weight": 0.4, "amount": 4000.0, - "trades": 1, + "trades": 9, "win_rate": null }, { "ticker": "SAP", - "final_equity": 3249.024417592704, - "total_return_pct": 8.300813919756811, - "sharpe_ratio": 0.0, + "final_equity": 3251.402843317581, + "total_return_pct": 8.214780660158764, + "sharpe_ratio": 1.4596569171772706, "weight": 0.3, "amount": 3000.0, - "trades": 1, + "trades": 9, "win_rate": null }, { "ticker": "AAPL", - "final_equity": 3289.532556790272, - "total_return_pct": 9.651085226342392, - "sharpe_ratio": 0.0, + "final_equity": 3237.937664247924, + "total_return_pct": 10.779105756111388, + "sharpe_ratio": 1.3528198410903929, "weight": 0.3, "amount": 3000.0, - "trades": 1, + "trades": 9, "win_rate": null } ], "portfolio_result": { - "total_equity": 10795.827557772478, - "total_return_pct": 7.958275577724772 + "total_equity": 10795.827557772482, + "total_return_pct": 7.958275577724816 }, "portfolio_composition": [ { @@ -2035,19 +2061,19 @@ "2024-01-12": 10514.876489376884, "2024-01-15": 10501.792439640638, "2024-01-16": 10377.194371387202, - "2024-01-17": 10251.482453871271, - "2024-01-18": 10111.87325451562, - "2024-01-19": 9967.166198784544, + "2024-01-17": 10251.482453871273, + "2024-01-18": 10111.873254515624, + "2024-01-19": 9967.166198784547, "2024-01-22": 9819.881047160654, "2024-01-23": 9673.277526497255, "2024-01-24": 9632.400244186256, - "2024-01-25": 9552.873511814569, - "2024-01-26": 9506.16409748266, + "2024-01-25": 9552.87351181457, + "2024-01-26": 9506.164097482662, "2024-01-29": 9488.639355553747, "2024-01-30": 9495.73649973785, - "2024-01-31": 9556.9468234427, + "2024-01-31": 9556.946823442702, "2024-02-01": 9649.71402337055, - "2024-02-02": 9768.388777374721, + "2024-02-02": 9768.38877737472, "2024-02-05": 9965.105838270563, "2024-02-06": 10093.594997280989, "2024-02-07": 10242.56782197674, @@ -2055,15 +2081,15 @@ "2024-02-09": 10508.025614272468, "2024-02-12": 10601.641397866086, "2024-02-13": 10650.546574857912, - "2024-02-14": 10685.460017623127, + "2024-02-14": 10685.460017623129, "2024-02-15": 10756.263137661124, "2024-02-16": 10718.580040920455, "2024-02-19": 10639.933090675528, "2024-02-20": 10519.036341663637, "2024-02-21": 10395.98105370475, - "2024-02-22": 10257.948110955715, - "2024-02-23": 10113.633947738046, - "2024-02-26": 10033.920125101318, + "2024-02-22": 10257.948110955716, + "2024-02-23": 10113.63394773805, + "2024-02-26": 10033.920125101322, "2024-02-27": 9884.042075473366, "2024-02-28": 9773.059461745084, "2024-02-29": 9689.178681898331, @@ -2084,32 +2110,32 @@ "2024-03-21": 10882.728476933913, "2024-03-22": 10849.739747255253, "2024-03-25": 10775.458253170553, - "2024-03-26": 10657.326890674603, - "2024-03-27": 10536.769507240737, - "2024-03-28": 10400.119210064197, + "2024-03-26": 10657.326890674605, + "2024-03-27": 10536.76950724074, + "2024-03-28": 10400.119210064198, "2024-03-29": 10255.988570684867, - "2024-04-01": 10175.475988843453, + "2024-04-01": 10175.475988843455, "2024-04-02": 10023.347265954673, - "2024-04-03": 9909.826722499189, + "2024-04-03": 9909.82672249919, "2024-04-04": 9822.401934042997, - "2024-04-05": 9766.607142573732, - "2024-04-08": 9739.267019183793, - "2024-04-09": 9736.158234969873, - "2024-04-10": 9854.271382328594, - "2024-04-11": 9939.24124074329, - "2024-04-12": 10051.896316723054, - "2024-04-15": 10178.596752357662, - "2024-04-16": 10304.630712488452, - "2024-04-17": 10453.998176450423, - "2024-04-18": 10597.824591683762, - "2024-04-19": 10727.290230928946, - "2024-04-22": 10898.668585272779, - "2024-04-23": 10955.781387227025, - "2024-04-24": 11000.156017983323, - "2024-04-25": 11008.437438463221, - "2024-04-26": 10980.229505544277, - "2024-04-29": 10910.250615320874, - "2024-04-30": 10795.827557772478 + "2024-04-05": 9766.607142573734, + "2024-04-08": 9739.267019183795, + "2024-04-09": 9736.158234969878, + "2024-04-10": 9854.271382328598, + "2024-04-11": 9939.241240743293, + "2024-04-12": 10051.896316723058, + "2024-04-15": 10178.596752357667, + "2024-04-16": 10304.630712488457, + "2024-04-17": 10453.998176450428, + "2024-04-18": 10597.824591683766, + "2024-04-19": 10727.29023092895, + "2024-04-22": 10898.668585272782, + "2024-04-23": 10955.78138722703, + "2024-04-24": 11000.156017983325, + "2024-04-25": 11008.437438463225, + "2024-04-26": 10980.229505544283, + "2024-04-29": 10910.250615320878, + "2024-04-30": 10795.827557772482 }, "daily_returns": { "2024-01-02": 0.0, @@ -2119,40 +2145,40 @@ "2024-01-08": 0.8606895269334995, "2024-01-09": 0.4269067612148176, "2024-01-10": 0.2893775863364969, - "2024-01-11": -0.056985288224463256, + "2024-01-11": -0.05698528822448047, "2024-01-12": -0.39790292687594253, "2024-01-15": -0.1244336987644577, "2024-01-16": -1.1864457326648432, - "2024-01-17": -1.2114249094394336, - "2024-01-18": -1.3618440063069162, - "2024-01-19": -1.4310608142408725, - "2024-01-22": -1.4777033781362108, + "2024-01-17": -1.211424909439416, + "2024-01-18": -1.3618440063068982, + "2024-01-19": -1.431060814240872, + "2024-01-22": -1.4777033781362467, "2024-01-23": -1.492925626688605, "2024-01-24": -0.42257944320347895, - "2024-01-25": -0.8256169838840154, - "2024-01-26": -0.48895669218419013, - "2024-01-29": -0.18435135086248142, + "2024-01-25": -0.8256169838839965, + "2024-01-26": -0.48895669218419, + "2024-01-29": -0.1843513508625005, "2024-01-30": 0.07479622649952389, - "2024-01-31": 0.6446084904160967, - "2024-02-01": 0.9706782055153518, - "2024-02-02": 1.2298266427041606, - "2024-02-05": 2.0138127728031563, + "2024-01-31": 0.6446084904161159, + "2024-02-01": 0.9706782055153326, + "2024-02-02": 1.2298266427041415, + "2024-02-05": 2.0138127728031754, "2024-02-06": 1.2893908112542884, "2024-02-07": 1.4759144262859902, "2024-02-08": 1.3768427995552388, "2024-02-09": 1.1983688900048919, "2024-02-12": 0.8908979386809284, "2024-02-13": 0.46129816277005675, - "2024-02-14": 0.327808929990816, - "2024-02-15": 0.6626118100785969, + "2024-02-14": 0.3278089299908331, + "2024-02-15": 0.6626118100785797, "2024-02-16": -0.3503363227395124, "2024-02-19": -0.7337441148424118, "2024-02-20": -1.1362547863937231, "2024-02-21": -1.1698342315967791, - "2024-02-22": -1.327752927174161, - "2024-02-23": -1.4068521468103135, - "2024-02-26": -0.788181805359449, - "2024-02-27": -1.493713800382071, + "2024-02-22": -1.3277529271741435, + "2024-02-23": -1.4068521468102955, + "2024-02-26": -0.7881818053594667, + "2024-02-27": -1.4937138003821067, "2024-02-28": -1.1228464314582198, "2024-02-29": -0.8582857822065831, "2024-03-01": -0.5310442973053698, @@ -2172,40 +2198,40 @@ "2024-03-21": 0.03221422063386553, "2024-03-22": -0.30312921753564215, "2024-03-25": -0.6846384873286179, - "2024-03-26": -1.0963001268293215, - "2024-03-27": -1.1312159669171538, - "2024-03-28": -1.2968898777052678, - "2024-03-29": -1.3858556471146588, - "2024-04-01": -0.7850299489563297, - "2024-04-02": -1.495052644766436, - "2024-04-03": -1.1325612137680667, - "2024-04-04": -0.8822029981382356, - "2024-04-05": -0.5680361264375513, - "2024-04-08": -0.27993471008739435, - "2024-04-09": -0.03192010453966358, - "2024-04-10": 1.2131391510718046, - "2024-04-11": 0.8622642417487062, - "2024-04-12": 1.1334373847167023, - "2024-04-15": 1.2604630175484395, - "2024-04-16": 1.2382252995885359, - "2024-04-17": 1.449517873366863, - "2024-04-18": 1.3758029493188098, - "2024-04-19": 1.2216246657524095, - "2024-04-22": 1.5975922218428904, - "2024-04-23": 0.5240346699909965, - "2024-04-24": 0.4050339194247789, - "2024-04-25": 0.07528457293114019, - "2024-04-26": -0.2562392081221795, - "2024-04-29": -0.6373171907569738, - "2024-04-30": -1.0487665369273549 + "2024-03-26": -1.0963001268293047, + "2024-03-27": -1.1312159669171535, + "2024-03-28": -1.2968898777052846, + "2024-03-29": -1.3858556471146586, + "2024-04-01": -0.7850299489563121, + "2024-04-02": -1.4950526447664536, + "2024-04-03": -1.1325612137680485, + "2024-04-04": -0.8822029981382538, + "2024-04-05": -0.5680361264375328, + "2024-04-08": -0.2799347100873943, + "2024-04-09": -0.03192010453962622, + "2024-04-10": 1.2131391510717853, + "2024-04-11": 0.8622642417487061, + "2024-04-12": 1.133437384716702, + "2024-04-15": 1.2604630175484572, + "2024-04-16": 1.2382252995885172, + "2024-04-17": 1.449517873366862, + "2024-04-18": 1.3758029493187915, + "2024-04-19": 1.221624665752409, + "2024-04-22": 1.5975922218428897, + "2024-04-23": 0.524034669991013, + "2024-04-24": 0.4050339194247621, + "2024-04-25": 0.07528457293115672, + "2024-04-26": -0.2562392081221629, + "2024-04-29": -0.6373171907569901, + "2024-04-30": -1.0487665369273547 }, "strategy_details": { "005930.KS": { "trade_log": [ { "EntryTime": "2024-01-02T00:00:00", - "EntryPrice": 70100.0, - "Size": 0.05706134094151213, + "EntryPrice": 53.92307692307692, + "Size": 74.10556348074181, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2214,9 +2240,9 @@ "Duration": null }, { - "EntryTime": "2024-01-16", - "EntryPrice": 55.35636786547617, - "Size": 0.879788161979917, + "EntryTime": "2024-01-16T00:00:00", + "EntryPrice": 55.356367865476166, + "Size": 0.8790843314503434, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2225,9 +2251,9 @@ "Duration": null }, { - "EntryTime": "2024-01-30", + "EntryTime": "2024-01-30T00:00:00", "EntryPrice": 51.84697193293489, - "Size": 1.7235498741685973, + "Size": 1.7249287140679428, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2236,9 +2262,9 @@ "Duration": null }, { - "EntryTime": "2024-02-13", - "EntryPrice": 56.232169901611705, - "Size": 2.5035185649860665, + "EntryTime": "2024-02-13T00:00:00", + "EntryPrice": 56.2321699016117, + "Size": 2.5015157501340894, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2247,9 +2273,9 @@ "Duration": null }, { - "EntryTime": "2024-02-27", + "EntryTime": "2024-02-27T00:00:00", "EntryPrice": 53.81561956016274, - "Size": 2.2934275409662206, + "Size": 2.295262282999005, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2258,9 +2284,9 @@ "Duration": null }, { - "EntryTime": "2024-03-12", + "EntryTime": "2024-03-12T00:00:00", "EntryPrice": 54.97020971555535, - "Size": 0.7647005744626796, + "Size": 0.7640888140031166, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2269,9 +2295,9 @@ "Duration": null }, { - "EntryTime": "2024-03-26", + "EntryTime": "2024-03-26T00:00:00", "EntryPrice": 56.65971348589905, - "Size": 1.0081668423878938, + "Size": 1.0073603089140022, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2280,9 +2306,9 @@ "Duration": null }, { - "EntryTime": "2024-04-09", + "EntryTime": "2024-04-09T00:00:00", "EntryPrice": 53.00994089666328, - "Size": 1.7693464088858946, + "Size": 1.7707618860129912, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2291,9 +2317,9 @@ "Duration": null }, { - "EntryTime": "2024-04-23", + "EntryTime": "2024-04-23T00:00:00", "EntryPrice": 58.31199060750222, - "Size": 1.6877102740390484, + "Size": 1.6861948549197194, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2307,8 +2333,8 @@ "trade_log": [ { "EntryTime": "2024-01-02T00:00:00", - "EntryPrice": 100.0, - "Size": 30.0, + "EntryPrice": 108.0, + "Size": 27.75, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2317,9 +2343,9 @@ "Duration": null }, { - "EntryTime": "2024-01-16", + "EntryTime": "2024-01-16T00:00:00", "EntryPrice": 112.41809949379365, - "Size": 0.05706134901065951, + "Size": 0.0573212815874804, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2328,9 +2354,20 @@ "Duration": null }, { - "EntryTime": "2024-02-13", + "EntryTime": "2024-01-30T00:00:00", + "EntryPrice": 102.86617070018288, + "Size": 0.0007887309040306434, + "Type": "BUY", + "ExitTime": null, + "ExitPrice": null, + "PnL": null, + "ReturnPct": null, + "Duration": null + }, + { + "EntryTime": "2024-02-13T00:00:00", "EntryPrice": 115.69823984950031, - "Size": 0.07637972540203464, + "Size": 0.07710978809693003, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2339,9 +2376,9 @@ "Duration": null }, { - "EntryTime": "2024-02-27", + "EntryTime": "2024-02-27T00:00:00", "EntryPrice": 106.63474320143689, - "Size": 0.19152733902739882, + "Size": 0.1908328811781388, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2350,9 +2387,9 @@ "Duration": null }, { - "EntryTime": "2024-03-12", + "EntryTime": "2024-03-12T00:00:00", "EntryPrice": 110.27023410516422, - "Size": 0.05394618595275347, + "Size": 0.05417490996674701, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2361,9 +2398,9 @@ "Duration": null }, { - "EntryTime": "2024-03-26", + "EntryTime": "2024-03-26T00:00:00", "EntryPrice": 115.45386520421813, - "Size": 0.060288888170994426, + "Size": 0.06058574671791561, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2372,9 +2409,20 @@ "Duration": null }, { - "EntryTime": "2024-04-23", + "EntryTime": "2024-04-09T00:00:00", + "EntryPrice": 105.45794348456634, + "Size": 0.004369179199695594, + "Type": "BUY", + "ExitTime": null, + "ExitPrice": null, + "PnL": null, + "ReturnPct": null, + "Duration": null + }, + { + "EntryTime": "2024-04-23T00:00:00", "EntryPrice": 118.23588522471503, - "Size": 0.1018737766734823, + "Size": 0.10131324186662383, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2389,7 +2437,7 @@ { "EntryTime": "2024-01-02T00:00:00", "EntryPrice": 100.0, - "Size": 30.0, + "Size": 29.97, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2398,9 +2446,9 @@ "Duration": null }, { - "EntryTime": "2024-01-16", + "EntryTime": "2024-01-16T00:00:00", "EntryPrice": 105.28777715283165, - "Size": 0.401633977574992, + "Size": 0.40191151336378894, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2409,9 +2457,9 @@ "Duration": null }, { - "EntryTime": "2024-01-30", + "EntryTime": "2024-01-30T00:00:00", "EntryPrice": 93.3286058026949, - "Size": 0.9560422653980787, + "Size": 0.9554677737434609, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2420,9 +2468,9 @@ "Duration": null }, { - "EntryTime": "2024-02-13", + "EntryTime": "2024-02-13T00:00:00", "EntryPrice": 109.00399981419791, - "Size": 1.2104260553175337, + "Size": 1.2112009532013097, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2431,9 +2479,9 @@ "Duration": null }, { - "EntryTime": "2024-02-27", + "EntryTime": "2024-02-27T00:00:00", "EntryPrice": 97.64783111288504, - "Size": 1.0547981890260978, + "Size": 1.0540398175028791, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2442,9 +2490,9 @@ "Duration": null }, { - "EntryTime": "2024-03-12", + "EntryTime": "2024-03-12T00:00:00", "EntryPrice": 101.9694248211904, - "Size": 0.3539011076801586, + "Size": 0.35414845094616965, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2453,9 +2501,9 @@ "Duration": null }, { - "EntryTime": "2024-03-26", + "EntryTime": "2024-03-26T00:00:00", "EntryPrice": 108.20230272125693, - "Size": 0.4635932693290208, + "Size": 0.4639100229081876, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2464,9 +2512,9 @@ "Duration": null }, { - "EntryTime": "2024-04-09", + "EntryTime": "2024-04-09T00:00:00", "EntryPrice": 95.69499195625474, - "Size": 0.974720894288307, + "Size": 0.9741328199493609, "Type": "BUY", "ExitTime": null, "ExitPrice": null, @@ -2475,9 +2523,9 @@ "Duration": null }, { - "EntryTime": "2024-04-23", + "EntryTime": "2024-04-23T00:00:00", "EntryPrice": 111.30356200582102, - "Size": 0.9924110228237595, + "Size": 0.9930064694374572, "Type": "SELL", "ExitTime": null, "ExitPrice": null, @@ -2495,8 +2543,8 @@ { "symbol": "005930.KS", "action": "buy", - "shares": 0.879788161979917, - "price": 55.35636786547617 + "shares": 0.8797881619799313, + "price": 55.356367865476166 }, { "symbol": "SAP", @@ -2517,7 +2565,7 @@ "AAPL": 0.30407496962061126 }, "weights_after": { - "005930.KS": 0.39999999999999997, + "005930.KS": 0.4000000000000001, "SAP": 0.3, "AAPL": 0.3 }, @@ -2529,7 +2577,7 @@ { "symbol": "005930.KS", "action": "sell", - "shares": 1.7235498741685973, + "shares": 1.7235498741686115, "price": 51.84697193293489 }, { @@ -2540,16 +2588,16 @@ } ], "weights_before": { - "005930.KS": 0.40941045069036053, + "005930.KS": 0.4094104506903606, "SAP": 0.29998580966343535, "AAPL": 0.29060373964620406 }, "weights_after": { - "005930.KS": 0.4, - "SAP": 0.29999999999999993, - "AAPL": 0.2999999999999999 + "005930.KS": 0.4000000000000001, + "SAP": 0.3, + "AAPL": 0.3 }, - "commission_cost": 0.17872168390206616 + "commission_cost": 0.17872168390206705 }, { "date": "2024-02-13", @@ -2557,8 +2605,8 @@ { "symbol": "005930.KS", "action": "buy", - "shares": 2.5035185649860665, - "price": 56.232169901611705 + "shares": 2.5035185649860807, + "price": 56.2321699016117 }, { "symbol": "SAP", @@ -2579,7 +2627,7 @@ "AAPL": 0.31238788945619156 }, "weights_after": { - "005930.KS": 0.4, + "005930.KS": 0.40000000000000013, "SAP": 0.29999999999999993, "AAPL": 0.3 }, @@ -2591,7 +2639,7 @@ { "symbol": "005930.KS", "action": "sell", - "shares": 2.2934275409662206, + "shares": 2.293427540966235, "price": 53.81561956016274 }, { @@ -2608,7 +2656,7 @@ } ], "weights_before": { - "005930.KS": 0.412486707443839, + "005930.KS": 0.4124867074438391, "SAP": 0.29793374427045743, "AAPL": 0.2895795482857034 }, @@ -2617,7 +2665,7 @@ "SAP": 0.3, "AAPL": 0.3 }, - "commission_cost": 0.2468444480668768 + "commission_cost": 0.24684444806687772 }, { "date": "2024-03-12", @@ -2659,26 +2707,26 @@ { "symbol": "005930.KS", "action": "buy", - "shares": 1.0081668423878938, + "shares": 1.008166842387908, "price": 56.65971348589905 }, { "symbol": "SAP", "action": "sell", - "shares": 0.060288888170994426, + "shares": 0.06028888817099087, "price": 115.45386520421813 }, { "symbol": "AAPL", "action": "sell", - "shares": 0.4635932693290208, + "shares": 0.46359326932901723, "price": 108.20230272125693 } ], "weights_before": { - "005930.KS": 0.39464013512164137, - "SAP": 0.3006531197385627, - "AAPL": 0.30470674513979606 + "005930.KS": 0.39464013512164126, + "SAP": 0.3006531197385626, + "AAPL": 0.304706745139796 }, "weights_after": { "005930.KS": 0.4, @@ -2693,27 +2741,27 @@ { "symbol": "005930.KS", "action": "sell", - "shares": 1.7693464088858946, + "shares": 1.7693464088858804, "price": 53.00994089666328 }, { "symbol": "AAPL", "action": "buy", - "shares": 0.974720894288307, + "shares": 0.9747208942883177, "price": 95.69499195625474 } ], "weights_before": { - "005930.KS": 0.409633280317524, - "SAP": 0.29994689584452283, - "AAPL": 0.2904198238379533 + "005930.KS": 0.40963328031752394, + "SAP": 0.2999468958445227, + "AAPL": 0.29041982383795323 }, "weights_after": { - "005930.KS": 0.4, - "SAP": 0.30000000000000004, - "AAPL": 0.30000000000000004 + "005930.KS": 0.39999999999999997, + "SAP": 0.3, + "AAPL": 0.3 }, - "commission_cost": 0.18758589712152843 + "commission_cost": 0.1875858971215298 }, { "date": "2024-04-23", @@ -2721,33 +2769,33 @@ { "symbol": "005930.KS", "action": "buy", - "shares": 1.6877102740390484, + "shares": 1.6877102740390342, "price": 58.31199060750222 }, { "symbol": "SAP", "action": "buy", - "shares": 0.1018737766734823, + "shares": 0.10187377667348585, "price": 118.23588522471503 }, { "symbol": "AAPL", "action": "sell", - "shares": 0.9924110228237595, + "shares": 0.992411022823763, "price": 111.30356200582102 } ], "weights_before": { - "005930.KS": 0.39101736720125363, - "SAP": 0.29890059021248216, + "005930.KS": 0.3910173672012537, + "SAP": 0.2989005902124822, "AAPL": 0.3100820425862642 }, "weights_after": { - "005930.KS": 0.4, + "005930.KS": 0.3999999999999999, "SAP": 0.3, - "AAPL": 0.30000000000000004 + "AAPL": 0.3 }, - "commission_cost": 0.2209177636282493 + "commission_cost": 0.2209177636282484 } ], "weight_history": [ @@ -2796,8 +2844,8 @@ { "date": "2024-01-11", "005930.KS": 0.3905647018404947, - "SAP": 0.3022035060711663, - "AAPL": 0.3072317920883389 + "SAP": 0.30220350607116636, + "AAPL": 0.30723179208833895 }, { "date": "2024-01-12", @@ -2813,61 +2861,61 @@ }, { "date": "2024-01-16", - "005930.KS": 0.39999999999999997, + "005930.KS": 0.4000000000000001, "SAP": 0.3, "AAPL": 0.3 }, { "date": "2024-01-17", - "005930.KS": 0.40087237192900554, - "SAP": 0.3003073160824916, - "AAPL": 0.2988203119885029 + "005930.KS": 0.4008723719290056, + "SAP": 0.30030731608249156, + "AAPL": 0.29882031198850284 }, { "date": "2024-01-18", - "005930.KS": 0.4019280757546259, - "SAP": 0.30060667550951276, - "AAPL": 0.2974652487358615 + "005930.KS": 0.40192807575462586, + "SAP": 0.30060667550951264, + "AAPL": 0.2974652487358614 }, { "date": "2024-01-19", - "005930.KS": 0.40308238322740736, - "SAP": 0.30090217358204474, - "AAPL": 0.296015443190548 + "005930.KS": 0.4030823832274073, + "SAP": 0.30090217358204463, + "AAPL": 0.2960154431905479 }, { "date": "2024-01-22", - "005930.KS": 0.4032809362431874, + "005930.KS": 0.4032809362431875, "SAP": 0.3019567768639828, "AAPL": 0.29476228689282974 }, { "date": "2024-01-23", - "005930.KS": 0.40511790229112576, + "005930.KS": 0.4051179022911258, "SAP": 0.300898481292004, "AAPL": 0.2939836164168703 }, { "date": "2024-01-24", - "005930.KS": 0.41000790204522314, + "005930.KS": 0.4100079020452232, "SAP": 0.2991564159596895, "AAPL": 0.29083568199508725 }, { "date": "2024-01-25", "005930.KS": 0.410527989418572, - "SAP": 0.299482121765089, - "AAPL": 0.2899898888163391 + "SAP": 0.2994821217650889, + "AAPL": 0.289989888816339 }, { "date": "2024-01-26", "005930.KS": 0.41065649445376146, - "SAP": 0.29982709030882254, - "AAPL": 0.289516415237416 + "SAP": 0.2998270903088225, + "AAPL": 0.28951641523741595 }, { "date": "2024-01-29", - "005930.KS": 0.40937805033709146, + "005930.KS": 0.4093780503370915, "SAP": 0.3009575405872964, "AAPL": 0.2896644090756122 }, @@ -2879,9 +2927,9 @@ }, { "date": "2024-01-31", - "005930.KS": 0.39881646120907316, - "SAP": 0.3003904580564747, - "AAPL": 0.30079308073445227 + "005930.KS": 0.3988164612090731, + "SAP": 0.30039045805647463, + "AAPL": 0.3007930807344522 }, { "date": "2024-02-01", @@ -2892,8 +2940,8 @@ { "date": "2024-02-02", "005930.KS": 0.3954408029381323, - "SAP": 0.3011988473533429, - "AAPL": 0.30336034970852477 + "SAP": 0.30119884735334296, + "AAPL": 0.3033603497085248 }, { "date": "2024-02-05", @@ -2909,7 +2957,7 @@ }, { "date": "2024-02-07", - "005930.KS": 0.39314169547857913, + "005930.KS": 0.3931416954785791, "SAP": 0.2998487615723924, "AAPL": 0.30700954294902855 }, @@ -2933,63 +2981,63 @@ }, { "date": "2024-02-13", - "005930.KS": 0.4, + "005930.KS": 0.40000000000000013, "SAP": 0.29999999999999993, "AAPL": 0.3 }, { "date": "2024-02-14", "005930.KS": 0.39921584889441625, - "SAP": 0.3003851099261556, - "AAPL": 0.3003990411794282 + "SAP": 0.3003851099261555, + "AAPL": 0.3003990411794281 }, { "date": "2024-02-15", - "005930.KS": 0.40282090527841596, + "005930.KS": 0.402820905278416, "SAP": 0.29873542318490603, "AAPL": 0.298443671536678 }, { "date": "2024-02-16", - "005930.KS": 0.40274612588584363, + "005930.KS": 0.40274612588584374, "SAP": 0.2990846647486495, "AAPL": 0.29816920936550684 }, { "date": "2024-02-19", - "005930.KS": 0.40205273345730413, + "005930.KS": 0.40205273345730425, "SAP": 0.3001718189560921, "AAPL": 0.29777544758660374 }, { "date": "2024-02-20", - "005930.KS": 0.4034066063637337, + "005930.KS": 0.40340660636373377, "SAP": 0.29912990982617876, "AAPL": 0.29746348381008747 }, { "date": "2024-02-21", - "005930.KS": 0.4042388346379904, + "005930.KS": 0.4042388346379905, "SAP": 0.2994313565540732, "AAPL": 0.29632980880793636 }, { "date": "2024-02-22", - "005930.KS": 0.4052625718081116, - "SAP": 0.29972323643152515, - "AAPL": 0.29501419176036325 + "005930.KS": 0.4052625718081117, + "SAP": 0.2997232364315251, + "AAPL": 0.2950141917603632 }, { "date": "2024-02-23", - "005930.KS": 0.4063955696539738, - "SAP": 0.3000100437597984, - "AAPL": 0.29359438658622794 + "005930.KS": 0.4063955696539737, + "SAP": 0.3000100437597983, + "AAPL": 0.29359438658622783 }, { "date": "2024-02-26", "005930.KS": 0.4106400716802039, - "SAP": 0.2989980651402185, - "AAPL": 0.29036186317957774 + "SAP": 0.2989980651402184, + "AAPL": 0.2903618631795777 }, { "date": "2024-02-27", @@ -3029,7 +3077,7 @@ }, { "date": "2024-03-06", - "005930.KS": 0.3993532578550977, + "005930.KS": 0.3993532578550976, "SAP": 0.30153233236803234, "AAPL": 0.29911440977687 }, @@ -3131,122 +3179,122 @@ }, { "date": "2024-03-29", - "005930.KS": 0.4028877574546488, + "005930.KS": 0.40288775745464883, "SAP": 0.3008973134020974, "AAPL": 0.29621492914325376 }, { "date": "2024-04-01", - "005930.KS": 0.40710232480818775, - "SAP": 0.2999057962227655, + "005930.KS": 0.4071023248081878, + "SAP": 0.2999057962227654, "AAPL": 0.2929918789690467 }, { "date": "2024-04-02", "005930.KS": 0.40894718770355054, - "SAP": 0.29884322625411086, - "AAPL": 0.2922095860423385 + "SAP": 0.2988432262541109, + "AAPL": 0.29220958604233854 }, { "date": "2024-04-03", "005930.KS": 0.40982020572309746, - "SAP": 0.299146795069476, - "AAPL": 0.29103299920742665 + "SAP": 0.29914679506947595, + "AAPL": 0.2910329992074266 }, { "date": "2024-04-04", - "005930.KS": 0.4104103368784849, - "SAP": 0.29946547266812945, + "005930.KS": 0.41041033687848494, + "SAP": 0.2994654726681295, "AAPL": 0.2901241904533856 }, { "date": "2024-04-05", "005930.KS": 0.41063498220180017, - "SAP": 0.29980320841557084, + "SAP": 0.2998032084155708, "AAPL": 0.28956180938262893 }, { "date": "2024-04-08", - "005930.KS": 0.4094760588372401, + "005930.KS": 0.40947605883724014, "SAP": 0.30092447947496037, "AAPL": 0.28959946168779954 }, { "date": "2024-04-09", - "005930.KS": 0.4, - "SAP": 0.30000000000000004, - "AAPL": 0.30000000000000004 + "005930.KS": 0.39999999999999997, + "SAP": 0.3, + "AAPL": 0.3 }, { "date": "2024-04-10", "005930.KS": 0.40297093958328334, - "SAP": 0.2983731769794662, - "AAPL": 0.2986558834372504 + "SAP": 0.29837317697946614, + "AAPL": 0.29865588343725047 }, { "date": "2024-04-11", "005930.KS": 0.4015240387072467, "SAP": 0.2987798494344794, - "AAPL": 0.299696111858274 + "AAPL": 0.29969611185827405 }, { "date": "2024-04-12", - "005930.KS": 0.39977875419180375, + "005930.KS": 0.3997787541918038, "SAP": 0.29919653237201194, - "AAPL": 0.30102471343618425 + "AAPL": 0.3010247134361843 }, { "date": "2024-04-15", - "005930.KS": 0.3968806529416553, - "SAP": 0.30037100901919944, - "AAPL": 0.3027483380391454 + "005930.KS": 0.39688065294165525, + "SAP": 0.3003710090191994, + "AAPL": 0.30274833803914536 }, { "date": "2024-04-16", - "005930.KS": 0.3956091208484716, - "SAP": 0.29943702960332025, + "005930.KS": 0.39560912084847155, + "SAP": 0.2994370296033203, "AAPL": 0.3049538495482081 }, { "date": "2024-04-17", "005930.KS": 0.3935764812460582, - "SAP": 0.29985857992159465, + "SAP": 0.2998585799215946, "AAPL": 0.3065649388323472 }, { "date": "2024-04-18", - "005930.KS": 0.39164774065186125, - "SAP": 0.3002752226079601, - "AAPL": 0.30807703674017856 + "005930.KS": 0.3916477406518613, + "SAP": 0.30027522260796013, + "AAPL": 0.3080770367401786 }, { "date": "2024-04-19", - "005930.KS": 0.3899043807745208, - "SAP": 0.3006842875604965, - "AAPL": 0.30941133166498275 + "005930.KS": 0.38990438077452083, + "SAP": 0.30068428756049653, + "AAPL": 0.3094113316649828 }, { "date": "2024-04-22", - "005930.KS": 0.39146212285954135, + "005930.KS": 0.3914621228595413, "SAP": 0.2998624977544652, - "AAPL": 0.30867537938599343 + "AAPL": 0.3086753793859935 }, { "date": "2024-04-23", - "005930.KS": 0.4, + "005930.KS": 0.3999999999999999, "SAP": 0.3, - "AAPL": 0.30000000000000004 + "AAPL": 0.3 }, { "date": "2024-04-24", "005930.KS": 0.39912460751333634, - "SAP": 0.3003926685656173, + "SAP": 0.3003926685656174, "AAPL": 0.30048272392104636 }, { "date": "2024-04-25", - "005930.KS": 0.39860031054245004, - "SAP": 0.3007662975552313, + "005930.KS": 0.39860031054245, + "SAP": 0.3007662975552314, "AAPL": 0.30063339190231864 }, { @@ -3257,14 +3305,14 @@ }, { "date": "2024-04-29", - "005930.KS": 0.3976461692880652, + "005930.KS": 0.3976461692880651, "SAP": 0.3022124229678113, "AAPL": 0.30014140774412346 }, { "date": "2024-04-30", - "005930.KS": 0.39890291199645117, - "SAP": 0.3011721728527171, + "005930.KS": 0.398902911996451, + "SAP": 0.30117217285271713, "AAPL": 0.29992491515083186 } ], @@ -3574,7 +3622,7 @@ "Total_Return": 7.2383139197568, "Annual_Return": 23.92360142271186, "Annual_Volatility": 16.31222417779554, - "Sharpe_Ratio": 1.4666057284375145, + "Sharpe_Ratio": 1.3364290788207738, "Max_Drawdown": -10.821231436346512, "Avg_Drawdown": -5.6237390886031555, "Max_Consecutive_Gains": 13, @@ -3588,23 +3636,31 @@ }, "individual_returns": { "CASH": { + "symbol": "CASH", "weight": 0.25, "amount": 1000.0, "return": 0.0, "start_price": 1.0, "end_price": 1.0, "investment_type": "lump_sum", + "dca_periods": null, + "final_value": 1000.0, + "trades": 0, + "sharpe_ratio": 0.0, "asset_type": "cash" }, "AAPL": { "symbol": "AAPL", "weight": 0.75, "amount": 3000.0, - "return": 9.651085226342392, + "return": 9.651085226342396, "start_price": 100.0, "end_price": 109.6510852263424, "investment_type": "lump_sum", - "dca_periods": null + "dca_periods": null, + "final_value": 3289.532556790272, + "trades": 1, + "sharpe_ratio": 1.3546999838107654 } }, "individual_results": [ @@ -3615,14 +3671,14 @@ "sharpe_ratio": 0.0, "weight": 0.25, "amount": 1000.0, - "trades": 1, + "trades": 0, "win_rate": null }, { "ticker": "AAPL", "final_equity": 3289.532556790272, - "total_return_pct": 9.651085226342392, - "sharpe_ratio": 0.0, + "total_return_pct": 9.651085226342396, + "sharpe_ratio": 1.3546999838107654, "weight": 0.75, "amount": 3000.0, "trades": 1, @@ -4295,7 +4351,7 @@ "Total_Return": 6.0383546157446455, "Annual_Return": 19.716560607856447, "Annual_Volatility": 17.240513189861993, - "Sharpe_Ratio": 1.143617964890409, + "Sharpe_Ratio": 1.0820108663652546, "Max_Drawdown": -11.56539585144346, "Avg_Drawdown": -5.944851107099882, "Max_Consecutive_Gains": 13, @@ -5305,7 +5361,7 @@ "Total_Return": 16.770452901038936, "Annual_Return": 60.94108899169186, "Annual_Volatility": 25.951702834285616, - "Sharpe_Ratio": 2.3482501083196965, + "Sharpe_Ratio": 1.8802314599700471, "Max_Drawdown": -16.380715508305098, "Avg_Drawdown": -8.53134068176262, "Max_Consecutive_Gains": 13, diff --git a/backtest_be_fast/tests/unit/test_calculation_accounting_regressions.py b/backtest_be_fast/tests/unit/test_calculation_accounting_regressions.py new file mode 100644 index 00000000..a52b27ed --- /dev/null +++ b/backtest_be_fast/tests/unit/test_calculation_accounting_regressions.py @@ -0,0 +1,96 @@ +"""Independent accounting oracles; market data I/O only is replaced.""" +import asyncio +from unittest.mock import AsyncMock, patch + +import numpy as np +import pandas as pd +import pytest + +from app.schemas.schemas import PortfolioBacktestRequest +from app.services.portfolio_manager_service import PortfolioManagerService +from app.services.portfolio.portfolio_metrics import PortfolioMetrics +from app.services.portfolio_calculator_service import PortfolioCalculator + +pytestmark = pytest.mark.unit + + +def run_portfolio(frames, start, end, *, dca=False, fee=0, rebalance='none', currencies=None, rates=None): + request = PortfolioBacktestRequest( + portfolio=[dict(symbol=s, amount=1000 / len(frames), + investment_type='dca' if dca else 'lump_sum', dca_frequency='monthly_1') + for s in frames], start_date=start, end_date=end, + commission=fee, rebalance_frequency=rebalance, strategy='buy_hold_strategy', + include_stock_data=False, include_exchange_rates=False, include_benchmarks=False, + include_news=False, include_volatility_events=False, + ) + service = PortfolioManagerService() + with patch.object(service.data_loader, 'load_stock_data_parallel', AsyncMock(return_value=frames)), \ + patch.object(service.data_loader, 'load_ticker_currencies', AsyncMock(return_value=currencies or {s: 'USD' for s in frames})), \ + patch.object(service.data_loader, 'load_exchange_rates', AsyncMock(return_value=rates or {})): + return asyncio.run(service.run_buy_and_hold_portfolio_backtest(request))['data'] + + +@pytest.mark.parametrize('start,end,holiday,expected,dates', [ + ('2024-01-03', '2024-03-06', '2024-02-07', 3000, ['2024-01-03', '2024-02-08', '2024-03-06']), + ('2024-01-03', '2024-03-06', '2024-03-06', 2000, ['2024-01-03', '2024-02-07']), + ('2024-01-01', '2024-12-02', '2024-01-01', 12000, None), +]) +def test_holidays_do_not_shift_dca_calendar_or_create_losses(start, end, holiday, expected, dates): + index = pd.bdate_range(start, end).difference(pd.DatetimeIndex([holiday])) + data = run_portfolio({'AAA': pd.DataFrame({'Close': 100.0}, index=index)}, start, end, dca=True) + stats = data['portfolio_statistics'] + assert stats['Initial_Value'] == expected + assert stats['Final_Value'] == pytest.approx(expected) + assert stats['Total_Return'] == pytest.approx(0) + assert data['individual_results'][0]['final_equity'] == pytest.approx(expected) + assert data['individual_results'][0]['trades'] == expected / 1000 + if dates: + assert [t['EntryTime'][:10] for t in data['strategy_details']['AAA']['trade_log']] == dates + + +@pytest.mark.parametrize('fee,currency,expected', [(0.01, 'USD', 990), (0, 'KRW', 500)]) +def test_individual_results_use_executed_usd_values(fee, currency, expected): + index = pd.bdate_range('2024-01-01', '2024-03-29') + frame = pd.DataFrame({'Close': 100 if currency == 'USD' else 100000}, index=index) + rates = {'KRW': {day.date(): 1000 if pos == 0 else 2000 for pos, day in enumerate(index)}} + data = run_portfolio({'AAA': frame}, '2024-01-01', '2024-03-29', fee=fee, + currencies={'AAA': currency}, rates=rates) + assert data['portfolio_statistics']['Final_Value'] == pytest.approx(expected) + assert data['individual_results'][0]['final_equity'] == pytest.approx(expected) + assert data['individual_returns']['AAA']['return'] == pytest.approx((expected / 1000 - 1) * 100) + assert data['individual_returns']['AAA']['end_price'] == pytest.approx(100 if currency == 'USD' else 50) + assert data['strategy_details']['AAA']['trade_log'][0]['Size'] == pytest.approx(10 * (1 - fee)) + + +def test_rebalanced_individual_final_equities_reconcile_with_portfolio(): + index = pd.bdate_range('2023-01-02', '2023-02-03') + frames = {'AAA': pd.DataFrame({'Close': 100 * 2.0 ** (np.arange(len(index)) // 5)}, index=index), + 'BBB': pd.DataFrame({'Close': 100.0}, index=index)} + data = run_portfolio(frames, '2023-01-02', '2023-02-03', rebalance='weekly_1') + assert data['portfolio_statistics']['Final_Value'] == pytest.approx(1000 * 1.5 ** 4) + assert sum(row['final_equity'] for row in data['individual_results']) == pytest.approx(1000 * 1.5 ** 4) + # Flat BBB earns no price profit even though rebalance sales fund its holdings. + assert data['individual_returns']['BBB']['return'] == pytest.approx(0) + assert data['individual_results'][0]['trades'] > 1 + + +def test_holiday_delays_rebalance_execution_but_not_the_next_due_date(): + index = pd.bdate_range('2024-01-03', '2024-03-06').difference(pd.DatetimeIndex(['2024-02-07'])) + frames = {'AAA': pd.DataFrame({'Close': 100 + np.arange(len(index))}, index=index), + 'BBB': pd.DataFrame({'Close': 100.0}, index=index)} + data = run_portfolio(frames, '2024-01-03', '2024-03-06', fee=.01, rebalance='monthly_1') + assert [event['date'] for event in data['rebalance_history']] == ['2024-02-08', '2024-03-06'] + stats = data['portfolio_statistics'] + assert sum(row['final_equity'] for row in data['individual_results']) == pytest.approx(stats['Final_Value']) + attributed_profit = sum(row['amount'] * row['return'] / 100 for row in data['individual_returns'].values()) + assert attributed_profit == pytest.approx(stats['Final_Value'] - stats['Initial_Value']) + + +@pytest.mark.parametrize('calculator', [PortfolioMetrics, PortfolioCalculator]) +def test_flat_days_break_loss_streaks_and_sharpe_uses_arithmetic_returns(calculator): + returns = pd.Series([0.0] + [0.1, -0.1] * 126, index=pd.bdate_range('2023-01-02', periods=253)) + frame = pd.DataFrame({'Portfolio_Value': (1 + returns).cumprod(), 'Daily_Return': returns}) + stats = calculator.calculate_portfolio_statistics(frame, 1000) + assert stats['Sharpe_Ratio'] == pytest.approx(0, abs=1e-12) + flat = frame.assign(Portfolio_Value=1.0, Daily_Return=0.0) + assert calculator.calculate_portfolio_statistics(flat, 1000)['Max_Consecutive_Losses'] == 0 diff --git a/backtest_be_fast/tests/unit/test_calculation_currency_validation.py b/backtest_be_fast/tests/unit/test_calculation_currency_validation.py new file mode 100644 index 00000000..048a0c6f --- /dev/null +++ b/backtest_be_fast/tests/unit/test_calculation_currency_validation.py @@ -0,0 +1,94 @@ +"""Currency failures must not become successful USD calculations.""" +import asyncio +from datetime import datetime +from unittest.mock import AsyncMock, Mock, patch + +import pandas as pd +import pytest + +from app.domain.portfolio_domain import DcaStrategyInfo +from app.schemas.requests import BacktestRequest +from app.services.backtest_engine import BacktestEngine +from app.services.validation_service import ValidationService +from app.services.portfolio.portfolio_data_loader import PortfolioDataLoader +from app.services.portfolio.portfolio_price_alignment import pre_calculate_prices +from app.utils.currency_converter import CurrencyConverter + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize('metadata', [{}, {'AAA': {}}, {'AAA': {'currency': None}}, {'AAA': {'currency': 'XXX'}}]) +def test_missing_currency_metadata_is_rejected(metadata): + repository = Mock() + repository.get_tickers_info_batch.return_value = metadata + with pytest.raises(ValueError, match='AAA'): + asyncio.run(PortfolioDataLoader(repository).load_ticker_currencies(['AAA'])) + + +@pytest.mark.parametrize('rate', [0.0, -1.0, float('nan'), float('inf')]) +def test_invalid_fx_never_falls_back_to_parity(rate): + index = pd.DatetimeIndex(['2024-01-01', '2024-01-02']) + info = {'AAA': DcaStrategyInfo(symbol='AAA', allocation=1, asset_type='stock', + investment_type='lump_sum', monthly_amount=1000)} + with pytest.raises(ValueError, match='환율'): + pre_calculate_prices(index, {'AAA': 1000}, {'AAA': pd.DataFrame({'Close': 100000}, index=index)}, + info, {'AAA': 'KRW'}, {'KRW': {d.date(): rate for d in index}}) + + +def test_future_fx_is_not_used_to_fill_earlier_stock_price(): + index = pd.DatetimeIndex(['2024-01-01', '2024-01-02']) + repository = Mock() + repository.load_stock_data.return_value = pd.DataFrame({'Close': [1300]}, index=index[1:]) + converter = CurrencyConverter() + converter.stock_repository = repository + with pytest.raises(ValueError, match='환율'): + asyncio.run(converter.load_and_prepare_exchange_rates('KRW', '2024-01-01', '2024-01-02', index)) + + +def test_cache_first_strategy_does_not_need_yahoo_info_and_measures_duration(): + index = pd.bdate_range('2023-01-02', '2023-04-03') + frame = pd.DataFrame({'Open': 100.0, 'High': 101.0, 'Low': 99.0, 'Close': 100.0, 'Volume': 1000}, index=index) + repository = Mock() + repository.get_stock_data = AsyncMock(return_value=frame) + fetcher = Mock() + fetcher.validate_ticker.side_effect = AssertionError('Cached prices must not call Yahoo metadata') + engine = BacktestEngine(data_repository=repository, + validation_service_instance=ValidationService(data_fetcher_instance=fetcher)) + request = BacktestRequest(ticker='AAA', start_date='2023-01-02', end_date='2023-04-03', + initial_cash=1000, strategy='sma_strategy', commission=0) + with patch.object(engine, '_convert_to_usd', AsyncMock(return_value=frame)): + result = asyncio.run(engine.run_backtest(request)) + fetcher.validate_ticker.assert_not_called() + repository.get_stock_data.assert_awaited_once() + assert 0 < result.execution_time_seconds < 10 + assert result.execution_time_seconds != 0.5 + + +@pytest.mark.parametrize('missing_metadata', [False, True]) +def test_real_http_route_uses_actual_funding_and_rejects_missing_currency(missing_metadata): + from fastapi.testclient import TestClient + from app.main import app + from app.api.v1.endpoints import backtest as endpoint + + index = pd.bdate_range('2024-01-03', '2024-03-05') + frame = pd.DataFrame({'Close': 100.0}, index=index) + repository = Mock() + repository.load_stock_data.return_value = frame + repository.get_tickers_info_batch.return_value = {} if missing_metadata else { + 'AAA': {'currency': 'USD', 'first_trade_date': '2000-01-01'}} + loader = PortfolioDataLoader(repository) + with patch.object(endpoint, 'get_stock_repository', return_value=repository), \ + patch.object(endpoint.portfolio_manager_service, 'data_loader', loader), TestClient(app) as client: + response = client.post('/api/v1/backtest', json={ + 'portfolio': [{'symbol': 'AAA', 'amount': 1000, 'investment_type': 'dca', 'dca_frequency': 'monthly_1'}], + 'start_date': '2024-01-03', 'end_date': '2024-03-06', 'strategy': 'buy_hold_strategy', + 'commission': 0, 'rebalance_frequency': 'none', + 'include_stock_data': False, 'include_exchange_rates': False, 'include_benchmarks': False, + 'include_news': False, 'include_volatility_events': False, + }) + assert response.status_code == (422 if missing_metadata else 200), response.text + if not missing_metadata: + stats = response.json()['data']['portfolio_statistics'] + assert stats['Initial_Value'] == 2000 + assert stats['Final_Value'] == 2000 + assert stats['Total_Return'] == 0 diff --git a/backtest_be_fast/tests/unit/test_currency_converter.py b/backtest_be_fast/tests/unit/test_currency_converter.py index 0b606a44..79830b95 100644 --- a/backtest_be_fast/tests/unit/test_currency_converter.py +++ b/backtest_be_fast/tests/unit/test_currency_converter.py @@ -134,19 +134,12 @@ async def test_krw_converts_correctly_using_vectorized_operation( assert result['Close'].iloc[0] == pytest.approx(expected_close, rel=0.01) @pytest.mark.asyncio - async def test_unsupported_currency_returns_data_unchanged(self, converter, sample_krw_data): - """Test unsupported currency returns original data with warning""" + async def test_unsupported_currency_fails_explicitly(self, converter, sample_krw_data): converter.stock_repository.get_ticker_info.return_value = {'currency': 'XXX'} - - result = await converter.convert_dataframe_to_usd( - ticker='TEST', - data=sample_krw_data.copy(), - start_date='2023-01-01', - end_date='2023-01-10' - ) - - # Data should be unchanged - pd.testing.assert_frame_equal(result, sample_krw_data) + with pytest.raises(ValueError, match='XXX'): + await converter.convert_dataframe_to_usd( + ticker='TEST', data=sample_krw_data.copy(), + start_date='2023-01-01', end_date='2023-01-10') @pytest.mark.asyncio async def test_conversion_error_raises_instead_of_returning_unconverted_data( diff --git a/backtest_be_fast/tests/unit/test_metrics_definitions.py b/backtest_be_fast/tests/unit/test_metrics_definitions.py index defcbb56..3cfc78da 100644 --- a/backtest_be_fast/tests/unit/test_metrics_definitions.py +++ b/backtest_be_fast/tests/unit/test_metrics_definitions.py @@ -183,15 +183,17 @@ def test_dca_into_constant_growth_matches_price_cagr(self): # Total_Return은 총 납입 대비 손익률 그대로다 (시간가중 수익률보다 작다) assert 0 < stats['Total_Return'] < (price_ratio - 1) * 100 - def test_sharpe_uses_the_time_weighted_annual_return(self): + def test_sharpe_uses_mean_daily_time_weighted_return(self): frame = _zigzag_frame() request = _request([{ 'symbol': 'AAPL', 'amount': 1000.0, 'investment_type': 'dca', 'dca_frequency': 'monthly_1', }]) - stats = _run_buy_hold(request, {'AAPL': frame})['data']['portfolio_statistics'] - - assert stats['Sharpe_Ratio'] == pytest.approx(stats['Annual_Return'] / stats['Annual_Volatility']) + data = _run_buy_hold(request, {'AAPL': frame})['data'] + stats = data['portfolio_statistics'] + daily = pd.Series(data['daily_returns'], dtype=float) / 100 + expected = daily.mean() / daily.std() * np.sqrt(252) + assert stats['Sharpe_Ratio'] == pytest.approx(expected) class TestLumpSumAnnualReturnIsUnchanged: diff --git a/backtest_be_fast/tests/unit/test_portfolio_manager_batch4_fixes.py b/backtest_be_fast/tests/unit/test_portfolio_manager_batch4_fixes.py index 18e47eef..764f1214 100644 --- a/backtest_be_fast/tests/unit/test_portfolio_manager_batch4_fixes.py +++ b/backtest_be_fast/tests/unit/test_portfolio_manager_batch4_fixes.py @@ -315,10 +315,11 @@ async def test_non_overlapping_drawdowns_yield_true_mdd_and_sharpe_smaller_than_ assert stats['Max_Drawdown'] == pytest.approx(-10.0, abs=1e-6), ( f"Max_Drawdown={stats['Max_Drawdown']} (기대: -10.0, 가중평균 근사값은 -20.0)" ) - # 진짜 포트폴리오 샤프: 총수익률 0% -> 0이어야 함 (가중평균 근사값은 2.0) - assert stats['Sharpe_Ratio'] == pytest.approx(0.0, abs=1e-9), ( - f"Sharpe_Ratio={stats['Sharpe_Ratio']} (기대: 0.0, 가중평균 근사값은 2.0)" - ) + # Arithmetic daily excess-return Sharpe (risk-free rate = 0), not CAGR/vol. + daily = pd.Series(result['data']['daily_returns'], dtype=float) / 100 + expected_sharpe = daily.mean() / daily.std() * (252 ** 0.5) + assert stats['Sharpe_Ratio'] == pytest.approx(expected_sharpe) + assert stats['Sharpe_Ratio'] != pytest.approx(2.0) # not a weighted mean of asset Sharpes assert stats['Avg_Drawdown'] == pytest.approx(-10.0, abs=1e-6) assert stats['Peak_Value'] == pytest.approx(2000.0, abs=1e-6) # 실제 데이터 포인트는 5개(1/1~1/5) -- 달력일 차이(4)가 아니어야 함 diff --git a/backtest_be_fast/tests/unit/test_portfolio_manager_helpers.py b/backtest_be_fast/tests/unit/test_portfolio_manager_helpers.py index e4b21f5e..6faa2c82 100644 --- a/backtest_be_fast/tests/unit/test_portfolio_manager_helpers.py +++ b/backtest_be_fast/tests/unit/test_portfolio_manager_helpers.py @@ -239,13 +239,13 @@ def test_format_buy_hold_mode_returns_correct_structure(self): 'symbol': 'AAPL', 'weight': 0.4, 'amount': 4000.0, - 'return': 25.0 + 'return': 25.0, 'final_value': 5000.0, 'sharpe_ratio': 1.25, 'trades': 3 }, 'GOOGL': { 'symbol': 'GOOGL', 'weight': 0.6, 'amount': 6000.0, - 'return': 18.0 + 'return': 18.0, 'final_value': 7080.0, 'sharpe_ratio': 1.0, 'trades': 2 } } @@ -260,8 +260,8 @@ def test_format_buy_hold_mode_returns_correct_structure(self): # final_equity = amount + (amount * return / 100) = 4000 + 1000 = 5000 assert aapl_result['final_equity'] == 5000.0 assert aapl_result['total_return_pct'] == 25.0 - assert aapl_result['sharpe_ratio'] == 0.0 # Not calculated in buy_hold - assert aapl_result['trades'] == 1 + assert aapl_result['sharpe_ratio'] == 1.25 + assert aapl_result['trades'] == 3 # buy&hold 포지션은 청산된 거래가 없어 거래 승률을 정의할 수 없다. # 과거에는 수익이면 100, 아니면 0을 지어냈다 assert aapl_result['win_rate'] is None @@ -273,7 +273,7 @@ def test_format_buy_hold_mode_with_negative_return(self): 'symbol': 'TSLA', 'weight': 1.0, 'amount': 10000.0, - 'return': -10.0 + 'return': -10.0, 'final_value': 9000.0, 'sharpe_ratio': -1.0, 'trades': 1 } } @@ -293,13 +293,13 @@ def test_format_buy_hold_mode_with_cash(self): 'symbol': '', # Empty symbol for cash 'weight': 0.2, 'amount': 2000.0, - 'return': 0.0 + 'return': 0.0, 'final_value': 2000.0, 'sharpe_ratio': 0.0, 'trades': 0 }, 'AAPL': { 'symbol': 'AAPL', 'weight': 0.8, 'amount': 8000.0, - 'return': 15.0 + 'return': 15.0, 'final_value': 9200.0, 'sharpe_ratio': 1.0, 'trades': 1 } } @@ -320,11 +320,11 @@ def test_format_strategy_mode_without_trades_has_none_win_rate(self): 포트폴리오 Trade_Win_Rate(거래 없으면 None)와 같은 규칙이다.""" individual_returns = { 'NVDA': { - 'symbol': 'NVDA', 'weight': 0.5, 'amount': 5000.0, 'return': 0.0, + 'symbol': 'NVDA', 'weight': 0.5, 'amount': 5000.0, 'return': 0.0, 'final_value': 2000.0, 'sharpe_ratio': 0.0, 'trades': 0, 'final_value': 5000.0, 'trades': 0, 'win_rate': 0.0, }, 'CASH': { - 'symbol': 'CASH', 'weight': 0.5, 'amount': 5000.0, 'return': 0.0, + 'symbol': 'CASH', 'weight': 0.5, 'amount': 5000.0, 'return': 0.0, 'final_value': 2000.0, 'sharpe_ratio': 0.0, 'trades': 0, 'final_value': 5000.0, 'trades': 0, 'win_rate': 0.0, }, } diff --git a/backtest_be_fast/tests/unit/test_simulation_engine_edge_cases.py b/backtest_be_fast/tests/unit/test_simulation_engine_edge_cases.py index 7488ae91..c9cf251b 100644 --- a/backtest_be_fast/tests/unit/test_simulation_engine_edge_cases.py +++ b/backtest_be_fast/tests/unit/test_simulation_engine_edge_cases.py @@ -50,7 +50,7 @@ class TestMissingOrEmptyPriceData: """종목에 가격 데이터가 전혀 없을 때(심볼이 portfolio_data에서 아예 빠지거나, 존재해도 0행인 경우) 그 종목에 배정된 자본이 어떻게 되는지 문서화한다. - **현재 동작(수정하지 않고 있는 그대로 고정)**: 그 자본은 available_cash에도 + **실제 납입 원금 기준**: 그 자본은 available_cash에도 보유 주식 평가액에도 반영되지 않는다 -- 에러나 경고 없이 포트폴리오 평가액 계산에서 조용히 빠진다. pending_initial_keys가 매일 재시도하지만 가격이 끝내 나타나지 않으므로 영원히 미매수 상태로 남는다. @@ -74,9 +74,8 @@ def test_symbol_entirely_absent_from_portfolio_data_never_gets_invested(self): exchange_rates_by_currency={}, rebalance_frequency='none', commission=0.0, ) - # A의 $600만 투자되고(가격 $100 -> 6주), B의 $400은 어디에도 반영되지 - # 않아 포트폴리오 가치가 총투자금 대비 0.6배로 고정된다. - assert result['Portfolio_Value'].tolist() == pytest.approx([0.6] * len(result)) + # Only A is funded. Unexecuted B is not contributed capital or a loss. + assert result['Portfolio_Value'].tolist() == pytest.approx([1.0] * len(result)) assert result.attrs['total_trades'] == 1 # A만 매수됨 def test_empty_dataframe_for_symbol_behaves_same_as_missing_symbol(self): @@ -99,7 +98,7 @@ def test_empty_dataframe_for_symbol_behaves_same_as_missing_symbol(self): exchange_rates_by_currency={}, rebalance_frequency='none', commission=0.0, ) - assert result['Portfolio_Value'].tolist() == pytest.approx([0.6] * len(result)) + assert result['Portfolio_Value'].tolist() == pytest.approx([1.0] * len(result)) assert result.attrs['total_trades'] == 1 diff --git a/backtest_be_fast/tests/unit/test_yfinance_repository_news_and_info.py b/backtest_be_fast/tests/unit/test_yfinance_repository_news_and_info.py index d745bf28..611849eb 100644 --- a/backtest_be_fast/tests/unit/test_yfinance_repository_news_and_info.py +++ b/backtest_be_fast/tests/unit/test_yfinance_repository_news_and_info.py @@ -97,7 +97,7 @@ def test_rows_are_mapped_and_missing_tickers_get_defaults(self, repository, monk "symbol": "005930.KS", "currency": "KRW", "company_name": "005930.KS", "exchange": "Unknown", "first_trade_date": None, } - default = lambda t: {"symbol": t, "currency": "USD", "company_name": t, + default = lambda t: {"symbol": t, "currency": None, "company_name": t, "exchange": "Unknown", "first_trade_date": None} assert result["BROKEN"] == default("BROKEN") assert result["NULLINFO"] == default("NULLINFO") @@ -117,13 +117,13 @@ def test_empty_input_does_not_touch_db(self, repository, monkeypatch): def test_db_failure_returns_defaults_without_first_trade_date(self, repository, monkeypatch): _use(repository, monkeypatch, _Engine(broken=True)) assert repository.get_ticker_info_batch_from_db(["aapl"]) == { - "AAPL": {"symbol": "AAPL", "currency": "USD", "company_name": "AAPL", + "AAPL": {"symbol": "AAPL", "currency": None, "company_name": "AAPL", "exchange": "Unknown"}, } class TestTickerInfoSingleDefaults: - DEFAULT = {"symbol": "AAPL", "currency": "USD", "company_name": "AAPL", + DEFAULT = {"symbol": "AAPL", "currency": None, "company_name": "AAPL", "exchange": "Unknown", "first_trade_date": None} def test_unknown_ticker_returns_default(self, repository, monkeypatch): diff --git a/docs/reviews/calculation-performance-2026-10-25.md b/docs/reviews/calculation-performance-2026-10-25.md new file mode 100644 index 00000000..68099320 --- /dev/null +++ b/docs/reviews/calculation-performance-2026-10-25.md @@ -0,0 +1,84 @@ +# 계산 정확성 수정과 성능 검증 — 2026-10-25 + +기준 버전은 BE `e0fbfe75`, 콘솔 `4755d843`이다. 아래 반례를 먼저 재현한 뒤 +실제 계산 경로를 수정하고 독립적인 회귀 테스트로 확인했다. + +## 수정한 문제 + +| 문제 | 수정 전 재현 | 수정 결과 | +|---|---|---| +| 휴장일 뒤 DCA 요일 이동 | 1/3 시작, 2/7 휴장, 3/6 종료: 고정가인데 -33.33% | 1/3·2/8·3/6 실제 매수, 원금 3000, 수익률 0% | +| 마지막 예정일에 매수 불가 | 3/6 휴장: 실제 2000 납입인데 원금 3000 | 실제 원금·평가금 모두 2000, 수익률 0% | +| 시작일 휴장 | 2024년 월 적립: 원금 12000, 실제 11000, -8.33% | 최초 1/2 매수 뒤 원래 첫 월요일 일정 유지, 12000, 0% | +| 종목별 수수료 누락 | 고정가 1000·수수료 1%: 전체 990, 종목별 1000 | 전체·종목별 모두 990, -1% | +| 종목별 환율 누락 | KRW 고정가·환율 1000→2000: 전체 500, 종목별 1000 | 전체·종목별 모두 500, -50% | +| 리밸런싱 결과 불일치 | 주간 두 종목 사례: 전체 5062.5, 개별 합 8500 | 개별 최종 평가액 합도 5062.5 | +| 통화 정보 누락 | KRW 시세가 USD로 간주되어 잘못된 HTTP 200 | 실제 POST에서 422로 거부 | +| 보합일 하락 연속 집계 | 하락일 0일, 연속 하락 65일 | 연속 하락 0일 | +| Sharpe 정의 | CAGR/변동성으로 계산 | 일간 초과수익률 평균/표준편차 × √252 | +| 중복 외부 검증·가짜 시간 | 캐시가 있어도 Yahoo `.info` 조회, 실행 시간 0.5 고정 | 캐시 우선 시세 로더로 존재 확인, 실제 소요 시간 | + +납입과 체결의 의미는 다음과 같다. + +- DCA 입력 금액은 회당 예산이다. 실제 시세가 있는 날 체결하면서 납입된 것으로 본다. + 휴장 중에는 같은 예정 회차를 보류하며, 종료일까지 체결되지 않은 예산은 원금에 넣지 않는다. +- DCA와 리밸런싱은 시작일의 N번째 요일 달력을 유지한다. 실제 체결 지연이 이후 일정을 + 바꾸지 않는다. 밀린 납입이 여러 회차면 다음 실제 관측일에 체결한다. +- 종목별 평가액·손익·거래 로그는 전체 시뮬레이션의 USD 기록을 재사용한다. + 리밸런싱으로 다른 자산에서 넘어온 돈을 투자 수익으로 세지 않는다. +- `Total_Return`은 실제 총 납입액 대비 순손익, `Annual_Return`은 납입을 제외한 TWR CAGR이다. + `Initial_Value`는 이름을 유지하되 실제 총 납입액이다. 콘솔 리포트는 ‘총 납입금’으로 표시한다. +- Sharpe는 무위험 수익률 0%, 252거래일을 가정한다. 변동성이 사실상 0이면 0을 반환하는 + 기존 정책을 유지한다. [원 정의](https://web.stanford.edu/~wfsharpe/art/sr/sr.htm). +- 주가의 과거 값만 ffill한다. 환율도 과거 관측값만 사용하며, 누락·0·음수·무한대는 + 동등 환율 1로 대체하지 않는다. 통화 정보가 없으면 USD로 추정하지 않는다. +- 기존 기술 전략의 진입 수수료 모델(`spread`, `finalize_trades`)은 유지했다. + +## API와 콘솔 영향 + +계산 POST 하나와 조회 GET 네 개, 요청 필드와 인증 헤더는 유지한다. Turnstile 검증, +슬롯 제한, 취소 전파, 캐시·DB 구조를 변경하지 않았다. DB 마이그레이션이나 새 시크릿은 없다. + +`individual_returns`에는 실제 `final_value`, `trades`, `sharpe_ratio`가 추가된다. +기존 `individual_results.final_equity`는 별도의 가상 재계산 대신 실제 종목 평가액이다. +가격은 USD로 통일하고 `start_price`는 실제 BUY 수량으로 가중한 평균 매수가다. +DCA 회차와 구성 금액은 실제 실행·납입 기준이다. 보합 연속일, Sharpe, 수수료·환율·휴장 +영향을 받는 과거 결과는 의도적으로 달라진다. 나머지 일시불 평가금·기존 주요 수익률은 유지된다. + +## 로컬 Docker 검증 + +- 기존 오류 8개를 수정 전에 실패시켰다. 추가한 독립 회귀 테스트는 총 21개다. +- BE: 단위 579개 + HTTP golden master 1개 통과. 통계의 의도된 차이를 검토하고 + 응답 스냅샷을 명시적으로 재생성한 뒤 재생성 옵션 없이 다시 통과했다. +- 콘솔: 367개, ESLint, 앱/테스트 TypeScript 검사, runtime 빌드 통과. +- 의존성 감사에 예외를 추가하지 않았다. initdb와 Alembic 스키마 정합성 통과. +- 별도 MySQL·BE·FE 컨테이너의 실제 HTTP에서 6개 전략, 휴장일 DCA 두 사례, + USD·KRW·현금+리밸런싱, 개별 평가액/손익 합계 정합성을 확인했다. +- 네 GET 병렬 조회·자료별 필드 분리·현금 1500 보존·이전 aggregate GET 제거도 확인했다. +- 실제 로컬 웹 입력으로 고정가·수수료 0·휴장일 DCA를 실행해 수익률 0%, 매수 3회를 확인했다. +- 로컬 전용 합성 데이터만 사용했다. 계산 런타임의 인증 비활성화는 로컬 개발 설정이며, + 운영의 Turnstile 인증을 끄거나 운영 비밀키를 테스트 이미지에 넣지 않았다. + +## 성능 비교 + +동일 호스트에서 이전 이미지와 수정 이미지를 순차 실행했다. 각 컨테이너는 linux/amd64, +2 CPU·2GiB·네트워크 없음이며, 각 사례 3회 중앙값이다. 합성 OHLC와 USD 메타데이터만 +주입하고 실제 전략·시뮬레이션·응답 조립·JSON 호환 변환을 실행했다. +DB·Yahoo 네트워크·Turnstile·HTTP 전송 시간과 운영 동시 처리 용량을 측정한 값은 아니다. + +| 사례 | 이전 | 수정 | 시간 감소 | +|---|---:|---:|---:| +| 1종목 · 1년 · 일시불 | 0.0310s | 0.0272s | 12.2% | +| 20종목 · 1년 · 일시불 | 0.1197s | 0.0937s | 21.7% | +| 20종목 · 10년 · 일시불 | 1.0905s | 0.7618s | 30.1% | +| 20종목 · 10년 · 월 DCA+리밸런싱 | 2.8886s | 1.7344s | 40.0% | +| 20종목 · 10년 · SMA | 2.5186s | 1.0879s | 56.8% | + +최대 프로세스 RSS는 이전 215.9MiB, 수정 228.5MiB였다. +종목별 손익 원장에 따른 메모리 증가를 포함한 측정이다. 최대 사례 응답은 여전히 약 +3.7MB(JSON)라 전송·브라우저 렌더링 비용은 남는다. 이번 변경은 응답용 DCA 재실행 제거, +사전 정렬 배열의 위치 조회, 예정일 재사용, 전략 평가 곡선의 행별 `iterrows` 제거에 한정했다. +큐·워커·캐시 계층이나 외부 의존성을 추가하지 않았다. + +운영 반영은 feature → dev → main 검증과 기존 digest 배포를 사용한다. +배포 후 실제 위젯을 통과한 계산, 무토큰/위조 토큰 거부, 네 GET, readiness를 확인한다.