From c314da44aa7fbec1ffbbcd6c0c68fdb86b4f5231 Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sat, 24 Oct 2026 22:20:23 +0900 Subject: [PATCH] chore(infra): upgrade runtime and verify managed MySQL TLS --- .env.example | 1 + .env.production.example | 2 + .github/workflows/cicd.yml | 18 +- HISTORY.md | 2 + README.md | 6 +- backtest_be_fast/Dockerfile | 14 +- backtest_be_fast/Dockerfile.dev | 2 +- .../app/services/backtest_engine.py | 5 +- .../app/services/database/database_config.py | 7 +- backtest_be_fast/requirements-test.lock.txt | 93 +++-- backtest_be_fast/requirements-test.txt | 14 +- backtest_be_fast/requirements.lock.txt | 119 +++--- backtest_be_fast/requirements.txt | 22 +- .../tests/unit/fixtures/api-contract.json | 338 ++++++++++++++++++ .../tests/unit/test_api_contract.py | 26 ++ .../tests/unit/test_backtest_engine.py | 2 +- .../test_backtest_engine_volatility_key.py | 4 +- .../tests/unit/test_bollinger_strategy.py | 6 +- .../unit/test_database_config_logging.py | 9 + .../tests/unit/test_ema_strategy.py | 4 +- .../tests/unit/test_macd_strategy.py | 4 +- .../tests/unit/test_rsi_strategy.py | 8 +- .../tests/unit/test_sma_strategy.py | 4 +- compose.dev.yaml | 2 +- compose.yaml | 2 + scripts/audit-deps.sh | 9 +- scripts/check-schema-parity.sh | 8 +- scripts/deploy.sh | 5 +- 28 files changed, 564 insertions(+), 172 deletions(-) create mode 100644 backtest_be_fast/tests/unit/fixtures/api-contract.json create mode 100644 backtest_be_fast/tests/unit/test_api_contract.py diff --git a/.env.example b/.env.example index 5201a2ae..06f5d167 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,7 @@ DATABASE_USER=appuser DATABASE_PASSWORD=appuser123 DATABASE_NAME=stock_data_cache # DATABASE_URL이 있으면 위 개별 설정보다 우선한다. +# 운영 TLS: ssl_ca=/app/tls/oracle-mysql-ca.pem, ssl_check_hostname=false DATABASE_URL=mysql+pymysql://appuser:appuser123@mysql:3306/stock_data_cache?charset=utf8mb4 # mysql 컨테이너 초기화용. 위 DATABASE_USER/DATABASE_PASSWORD와 일치시켜야 한다. diff --git a/.env.production.example b/.env.production.example index 96d9137a..4437b129 100644 --- a/.env.production.example +++ b/.env.production.example @@ -12,4 +12,6 @@ SECRET_KEY=replace-on-server NAVER_CLIENT_ID= NAVER_CLIENT_SECRET= # DATABASE_URL, when present, overrides the individual DATABASE_* fields. +# Managed MySQL: charset=utf8mb4&ssl_ca=/app/tls/oracle-mysql-ca.pem&ssl_check_hostname=false +DATABASE_URL= # VITE_* and frontend proxy settings are not backend runtime credentials. diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c028a26a..710a120e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-24.04-arm timeout-minutes: 40 steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Test in Docker @@ -42,16 +42,16 @@ jobs: outputs: image: ghcr.io/kyj0503/backtest@${{ steps.build.outputs.digest }} steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 id: build with: context: ./backtest_be_fast @@ -80,17 +80,17 @@ jobs: packages: read id-token: write steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Connect to OCI through Tailscale - uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4 + uses: tailscale/github-action@99a8333667fff04fdd6cffb46dfd409f8329a7bf # v4.3.0 with: oauth-client-id: ${{ vars.TS_CLIENT_ID }} audience: ${{ vars.TS_AUDIENCE }} tags: tag:jandi-ci - version: '1.104.1' - sha256sum: 108d1d96ecf410d305571e173516f27038f870919a9b89c914a167c6d33a4528 + version: '1.104.2' + sha256sum: 512f613ed7d04fc7282f7dc5bd76aa105aae28168fbb9357eb749ebcbc29f0ae args: --accept-dns=false retry: '1' ping: ${{ vars.OCI_HOST }} diff --git a/HISTORY.md b/HISTORY.md index f9784c48..9bf74859 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -395,3 +395,5 @@ docker compose -f compose.dev.yaml exec -T backtest-fe npm run build - 문서 클린: 버전 표기 전면 최신(구버전 문자열 0건), Docker 온보딩 견실, CLAUDE.md 제약 검증표 Zustand 1건 제외 전부 VERIFIED(기준선 141/113 정적 재계산 일치). - 이번 세션 추가 검증: lint 경고 정확히 3개 실측(컨테이너), `pool_config.py` 40+80 확인, `TICKER_POPULARITY_TOTAL.labels(ticker=…)` 확인, jsdom/patch-package 미사용 확인, 현금 이름 필드 검증 순서 버그 확인(Codex 옳음 — 이전 감사의 "현금 심볼 검증 면제" 서술은 필드 순서 때문에 실제로는 무효), `nginx.prod.conf` trailing slash·SPA fallback 확인. - Codex 항목 중 미재현 잔여: BE 커버리지 42%(측정치 인용), 요청 한도 부재의 실부하 영향(P2-16), 캐시 DataFrame 공유 변경(P3-22), UI 숨김 옵션 payload 잔류(P2-13). + +- 2026-10-24: Python 3.15 / backtesting.py 0.6.6 업그레이드. 기존 단일 수수료 적용과 마지막 거래 청산을 유지. MySQL CA 검증용 마운트 및 URL 로그 비밀번호 마스킹 수정. 단위 504개와 기존 golden E2E 통과. diff --git a/README.md b/README.md index da76e997..c41afa1d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## 구성 -- `backtest_be_fast/`: Python 3.11, FastAPI, SQLAlchemy, pandas, backtesting.py 0.3.3. +- `backtest_be_fast/`: Python 3.15, FastAPI, SQLAlchemy, pandas, backtesting.py 0.6.6. - `database/`: MySQL 스키마와 마이그레이션 운영 안내. - `compose.dev.yaml`: 로컬 백엔드·MySQL 개발 환경. - `compose.dev-prod.yaml`: 실제 런타임 이미지의 로컬 워커 모드 검증. @@ -34,7 +34,7 @@ docker build --target runtime ./backtest_be_fast ``` 테스트는 Docker에서 실행합니다. 스키마 정합성 검사는 임시 MySQL만 사용하며 운영 DB를 변경하지 않습니다. -CI 취약점 검사는 기존 bokeh 예외 근거를 유지합니다. 신규 차단 취약점을 무시하지 않습니다. +CI 취약점 검사는 알려진 취약점을 예외 없이 차단합니다. ## GitHub Actions 배포 @@ -55,6 +55,8 @@ CI 취약점 검사는 기존 bokeh 예외 근거를 유지합니다. 신규 차 ## DB 운영 앱 배포에서 운영 DB 마이그레이션을 자동 실행하지 않습니다. +OCI 관리형 MySQL 26.10을 사용하며, 전용 CA를 검증하는 TLS 연결을 사용합니다. +CA 파일은 `/opt/backtest/production/tls/oracle-mysql-ca.pem`에 두고 컨테이너의 `/app/tls`에 읽기 전용으로 마운트합니다. 스키마 변경은 `database/schema.sql`과 `backtest_be_fast/alembic/`를 함께 갱신하고 검증합니다. 기존 DB의 baseline, Alembic 적용, MySQL 버전 변경은 [database/README.md](database/README.md)를 따릅니다. diff --git a/backtest_be_fast/Dockerfile b/backtest_be_fast/Dockerfile index 3dca26dd..b82245d9 100644 --- a/backtest_be_fast/Dockerfile +++ b/backtest_be_fast/Dockerfile @@ -1,14 +1,14 @@ # syntax=docker/dockerfile:1 -# Python 3.11 슬림 이미지 사용 +# Python 3.15 슬림 이미지 사용 # # 3-스테이지 구성: # builder - 컴파일 툴체인(gcc/g++/cargo/rustc 등) + venv 생성 + requirements.txt만 설치. # 최종 이미지에는 포함되지 않는다. # test - builder에서 갈라져 나와 requirements-test.txt를 추가 설치하고 pytest를 실행. # 배포 전 테스트 단계 전용 (아래 설명 참고). -# runtime - 클린 python:3.11-slim 위에 /opt/venv만 복사. 컴파일러도, 테스트 +# runtime - 클린 python:3.15-slim-trixie 위에 /opt/venv만 복사. 컴파일러도, 테스트 # 의존성도 없다. non-root 사용자로 실행된다. -FROM python:3.11-slim AS builder +FROM python:3.15-slim-trixie AS builder WORKDIR /app @@ -58,7 +58,7 @@ COPY tests ./tests COPY scripts ./scripts COPY pytest.ini ./ -RUN pytest tests/unit -q +RUN pytest tests/unit -q && pytest tests/e2e -m e2e -q # audit: 의존성 취약점 감사 (CI 전용) # @@ -69,7 +69,7 @@ RUN pytest tests/unit -q # 바인드 마운트(-v)가 아니라 빌드 컨텍스트로 파일을 받는 이유: Jenkins가 # 컨테이너 안에서 호스트 Docker 데몬에 붙어 sibling 컨테이너를 띄우는 구조라 # -v의 소스 경로가 호스트 기준으로 해석되어 파일을 찾지 못한다. -FROM python:3.11-slim AS audit +FROM python:3.15-slim-trixie AS audit WORKDIR /app COPY requirements.lock.txt . @@ -86,8 +86,8 @@ RUN pip install -q pip-audit && \ # runtime: 실제 배포 이미지 (마지막 스테이지 = 기본 빌드 타깃) # # builder의 컴파일러/헤더, test의 pytest 등 테스트 의존성은 여기 포함되지 않는다. -# 클린한 python:3.11-slim 위에 venv(/opt/venv)만 복사하고 non-root 사용자로 실행한다. -FROM python:3.11-slim AS runtime +# 클린한 python:3.15-slim-trixie 위에 venv(/opt/venv)만 복사하고 non-root 사용자로 실행한다. +FROM python:3.15-slim-trixie AS runtime WORKDIR /app diff --git a/backtest_be_fast/Dockerfile.dev b/backtest_be_fast/Dockerfile.dev index 77cf92d5..e3abb475 100644 --- a/backtest_be_fast/Dockerfile.dev +++ b/backtest_be_fast/Dockerfile.dev @@ -1,5 +1,5 @@ # Development Dockerfile for Backend -FROM python:3.11-slim +FROM python:3.15-slim-trixie WORKDIR /app diff --git a/backtest_be_fast/app/services/backtest_engine.py b/backtest_be_fast/app/services/backtest_engine.py index 757b6bbf..0f013e19 100644 --- a/backtest_be_fast/app/services/backtest_engine.py +++ b/backtest_be_fast/app/services/backtest_engine.py @@ -71,7 +71,10 @@ async def run_backtest(self, request: BacktestRequest) -> BacktestResult: data, strategy_class, cash=request.initial_cash, - commission=request.commission, + # 기존 진입 시 1회 수수료 모델 유지 + spread=request.commission, + commission=0.0, + finalize_trades=True, ) self.logger.debug("백테스트 객체 생성 완료") diff --git a/backtest_be_fast/app/services/database/database_config.py b/backtest_be_fast/app/services/database/database_config.py index 0aec8875..6cccd809 100644 --- a/backtest_be_fast/app/services/database/database_config.py +++ b/backtest_be_fast/app/services/database/database_config.py @@ -125,11 +125,10 @@ def get_url(self) -> str: def get_masked_url(self) -> str: """로깅용 마스킹된 URL을 반환합니다 (비밀번호 숨김).""" try: - if self.database_password and isinstance(self.database_url, str): - return self.database_url.replace(self.database_password, "***") + from sqlalchemy.engine import make_url + return make_url(self.database_url).render_as_string(hide_password=True) except Exception: - pass - return self.database_url + return "" def get_components(self) -> dict: """연결 설정 요소를 딕셔너리로 반환합니다.""" diff --git a/backtest_be_fast/requirements-test.lock.txt b/backtest_be_fast/requirements-test.lock.txt index c61437ad..cb177faf 100644 --- a/backtest_be_fast/requirements-test.lock.txt +++ b/backtest_be_fast/requirements-test.lock.txt @@ -1,25 +1,70 @@ -# P2-22: requirements-test.txt의 완전 고정 락. requirements.lock.txt 위에 -# "얹어서" 설치되는 것을 전제로 하며 (Dockerfile의 test 스테이지는 builder를 -# FROM하고, Dockerfile.dev는 requirements.lock.txt 설치 후 이 파일을 설치), -# 그래서 requirements.lock.txt에 이미 있는 패키지는 여기 다시 나열하지 않는다. -# -# 생성 방법은 requirements.lock.txt와 동일 (같은 pip freeze 출력에서, 어떤 -# 설치된 패키지도 필요로 하지 않는/오직 pytest 계열만 필요로 하는 패키지만 -# `pip show`의 Required-by로 골라냈다). httpx/httpcore/sniffio는 FastAPI -# TestClient 등 테스트에서만 쓰이고 애플리케이션 코드 경로에는 전혀 -# 등장하지 않는다 (requirements.lock.txt의 anyio/starlette 쪽에는 sniffio가 -# 필요하지 않음을 pip show로 확인했다). - -coverage==7.15.2 -factory-boy==3.3.0 -Faker==22.6.0 -freezegun==1.4.0 +alembic==1.20.1 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +backtesting==0.6.6 +beautifulsoup4==4.15.0 +bokeh==3.10.1 +cachetools==7.2.4 +certifi==2026.10.23 +cffi==2.1.1 +charset-normalizer==3.5.2 +click==8.5.0 +coverage==7.17.0 +cryptography==50.0.2 +curl_cffi==0.16.3 +factory_boy==3.3.3 +Faker==40.43.0 +fastapi==0.162.0 +freezegun==1.5.5 +h11==0.16.0 httpcore==1.0.9 -httpx==0.26.0 -iniconfig==2.3.0 -pluggy==1.6.0 -pytest==8.4.2 -pytest-asyncio==0.24.0 -pytest-cov==4.1.0 -pytest-mock==3.12.0 -sniffio==1.3.1 +httpx==0.28.1 +idna==3.20 +iniconfig==2.3.1 +Jinja2==3.1.6 +lxml==6.1.3 +Mako==1.4.3 +MarkupSafe==3.0.4 +multitasking==0.0.13 +narwhals==2.27.1 +numpy==2.5.4 +opentelemetry-api==1.45.1 +packaging==26.3 +pandas==3.1.0 +peewee==4.5.3 +pillow==13.0.0 +platformdirs==4.13.5 +pluggy==1.7.0 +prometheus-fastapi-instrumentator==8.1.0 +prometheus_client==0.26.0 +protobuf==7.36.2 +pycparser==3.12 +pydantic==2.14.1 +pydantic-settings==2.16.1 +pydantic_core==2.50.1 +Pygments==2.21.0 +PyMySQL==1.2.3 +pytest==9.1.1 +pytest-asyncio==1.4.0 +pytest-cov==7.1.0 +pytest-mock==3.17.0 +python-dateutil==3.0.0 +python-dotenv==1.2.4 +python-multipart==0.0.32 +pytz==2026.5 +PyYAML==6.0.3 +requests==2.34.2 +six==1.17.0 +soupsieve==3.0.4 +SQLAlchemy==2.1.6 +starlette==1.9.0 +tornado==6.6 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +tzdata==2026.5 +urllib3==2.8.0 +uvicorn==0.54.0 +websockets==17.3 +xyzservices==2026.9.1 +yfinance==1.7.0 diff --git a/backtest_be_fast/requirements-test.txt b/backtest_be_fast/requirements-test.txt index d584a4f4..ca7bf0f3 100644 --- a/backtest_be_fast/requirements-test.txt +++ b/backtest_be_fast/requirements-test.txt @@ -4,10 +4,10 @@ # requirements-test.lock.txt(고정본, requirements.lock.txt 위에 얹어 설치)를 # 설치한다. 재생성 방법은 requirements.lock.txt 상단 주석 참고. pytest>=8.2.0 -pytest-asyncio==0.24.0 -pytest-cov==4.1.0 -pytest-mock==3.12.0 -httpx==0.26.0 -faker==22.6.0 -factory-boy==3.3.0 -freezegun==1.4.0 +pytest-asyncio==1.4.0 +pytest-cov==7.1.0 +pytest-mock==3.17.0 +httpx==0.28.1 +faker==40.43.0 +factory-boy==3.3.3 +freezegun==1.5.5 diff --git a/backtest_be_fast/requirements.lock.txt b/backtest_be_fast/requirements.lock.txt index 9e3a98ae..fc4ff140 100644 --- a/backtest_be_fast/requirements.lock.txt +++ b/backtest_be_fast/requirements.lock.txt @@ -1,90 +1,57 @@ -# P2-22: 완전 고정 락 파일 — requirements.txt(사람이 편집하는 입력)의 모든 -# 직접/전이 의존성을 정확한 버전으로 고정한다. Dockerfile / Dockerfile.dev의 -# builder 스테이지는 requirements.txt가 아니라 이 파일을 설치한다. -# -# 생성 방법: pip-compile(pip-tools) 대신, 현재 동작 중인 개발 컨테이너의 -# `pip freeze`를 소스 오브 트루스로 사용했다 (189개 unit 테스트가 실제로 -# 통과하는 조합이므로) — -# docker compose -f compose.dev.yaml exec -T backtest-be-fast pip freeze -# 그 출력(런타임+테스트 의존성 혼재)에서, requirements.txt의 각 패키지가 -# 선언한 Requires를 importlib.metadata로 재귀 추적하고 `pip show`의 -# Required-by로 교차 검증해 "런타임에 실제로 필요한" 패키지만 추렸다 -# (테스트 전용 — pytest/httpx/faker/factory-boy/freezegun 및 이들의 전이 -# 의존성 — 은 requirements-test.lock.txt로 분리했다). -# -# frozendict는 예외: `pip show frozendict`의 Required-by가 비어 있어(현재 -# yfinance==1.5.2의 선언 메타데이터에는 없음) 정적 추적으로는 안 걸리지만, -# 개발 컨테이너에 실제로 설치돼 있고 yfinance 내부에서 비공식적으로 참조될 -# 가능성을 배제할 수 없어 안전 쪽으로 유지했다 (제거보다 여분 패키지 하나가 -# 훨씬 저렴하다). -# -# 재생성: `docker compose -f compose.dev.yaml exec -T backtest-be-fast pip freeze` -# 를 다시 떠서 requirements.txt에 선언된 패키지들의 의존성 클로저만 갱신한다. -# CRITICAL: backtesting==0.3.3 / bokeh==2.4.3 / numpy<2 는 backtesting.py 0.3.3의 -# 강제 요구사항이다 — 메이저 버전을 올리지 말 것 (AGENTS.md 제약 #2 참고). -# -# alembic/Mako는 requirements.txt에 추가된 alembic==1.18.5(P2-28)의 -# 의존성이며, 이미 고정된 SQLAlchemy==2.0.51과 충돌 없이 해석됨을 확인했다. - +alembic==1.20.1 annotated-doc==0.0.5 annotated-types==0.8.0 -anyio==4.14.2 -Backtesting==0.3.3 +anyio==4.15.1 +backtesting==0.6.6 beautifulsoup4==4.15.0 -bokeh==2.4.3 -cachetools==7.1.6 -certifi==2026.7.22 -cffi==2.1.0 -charset-normalizer==3.4.9 -click==8.4.2 -cryptography==50.0.0 -curl_cffi==0.15.0 -fastapi==0.141.1 -frozendict==2.4.7 -greenlet==3.5.4 +bokeh==3.10.1 +cachetools==7.2.4 +certifi==2026.10.23 +cffi==2.1.1 +charset-normalizer==3.5.2 +click==8.5.0 +cryptography==50.0.2 +curl_cffi==0.16.3 +fastapi==0.162.0 h11==0.16.0 -httptools==0.8.0 -idna==3.18 +idna==3.20 Jinja2==3.1.6 -Mako==1.3.12 -alembic==1.18.5 -markdown-it-py==4.2.0 -MarkupSafe==3.0.3 -mdurl==0.1.2 +lxml==6.1.3 +Mako==1.4.3 +MarkupSafe==3.0.4 multitasking==0.0.13 -numpy==1.26.4 -packaging==26.2 -pandas==2.3.3 -peewee==4.3.0 -pillow==12.3.0 -platformdirs==4.11.0 +narwhals==2.27.1 +numpy==2.5.4 +opentelemetry-api==1.45.1 +packaging==26.3 +pandas==3.1.0 +peewee==4.5.3 +pillow==13.0.0 +platformdirs==4.13.5 prometheus-fastapi-instrumentator==8.1.0 prometheus_client==0.26.0 -protobuf==7.35.1 -pycparser==3.0 -pydantic==2.13.4 -pydantic-settings==2.14.2 -pydantic_core==2.46.4 -Pygments==2.20.0 -PyMySQL==1.2.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.2.2 +protobuf==7.36.2 +pycparser==3.12 +pydantic==2.14.1 +pydantic-settings==2.16.1 +pydantic_core==2.50.1 +PyMySQL==1.2.3 +python-dateutil==3.0.0 +python-dotenv==1.2.4 python-multipart==0.0.32 -pytz==2026.3.post1 +pytz==2026.5 PyYAML==6.0.3 requests==2.34.2 -rich==15.0.0 six==1.17.0 -soupsieve==2.9.1 -SQLAlchemy==2.0.51 -starlette==1.3.1 -tornado==6.5.9 -typing-inspection==0.4.2 +soupsieve==3.0.4 +SQLAlchemy==2.1.6 +starlette==1.9.0 +tornado==6.6 +typing-inspection==0.4.4 typing_extensions==4.16.0 -tzdata==2026.3 +tzdata==2026.5 urllib3==2.8.0 -uvicorn==0.52.0 -uvloop==0.22.1 -watchfiles==1.2.0 -websockets==17.0.1 -yfinance==1.5.2 +uvicorn==0.54.0 +websockets==17.3 +xyzservices==2026.9.1 +yfinance==1.7.0 diff --git a/backtest_be_fast/requirements.txt b/backtest_be_fast/requirements.txt index e714ef42..e0ec2eab 100644 --- a/backtest_be_fast/requirements.txt +++ b/backtest_be_fast/requirements.txt @@ -2,22 +2,22 @@ # 아니라 requirements.lock.txt(전 의존성 고정본)를 설치한다. 여기를 바꾼 뒤에는 # `docker compose -f compose.dev.yaml exec -T backtest-be-fast pip freeze`로 # requirements.lock.txt를 재생성할 것 (락 파일 상단 주석에 방법 설명). -fastapi==0.141.1 -uvicorn[standard]==0.52.0 -pydantic==2.13.4 -pydantic-settings==2.14.2 +fastapi==0.162.0 +uvicorn==0.54.0 +pydantic==2.14.1 +pydantic-settings==2.16.1 # Monitoring prometheus-fastapi-instrumentator==8.1.0 -backtesting==0.3.3 -bokeh==2.4.3 -yfinance==1.5.2 +backtesting==0.6.6 +bokeh==3.10.1 +yfinance==1.7.0 requests==2.34.2 # Security floors for requests/bokeh transitive dependencies (2026-10-09 audit). urllib3>=2.8.0 tornado>=6.5.9 -pandas>=2.0.3,<3.0.0 -numpy<2.0.0 -python-dotenv==1.2.2 +pandas==3.1.0 +numpy==2.5.4 +python-dotenv==1.2.4 python-multipart==0.0.32 pytz>=2023.3 python-dateutil>=2.8.2 @@ -28,4 +28,4 @@ pymysql>=1.0.2 cryptography>=41.0.0 # P2-28: 스키마 마이그레이션 도구 (alembic/ 디렉터리 참고). SQLAlchemy 위에서 # 동작하며 앱과 같은 venv/이미지에서 `alembic upgrade head`로 실행된다. -alembic==1.18.5 +alembic==1.20.1 diff --git a/backtest_be_fast/tests/unit/fixtures/api-contract.json b/backtest_be_fast/tests/unit/fixtures/api-contract.json new file mode 100644 index 00000000..ae587a7f --- /dev/null +++ b/backtest_be_fast/tests/unit/fixtures/api-contract.json @@ -0,0 +1,338 @@ +{ + "paths": { + "/api/v1/backtest": { + "post": { + "operationId": "run_portfolio_backtest_api_v1_backtest_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PortfolioBacktestRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + } + }, + "429": {}, + "503": {}, + "504": {}, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/health": { + "get": { + "operationId": "health_check_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/health/ready": { + "get": { + "operationId": "readiness_check_health_ready_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + } + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "operationId": "metrics_metrics_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array" + } + }, + "type": "object" + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "string" + } + }, + "type": "object", + "required": [ + "status", + "timestamp", + "version" + ] + }, + "PortfolioBacktestRequest": { + "properties": { + "portfolio": { + "items": { + "$ref": "#/components/schemas/PortfolioStock" + }, + "type": "array", + "maxItems": 20, + "minItems": 1 + }, + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "commission": { + "type": "number", + "exclusiveMaximum": 0.1, + "minimum": 0.0, + "default": 0.002 + }, + "rebalance_frequency": { + "type": "string", + "default": "monthly_1" + }, + "strategy": { + "type": "string", + "default": "buy_hold_strategy" + }, + "strategy_params": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "include_stock_data": { + "type": "boolean", + "default": true + }, + "include_volatility_events": { + "type": "boolean", + "default": true + }, + "include_exchange_rates": { + "type": "boolean", + "default": true + }, + "include_benchmarks": { + "type": "boolean", + "default": true + }, + "include_news": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "required": [ + "portfolio", + "start_date", + "end_date" + ] + }, + "PortfolioStock": { + "properties": { + "symbol": { + "type": "string", + "maxLength": 10, + "minLength": 1 + }, + "amount": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ] + }, + "weight": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ] + }, + "investment_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "lump_sum" + }, + "dca_frequency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "monthly_1" + }, + "asset_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "stock" + }, + "custom_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "symbol" + ] + }, + "ReadinessResponse": { + "properties": { + "status": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "string" + }, + "checks": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object", + "required": [ + "status", + "timestamp", + "version", + "checks" + ] + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array" + }, + "msg": { + "type": "string" + }, + "type": { + "type": "string" + }, + "input": {}, + "ctx": { + "type": "object" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ] + } + } + } +} diff --git a/backtest_be_fast/tests/unit/test_api_contract.py b/backtest_be_fast/tests/unit/test_api_contract.py new file mode 100644 index 00000000..317767e6 --- /dev/null +++ b/backtest_be_fast/tests/unit/test_api_contract.py @@ -0,0 +1,26 @@ +"""Public request/response schemas captured before the infrastructure migration.""" +import json +from pathlib import Path +import pytest +from fastapi.testclient import TestClient +from app.main import app + +pytestmark = pytest.mark.unit +DOCUMENTATION = {"description", "summary", "example", "examples", "title", "tags", "externalDocs"} + +def _contract(value): + if isinstance(value, dict): + return {key: _contract(child) for key, child in value.items() if key not in DOCUMENTATION} + if isinstance(value, list): + return [_contract(child) for child in value] + return value + +def test_preserves_production_routes_requests_and_responses(): + expected = json.loads((Path(__file__).parent / "fixtures/api-contract.json").read_text(encoding="utf-8")) + with TestClient(app) as http: + app.openapi_schema = None + response = http.get("/api/v1/openapi.json") + assert response.status_code == 200 + actual = response.json() + for section in ("paths", "components"): + assert _contract(actual[section]) == expected[section] diff --git a/backtest_be_fast/tests/unit/test_backtest_engine.py b/backtest_be_fast/tests/unit/test_backtest_engine.py index b4f28097..4062d0fa 100644 --- a/backtest_be_fast/tests/unit/test_backtest_engine.py +++ b/backtest_be_fast/tests/unit/test_backtest_engine.py @@ -403,7 +403,7 @@ def next(self): elif len(self.data) == 30 and self.position: self.position.close() - stats = Backtest(data, BuyThenSell, cash=10000, commission=0.0).run() + stats = Backtest(data, BuyThenSell, cash=10000, commission=0.0, finalize_trades=True).run() assert stats['# Trades'] == 1 and stats['Win Rate [%]'] == 100.0 request = BacktestRequest( ticker='TSLA', start_date='2023-01-02', end_date='2023-02-24', diff --git a/backtest_be_fast/tests/unit/test_backtest_engine_volatility_key.py b/backtest_be_fast/tests/unit/test_backtest_engine_volatility_key.py index ead0b423..eb4f33a4 100644 --- a/backtest_be_fast/tests/unit/test_backtest_engine_volatility_key.py +++ b/backtest_be_fast/tests/unit/test_backtest_engine_volatility_key.py @@ -33,7 +33,7 @@ def next(self): if crossover(self.fast, self.slow): self.buy() - stats = Backtest(GOOG.iloc[:200], _Sma, cash=10_000, commission=0).run() + stats = Backtest(GOOG.iloc[:200], _Sma, cash=10_000, commission=0, finalize_trades=True).run() assert 'Volatility (Ann.) [%]' in stats.index assert isinstance(stats['Volatility (Ann.) [%]'], float) @@ -60,7 +60,7 @@ def next(self): self.buy() data = GOOG.iloc[:200] - stats = Backtest(data, _Sma, cash=10_000, commission=0).run() + stats = Backtest(data, _Sma, cash=10_000, commission=0, finalize_trades=True).run() assert stats['Volatility (Ann.) [%]'] > 0, "픽스처 전제: 변동성이 0보다 커야 의미 있는 검증" request = Mock() diff --git a/backtest_be_fast/tests/unit/test_bollinger_strategy.py b/backtest_be_fast/tests/unit/test_bollinger_strategy.py index 793deedb..80491c46 100644 --- a/backtest_be_fast/tests/unit/test_bollinger_strategy.py +++ b/backtest_be_fast/tests/unit/test_bollinger_strategy.py @@ -63,7 +63,7 @@ def test_req_bb_05_buy_signal_boundary(self, standard_setup, price_offset, expec data = p1 + [last_price] + extra_data df = self.create_fixture_data(data) - bt = Backtest(df, BollingerBandsStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, BollingerBandsStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(period=20, std_dev=2) if expected_trade: @@ -89,7 +89,7 @@ def test_req_bb_06_sell_signal_breakout(self, standard_setup): data = p1 + p2 + p3 + p4 df = self.create_fixture_data(data) - bt = Backtest(df, BollingerBandsStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, BollingerBandsStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(period=20, std_dev=2) trades = stats['_trades'] @@ -113,7 +113,7 @@ def test_req_bb_07_exit_at_sma(self): data = p1 + p2 + p3 df = self.create_fixture_data(data) - bt = Backtest(df, BollingerBandsStrategy, cash=100000, commission=0) + bt = Backtest(df, BollingerBandsStrategy, cash=100000, commission=0, finalize_trades=True) stats = bt.run(period=20, std_dev=2) trades = stats['_trades'] diff --git a/backtest_be_fast/tests/unit/test_database_config_logging.py b/backtest_be_fast/tests/unit/test_database_config_logging.py index 3b7489bd..b03e4c98 100644 --- a/backtest_be_fast/tests/unit/test_database_config_logging.py +++ b/backtest_be_fast/tests/unit/test_database_config_logging.py @@ -52,6 +52,15 @@ def test_log_info_does_not_leak_raw_password_to_stdout(self, config, capsys): class TestLogInfoStillLogsViaLogger: + def test_url_encoded_password_is_masked(self, config): + config.database_url = 'mysql+pymysql://user:secret%40value%21@db/test' + config.database_password = 'secret@value!' + assert config.get_masked_url() == 'mysql+pymysql://user:***@db/test' + + def test_invalid_url_is_not_logged(self, config): + config.database_url = 'not-a-url-with-secret' + assert config.get_masked_url() == '' + def test_log_info_logs_connection_metadata_via_logger(self, config, caplog): with caplog.at_level(logging.INFO, logger='app.services.database.database_config'): config.log_info() diff --git a/backtest_be_fast/tests/unit/test_ema_strategy.py b/backtest_be_fast/tests/unit/test_ema_strategy.py index a361e0a0..950fd0b8 100644 --- a/backtest_be_fast/tests/unit/test_ema_strategy.py +++ b/backtest_be_fast/tests/unit/test_ema_strategy.py @@ -44,7 +44,7 @@ def test_req_ema_02_golden_cross_buy(self, standard_setup): data = p1 + p2 df = self.create_fixture_data(data) - bt = Backtest(df, EmaStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, EmaStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(fast_window=5, slow_window=10) trades = stats['_trades'] @@ -65,7 +65,7 @@ def test_req_ema_03_dead_cross_sell(self, standard_setup): data = p0 + p1 + p2 df = self.create_fixture_data(data) - bt = Backtest(df, EmaStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, EmaStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(fast_window=5, slow_window=10) trades = stats['_trades'] diff --git a/backtest_be_fast/tests/unit/test_macd_strategy.py b/backtest_be_fast/tests/unit/test_macd_strategy.py index 5250eaf7..45809b29 100644 --- a/backtest_be_fast/tests/unit/test_macd_strategy.py +++ b/backtest_be_fast/tests/unit/test_macd_strategy.py @@ -39,7 +39,7 @@ def test_req_macd_03_buy_signal(self): data = p1 + p2 df = self.create_fixture_data(data) - bt = Backtest(df, MacdStrategy, cash=100000, commission=0) + bt = Backtest(df, MacdStrategy, cash=100000, commission=0, finalize_trades=True) stats = bt.run(fast_period=12, slow_period=26, signal_period=9) assert len(stats['_trades']) > 0, "MACD 골든크로스 매수" @@ -59,7 +59,7 @@ def test_req_macd_04_sell_signal(self): data = p0 + p1 + p2 df = self.create_fixture_data(data) - bt = Backtest(df, MacdStrategy, cash=100000, commission=0) + bt = Backtest(df, MacdStrategy, cash=100000, commission=0, finalize_trades=True) stats = bt.run() trades = stats['_trades'] diff --git a/backtest_be_fast/tests/unit/test_rsi_strategy.py b/backtest_be_fast/tests/unit/test_rsi_strategy.py index 99d0aec5..e3c3b553 100644 --- a/backtest_be_fast/tests/unit/test_rsi_strategy.py +++ b/backtest_be_fast/tests/unit/test_rsi_strategy.py @@ -58,7 +58,7 @@ def test_req_rsi_04_buy_signal_partition(self, standard_setup, rsi_value_scenari data = p1 + p2 df = self.create_fixture_data(data) - bt = Backtest(df, RsiStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, RsiStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(rsi_period=14) if expected_trade: @@ -83,7 +83,7 @@ def test_req_rsi_05_sell_signal_overbought(self, standard_setup): data = p1 + p2 + p3 df = self.create_fixture_data(data) - bt = Backtest(df, RsiStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, RsiStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(rsi_period=14) trades = stats['_trades'] @@ -100,7 +100,7 @@ def test_req_rsi_07_flat_data_no_error(self, standard_setup): data = [100] * 50 df = self.create_fixture_data(data) - bt = Backtest(df, RsiStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, RsiStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(rsi_period=14) # Should finish without error @@ -113,6 +113,6 @@ def test_rsi_calculation_boundary(self): data = [100] * 10 # < 14 df = self.create_fixture_data(data) - bt = Backtest(df, RsiStrategy, cash=10000, commission=0) + bt = Backtest(df, RsiStrategy, cash=10000, commission=0, finalize_trades=True) stats = bt.run(rsi_period=14) assert len(stats['_trades']) == 0 diff --git a/backtest_be_fast/tests/unit/test_sma_strategy.py b/backtest_be_fast/tests/unit/test_sma_strategy.py index 2c1242f9..fa57c417 100644 --- a/backtest_be_fast/tests/unit/test_sma_strategy.py +++ b/backtest_be_fast/tests/unit/test_sma_strategy.py @@ -54,7 +54,7 @@ def test_req_sma_02_golden_cross_buy(self, standard_setup): df = self.create_fixture_data(data) # Run with short=5, long=10 - bt = Backtest(df, SmaCrossStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, SmaCrossStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(short_window=5, long_window=10) trades = stats['_trades'] @@ -77,7 +77,7 @@ def test_sma_no_cross_no_trade(self, standard_setup): data = np.linspace(100, 200, 60).tolist() df = self.create_fixture_data(data) - bt = Backtest(df, SmaCrossStrategy, cash=standard_setup['cash'], commission=0) + bt = Backtest(df, SmaCrossStrategy, cash=standard_setup['cash'], commission=0, finalize_trades=True) stats = bt.run(short_window=5, long_window=10) trades = stats['_trades'] diff --git a/compose.dev.yaml b/compose.dev.yaml index 80d8b6f8..efedb1a2 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -44,7 +44,7 @@ services: # 8.4는 다음 LTS이며, schema.sql이 쓰는 8.0+ 문법(utf8mb4_0900_ai_ci, # CHECK 제약)이 그대로 유지된다 — 스키마 초기화 스크립트가 8.4에서도 # 깨끗이 적용됨을 일회성 컨테이너로 검증했다 (b4-E-report.md 참고). - image: mysql:8.4 + image: mysql:innovation@sha256:f51c9c01c16b0eb5d81bb70ab7e7fcb3b96abc6a54114be3a94e8e569cae7815 container_name: backtest-mysql-dev networks: - backtest-net diff --git a/compose.yaml b/compose.yaml index aa6bdd8c..7a7b9107 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,6 +16,8 @@ services: format: raw networks: - backend-net + volumes: + - /opt/backtest/production/tls:/app/tls:ro healthcheck: test: [CMD, curl, -fsS, 'http://localhost:8000/health'] interval: 10s diff --git a/scripts/audit-deps.sh b/scripts/audit-deps.sh index 182b6cfb..805d69e5 100755 --- a/scripts/audit-deps.sh +++ b/scripts/audit-deps.sh @@ -21,14 +21,7 @@ set -eu REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# --- 예외 목록 --------------------------------------------------------------- -# PYSEC-2026-1223 / CVE-2026-21883 (bokeh 2.4.3) -# bokeh 서버의 Origin 검증 우회. bokeh는 backtesting==0.3.3이 끌어오는 전이 -# 의존성이고(AGENTS.md 제약 2에 따라 의도적으로 고정), 이 앱은 .plot()을 -# 호출하지 않아 bokeh 서버를 띄우지 않는다. -# 재검토: backtesting 핀을 풀면 함께 제거할 것. -PIP_IGNORE="PYSEC-2026-1223" -# ----------------------------------------------------------------------------- +PIP_IGNORE="" audit_backend() { echo "== BE: pip-audit (예외: ${PIP_IGNORE:-없음}) ==" diff --git a/scripts/check-schema-parity.sh b/scripts/check-schema-parity.sh index 3aa9bbb0..144e465e 100755 --- a/scripts/check-schema-parity.sh +++ b/scripts/check-schema-parity.sh @@ -22,8 +22,8 @@ # 사용: scripts/check-schema-parity.sh # 환경변수(모두 선택): # DOCKER docker 명령 (기본: docker). 예: DOCKER=docker.exe -# MYSQL_IMAGE 비교에 쓸 MySQL 이미지 (기본: mysql:8.4, compose와 동일) -# PY_IMAGE Alembic 실행 이미지 (기본: python:3.11-slim, BE Dockerfile과 동일) +# MYSQL_IMAGE 비교에 쓸 MySQL 이미지 (기본: mysql:innovation, compose와 동일) +# PY_IMAGE Alembic 실행 이미지 (기본: python:3.15-slim-trixie, BE Dockerfile과 동일) # SCHEMA_SQL 비교할 initdb 스크립트 (기본: database/schema.sql) # PARITY_PREFIX 임시 리소스 이름 접두사 (기본: schema-parity-) # KEEP_DUMPS 비어 있지 않으면 비교에 쓴 덤프 디렉터리를 지우지 않는다 @@ -33,8 +33,8 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" BE_DIR="$REPO_ROOT/backtest_be_fast" DOCKER="${DOCKER:-docker}" -MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.4}" -PY_IMAGE="${PY_IMAGE:-python:3.11-slim}" +MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:innovation@sha256:f51c9c01c16b0eb5d81bb70ab7e7fcb3b96abc6a54114be3a94e8e569cae7815}" +PY_IMAGE="${PY_IMAGE:-python:3.15-slim-trixie}" SCHEMA_SQL="${SCHEMA_SQL:-$REPO_ROOT/database/schema.sql}" PREFIX="${PARITY_PREFIX:-schema-parity-$$}" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6eb13b8e..7e12f0d6 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -46,7 +46,10 @@ failed() { } trap cleanup EXIT trap failed ERR -if [[ "$app" == backtest ]]; then test -s "$root/.env"; fi +if [[ "$app" == backtest ]]; then + test -s "$root/.env" + test -s "$root/tls/oracle-mysql-ca.pem" +fi docker network inspect backend-net >/dev/null mkdir -m 700 "$release/.docker" export DOCKER_CONFIG="$release/.docker"