diff --git a/AGENTS.md b/AGENTS.md index e235f9d2..fc71a1f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ docker build --target test ./backtest_be_fast **DB schema:** `database/schema.sql` is the first-boot initdb script; schema changes go through Alembic (`backtest_be_fast/alembic/`). There are **no physical foreign keys** — cross-table references are logical, integrity is owned by the app (A-10). A DB created from schema.sql **before** revision `7b2e9c4f1a30` still has the old FK: baseline it with `alembic stamp d5c3763b29e6` then `alembic upgrade head` (plain `stamp head` would skip the FK drop). A DB created from the current schema.sql is already at head (`alembic stamp head`). Migrations are NOT run by the GitHub Actions pipeline or the runtime image — apply them manually. schema.sql and the Alembic head must stay identical (COMMENTs included): change both together and run `scripts/check-schema-parity.sh`. Keep the `SET NAMES utf8mb4;` at the top of schema.sql — the official image runs initdb with a latin1 client and would double-encode Korean COMMENTs. Upgrade/baseline procedures and the MySQL 8.0 → 8.4 notes live in `database/README.md`. -**API:** POST `/api/v1/backtest` calculates results; GET `/api/v1/market-data` independently loads reference data. The console disables all POST `include_*` flags, renders the calculation first, then loads/retries reference data without rerunning it. Legacy POST defaults remain unchanged. See `docs/architecture/api-boundaries.md`. Errors: `@handle_portfolio_errors` decorator on the calculation path. +**API:** POST `/api/v1/backtest` calculates results; four GETs (`/prices`, `/exchange-rates`, `/benchmarks`, `/news` under `/api/v1`) independently load reference data. The console disables all POST `include_*` flags, renders the calculation first, then loads/retries each resource without rerunning it. The old `/market-data` GET is replaced; legacy POST defaults remain unchanged. All reads share one bounded runner, not four independent capacity pools. See `docs/architecture/api-boundaries.md`. Errors: `@handle_portfolio_errors` decorator on the calculation path. ## Critical Constraints @@ -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 baseline (2026-09-27):** BE 492 unit tests + 12 integration + 1 e2e golden master (`pytest tests/e2e -m e2e`, no network/DB), FE 344 tests — all green. Any failure is a regression, not pre-existing noise. +- **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. - **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. @@ -92,7 +92,7 @@ docker build --target test ./backtest_be_fast GitHub Actions lives in `.github/workflows/cicd.yml`. PRs to main/dev and pushes to either branch run Docker tests, dependency audit, runtime build, and schema parity checks. Only main publishes an ARM64 `ghcr.io/kyj0503/backtest` image and deploys its immutable digest through Tailscale and pinned SSH. dev has no OCI deployment. Runtime credentials stay in `/opt/backtest/production/.env` and are never committed. -The dependency audit retains the documented bokeh exception in `scripts/audit-deps.sh`; do not weaken it. Deployment checks `/health/ready` after Docker liveness succeeds and restores the previous release on failure. CI does not automatically migrate a deployed DB. Shared gateway configuration is managed outside this repository at `/opt/gateway`. +The dependency audit has no exceptions in `scripts/audit-deps.sh`; do not weaken it. Deployment checks `/health/ready` after Docker liveness succeeds and restores the previous release on failure. CI does not automatically migrate a deployed DB. Shared gateway configuration is managed outside this repository at `/opt/gateway`. ## Commit Convention diff --git a/HISTORY.md b/HISTORY.md index 328649a4..18267ee2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,6 +9,28 @@ --- +## 2026-10-25 네 종류의 독립 조회 + +- **A-39**: 통합 GET `/api/v1/market-data`를 `/prices`, `/exchange-rates`, + `/benchmarks`, `/news` 네 GET으로 교체했다. 각 응답은 소유한 자료와 수집 상태만 + 반환한다. 기존 POST 요청·기본값·계산 필드는 유지한다. +- 기존 수집기를 재사용하되 뉴스·환율·벤치마크의 불필요한 종목 메타데이터 조회를 + 생략한다. 주가와 급등락은 동일 시세를 공유한다. 뉴스에는 기간을 받지 않고, + 취소 후 다음 종목을 조회하거나 캐시에 쓰지 않도록 취소 확인을 추가했다. +- 네 GET은 하나의 파일 슬롯 실행기를 공유하며 기본 전체·IP 상한을 각각 4로 맞췄다. + API마다 독립 풀을 만들어 총 조회량이 네 배로 늘어나는 구성을 피한다. +- 콘솔의 로딩·오류·빈 결과·재시도를 종류별로 분리했다. 함수형 상태 병합과 필드 + 허용 목록으로 병렬 응답끼리 덮어쓰지 않으며 재시도는 다른 요청을 취소하지 않는다. + 새 계산·화면 이탈 때 모든 이전 요청을 취소한다. 뉴스 설명도 최신 뉴스로 바로잡았다. +- Docker BE 529개·골든 E2E 1개, 콘솔 356개·lint·두 TypeScript 검사, + 예외 없는 두 의존성 감사와 runtime 빌드를 통과했다. 배포용 이미지·격리 MySQL의 + 합성 시세·뉴스로 네 병렬 조회, 기존 계산값·현금 1,500 보존을 확인했다. +- 실제 브라우저에서 네 GET에 503을 주고 하나씩 복구했다. 각 재시도는 그 GET만 + 1회 증가시키며 계산 POST는 네 번의 재시도 전후 4건으로 같았다. 운영 자격증명과 + 운영 DB는 사용하지 않았다. 이 검증은 계산 정확성 전반이나 운영 부하 보장이 아니다. +- 옛 GET을 호출하는 콘솔은 새 API와 호환되지 않으므로 운영 릴리스는 두 저장소를 + 함께 계획하고 이전 탭을 새로고침해야 한다. 현재 계약은 [API 경계](docs/architecture/api-boundaries.md)에 있다. + ## 2026-10-25 API 경계·게이트웨이 보완 - **A-38**: 콘솔의 계산 POST와 참고 자료 GET을 분리했다. 기존 POST 본문·기본값·응답 diff --git a/README.md b/README.md index c0acf4f8..fa9ad18f 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ CA 파일은 `/opt/backtest/production/tls/oracle-mysql-ca.pem`에 두고 컨테 `POST /api/v1/backtest`가 주요 API입니다. 최소 백테스트 기간은 30일입니다. -콘솔은 `include_*`를 끄고 계산 결과를 먼저 받은 뒤 `GET /api/v1/market-data`로 -참고 데이터를 조회합니다. 기존 통합 응답은 기본 옵션으로 계속 지원합니다. +콘솔은 `include_*`를 끄고 계산 결과를 먼저 받은 뒤 `/api/v1/prices`, `/exchange-rates`, +`/benchmarks`, `/news`를 각각 조회·재시도합니다. 기존 POST 통합 응답은 기본 옵션으로 계속 지원합니다. 조회·취소·실행 한도와 이전 절차는 [API 경계](docs/architecture/api-boundaries.md)를 참고하세요. 프론트엔드의 `MIN_BACKTEST_PERIOD_DAYS`와 백엔드 설정을 함께 유지합니다. CORS는 공통 Nginx에서 처리합니다. 서버 비밀값을 프론트엔드 `VITE_*` 변수에 넣지 않습니다. diff --git a/backtest_be_fast/app/api/v1/api.py b/backtest_be_fast/app/api/v1/api.py index e52e5f2a..50ea5c05 100644 --- a/backtest_be_fast/app/api/v1/api.py +++ b/backtest_be_fast/app/api/v1/api.py @@ -7,7 +7,7 @@ **엔드포인트**: - POST /api/v1/backtest: 계산 실행 (기존 include_* 통합 응답도 지원) -- GET /api/v1/market-data: 계산과 독립적인 주가·환율·벤치마크·뉴스 조회 +- GET /api/v1/prices, /exchange-rates, /benchmarks, /news: 독립 조회·재시도 - 전략 목록은 프론트엔드에서 관리 - 콘솔은 계산을 먼저 표시하고 참고 데이터를 별도로 조회한다. """ @@ -23,5 +23,5 @@ tags=["백테스팅"] ) -api_router.include_router(market_data.router, prefix="/market-data", tags=["시장 데이터"]) +api_router.include_router(market_data.router, tags=["시장 데이터"]) diff --git a/backtest_be_fast/app/api/v1/endpoints/market_data.py b/backtest_be_fast/app/api/v1/endpoints/market_data.py index 61080637..9bafdd6e 100644 --- a/backtest_be_fast/app/api/v1/endpoints/market_data.py +++ b/backtest_be_fast/app/api/v1/endpoints/market_data.py @@ -1,4 +1,4 @@ -"""Reference market data, independently retriable without another simulation.""" +"""Independently retriable reads sharing one bounded reference-data capacity.""" import asyncio from datetime import date from functools import lru_cache @@ -19,23 +19,36 @@ _running = Gauge('market_data_jobs_running', 'Active reference-data jobs', multiprocess_mode='livesum') _waiting = Gauge('market_data_jobs_waiting', 'Queued reference-data jobs', multiprocess_mode='livesum') +# Both price views share one fetch. A response can only update its own fields. +READ_FIELDS = { + 'prices': ('ticker_info', 'stock_data', 'volatility_events'), + 'exchange-rates': ('exchange_rates', 'exchange_stats'), + 'benchmarks': ('sp500_benchmark', 'nasdaq_benchmark'), + 'news': ('latest_news',), +} +READ_STATUSES = { + 'prices': ('ticker_info', 'stock_data', 'volatility_events'), + 'exchange-rates': ('exchange_rates',), + 'benchmarks': ('benchmarks',), + 'news': ('news',), +} -class MarketDataQuery(BaseModel): + +class SymbolsQuery(BaseModel): model_config = {'extra': 'forbid'} - symbols: list[str] = Field(default_factory=list, max_length=settings.max_portfolio_items) - start_date: date - end_date: date - include_stock_data: bool = True - include_volatility_events: bool = True - include_exchange_rates: bool = True - include_benchmarks: bool = True - include_news: bool = True + symbols: list[str] = Field(min_length=1, max_length=settings.max_portfolio_items) @field_validator('symbols') @classmethod def validate_symbols(cls, values): return list(dict.fromkeys(PortfolioStock(symbol=value).symbol for value in values)) + +class PeriodQuery(BaseModel): + model_config = {'extra': 'forbid'} + start_date: date + end_date: date + @model_validator(mode='after') def validate_dates(self): if self.end_date < self.start_date: @@ -47,48 +60,80 @@ def validate_dates(self): return self +class PricesQuery(PeriodQuery, SymbolsQuery): + pass + + +class NewsQuery(SymbolsQuery): + limit: int = Field(default=15, ge=1, le=20) + + @lru_cache(maxsize=1) def get_market_data_runner(): return BacktestJobRunner( slot_dir=settings.backtest_slot_dir + '-market-data', max_concurrent=settings.max_concurrent_market_data, - per_client=1, + per_client=4, queue_timeout=settings.market_data_queue_timeout_seconds, exec_timeout=settings.market_data_timeout_seconds, cancel_grace=settings.backtest_cancel_grace_seconds, - running_metric=_running, - waiting_metric=_waiting, - job_name='참고 데이터 조회', + running_metric=_running, waiting_metric=_waiting, job_name='참고 데이터 조회', ) -@router.get('', summary='주가·환율·벤치마크·뉴스 조회') -async def get_market_data(query: Annotated[MarketDataQuery, Query()], request: Request): +async def _read(section: str, request: Request, *, symbols=None, start_date=None, end_date=None, limit=15): async def collect(): data = await asyncio.to_thread( unified_data_service.collect_all_unified_data, - symbols=query.symbols, - start_date=query.start_date.isoformat(), - end_date=query.end_date.isoformat(), - include_news=query.include_news, - news_display_count=15, - include_stock_data=query.include_stock_data, - include_volatility_events=query.include_volatility_events, - include_exchange_rates=query.include_exchange_rates, - include_benchmarks=query.include_benchmarks, + symbols=symbols or [], + start_date=start_date.isoformat() if start_date else None, + end_date=end_date.isoformat() if end_date else None, + include_ticker_info=section == 'prices', + include_stock_data=section == 'prices', + include_volatility_events=section == 'prices', + include_exchange_rates=section == 'exchange-rates', + include_benchmarks=section == 'benchmarks', + include_news=section == 'news', + news_display_count=limit, timeout_seconds=settings.supplemental_data_timeout_seconds, ) - benchmark = data.get('sp500_benchmark', []) - data['sp500_total_return_pct'] = ( - unified_data_service.calculate_benchmark_return(benchmark) - if len(benchmark) >= 2 else None - ) - return {'status': 'success', 'data': recursive_serialize(data)} + result = {field: data[field] for field in READ_FIELDS[section]} + result['supplemental_status'] = { + name: data['supplemental_status'][name] for name in READ_STATUSES[section] + } + if section == 'benchmarks': + benchmark = result['sp500_benchmark'] + result['sp500_total_return_pct'] = ( + unified_data_service.calculate_benchmark_return(benchmark) + if len(benchmark) >= 2 else None + ) + return {'status': 'success', 'data': recursive_serialize(result)} try: return await get_market_data_runner().run( - collect, client_key=_client_key(request), - is_disconnected=request.is_disconnected, label='market-data', + collect, client_key=_client_key(request), is_disconnected=request.is_disconnected, + label=section, ) except BacktestRejected as exc: return _rejection_response(exc) + + +@router.get('/prices', summary='종목 정보·주가·급등락 이벤트 조회') +async def get_prices(query: Annotated[PricesQuery, Query()], request: Request): + return await _read('prices', request, symbols=query.symbols, + start_date=query.start_date, end_date=query.end_date) + + +@router.get('/exchange-rates', summary='기간별 원달러 환율·통계 조회') +async def get_exchange_rates(query: Annotated[PeriodQuery, Query()], request: Request): + return await _read('exchange-rates', request, start_date=query.start_date, end_date=query.end_date) + + +@router.get('/benchmarks', summary='기간별 S&P 500·NASDAQ 조회') +async def get_benchmarks(query: Annotated[PeriodQuery, Query()], request: Request): + return await _read('benchmarks', request, start_date=query.start_date, end_date=query.end_date) + + +@router.get('/news', summary='종목별 최신 뉴스 조회 — 백테스트 기간과 무관') +async def get_news(query: Annotated[NewsQuery, Query()], request: Request): + return await _read('news', request, symbols=query.symbols, limit=query.limit) diff --git a/backtest_be_fast/app/core/config.py b/backtest_be_fast/app/core/config.py index 86ac14cd..942dd941 100644 --- a/backtest_be_fast/app/core/config.py +++ b/backtest_be_fast/app/core/config.py @@ -159,7 +159,7 @@ def backend_cors_origins(self) -> list[str]: # Read-only market data has its own capacity; slow feeds must not occupy # the simulation slots or keep a completed calculation waiting. - max_concurrent_market_data: int = 2 + max_concurrent_market_data: int = 4 market_data_queue_timeout_seconds: float = 2.0 market_data_timeout_seconds: float = 20.0 diff --git a/backtest_be_fast/app/services/unified_data_service.py b/backtest_be_fast/app/services/unified_data_service.py index 7ceac6c1..26c3a73c 100644 --- a/backtest_be_fast/app/services/unified_data_service.py +++ b/backtest_be_fast/app/services/unified_data_service.py @@ -18,7 +18,7 @@ ) from app.repositories.stock_repository import get_stock_repository from ..core.config import settings -from ..core.cancellation import CancelToken, bind_token, current_token, submit_with_context +from ..core.cancellation import CancelToken, bind_token, current_token, submit_with_context, check_cancelled logger = logging.getLogger(__name__) @@ -273,6 +273,7 @@ def collect_latest_news( latest_news = {} for symbol in symbols: + check_cancelled() try: # 1. DB에서 먼저 조회 (3시간 이내) cached_news = self.stock_repo.load_ticker_news(symbol, max_age_hours=max_cache_hours) @@ -286,6 +287,7 @@ def collect_latest_news( logger.info(f"{symbol} 뉴스 캐시가 없거나 오래됨 - API 호출") search_query = self.news_service.get_ticker_query(symbol) news_list = self.news_service.search_news(search_query, display=display) + check_cancelled() # 3. API 결과를 DB에 저장 if news_list: @@ -350,8 +352,8 @@ def _fetch_price_histories( def collect_all_unified_data( self, symbols: List[str], - start_date: str, - end_date: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, include_news: bool = True, news_display_count: int = 20, include_stock_data: bool = True, @@ -359,6 +361,7 @@ def collect_all_unified_data( include_exchange_rates: bool = True, include_benchmarks: bool = True, timeout_seconds: Optional[float] = None, + include_ticker_info: bool = True, ) -> Dict[str, Any]: """ 모든 통합 데이터를 한 번에 수집 @@ -374,6 +377,7 @@ def collect_all_unified_data( include_exchange_rates: 환율·환율 통계 포함 여부 (A-08) include_benchmarks: S&P 500/NASDAQ 포함 여부 (A-08) timeout_seconds: 병렬 수집 구간의 시간 예산. None이면 무제한 (A-08) + include_ticker_info: 종목 메타데이터 포함. 독립 뉴스·환율·벤치마크 조회는 끈다. Returns: 모든 통합 데이터를 포함하는 딕셔너리. 끈 섹션·시간 초과 섹션도 키는 @@ -405,6 +409,8 @@ def collect_all_unified_data( 타임아웃까지 응답을 기다린 뒤 끝난다(CPU를 쓰지 않는 대기라 동시 계산 상한의 목적은 유지된다). """ + if (include_stock_data or include_volatility_events or include_exchange_rates or include_benchmarks) and not (start_date and end_date): + raise ValueError('가격·환율·벤치마크 조회에는 시작일과 종료일이 필요합니다.') total_start = time.perf_counter() timings: Dict[str, float] = {} @@ -431,9 +437,10 @@ def timed(stage: str, fn: Callable, *args, **kwargs): try: # submit_with_context가 제출 시점 컨텍스트(하위 토큰 바인딩)를 스레드로 넘긴다 with bind_token(supplemental_token): - futures['ticker_info'] = submit_with_context( - executor, timed, "ticker_info", self.collect_ticker_info, symbols - ) + if include_ticker_info: + futures['ticker_info'] = submit_with_context( + executor, timed, "ticker_info", self.collect_ticker_info, symbols + ) if need_prices: futures['price_history'] = submit_with_context( executor, timed, "price_history", self._fetch_price_histories, diff --git a/backtest_be_fast/tests/integration/verify_reference_reads_http.py b/backtest_be_fast/tests/integration/verify_reference_reads_http.py new file mode 100644 index 00000000..306c9825 --- /dev/null +++ b/backtest_be_fast/tests/integration/verify_reference_reads_http.py @@ -0,0 +1,92 @@ +"""Local runtime HTTP verification with synthetic price/news fixtures. + +Run directly, after seeding an isolated local stack as described in +docs/architecture/api-boundaries.md. Never connects to a production hostname. +""" +import argparse +from concurrent.futures import ThreadPoolExecutor +import json +import math +from pathlib import Path +import urllib.error +import urllib.parse +import urllib.request + + +def verify(base): + def call(path, payload=None): + body = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request(base + path, data=body, headers={'Content-Type': 'application/json'}) + try: + response = urllib.request.urlopen(req, timeout=35) + except urllib.error.HTTPError as exc: + response = exc + with response: + data = json.loads(response.read()) + return response.status, data + + flags = {f'include_{section}': False for section in ( + 'news', 'benchmarks', 'exchange_rates', 'stock_data', 'volatility_events')} + request = {'portfolio': [{'symbol': 'CASH', 'amount': 1500, 'asset_type': 'cash'}], + 'start_date': '2023-01-01', 'end_date': '2023-12-31', + 'strategy': 'buy_hold_strategy', 'rebalance_frequency': 'none', **flags} + code, cash = call('/api/v1/backtest', request) + assert code == 200 and cash['data']['portfolio_statistics']['Final_Value'] == 1500 + request['portfolio'] = [{'symbol': 'AAPL', 'amount': 1000, 'asset_type': 'stock'}] + code, core = call('/api/v1/backtest', request) + assert code == 200 and core['data']['equity_curve'] + assert math.isfinite(core['data']['portfolio_statistics']['Final_Value']) + period = urllib.parse.urlencode({'start_date': request['start_date'], 'end_date': request['end_date']}) + paths = [ + '/api/v1/prices?' + period + '&symbols=AAPL', + '/api/v1/exchange-rates?' + period, + '/api/v1/benchmarks?' + period, + '/api/v1/news?symbols=AAPL&limit=1', + ] + with ThreadPoolExecutor(max_workers=4) as executor: + responses = list(executor.map(call, paths)) + assert [code for code, _ in responses] == [200] * 4 + prices, exchange, benchmarks, news = [data['data'] for _, data in responses] + assert len(prices['stock_data']['AAPL']) > 200 + assert len(exchange['exchange_rates']) > 200 + assert benchmarks['sp500_total_return_pct'] is not None + assert len(news['latest_news']['AAPL']) == 1 + assert 'LOCAL FIXTURE NEWS' in news['latest_news']['AAPL'][0]['title'] + expected = [ + {'ticker_info', 'stock_data', 'volatility_events', 'supplemental_status'}, + {'exchange_rates', 'exchange_stats', 'supplemental_status'}, + {'sp500_benchmark', 'nasdaq_benchmark', 'sp500_total_return_pct', 'supplemental_status'}, + {'latest_news', 'supplemental_status'}, + ] + assert all(set(data) == fields for data, fields in zip((prices, exchange, benchmarks, news), expected)) + for path in ('/api/v1/news?symbols=AAPL&start_date=2023-01-01', + '/api/v1/prices?' + period + '&symbols=../bad'): + assert call(path)[0] == 422 + assert call('/api/v1/market-data?' + period)[0] == 404 + # The existing POST still offers its legacy all-in-one response. + legacy = {key: value for key, value in request.items() if not key.startswith('include_')} + code, full = call('/api/v1/backtest', legacy) + assert code == 200 + for key in ('equity_curve', 'daily_returns', 'individual_returns', 'portfolio_composition', 'strategy_details'): + assert full['data'][key] == core['data'][key], key + statistics = {key: value for key, value in full['data']['portfolio_statistics'].items() + if key not in ('sp500_total_return_pct', 'alpha_vs_sp500_pct')} + assert statistics == core['data']['portfolio_statistics'] + return {'four_parallel_reads': True, 'resource_fields_isolated': True, + 'news_without_period': True, 'invalid_queries_rejected': True, + 'cash_1500_preserved': True, 'legacy_calculation_values_preserved': True, + 'old_aggregate_get_removed': True, 'price_points': len(prices['stock_data']['AAPL'])} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--base-url', default='http://127.0.0.1:18080') + parser.add_argument('--report', type=Path) + args = parser.parse_args() + url = urllib.parse.urlsplit(args.base_url) + if url.scheme != 'http' or url.hostname not in ('localhost', '127.0.0.1', '::1'): + parser.error('Only a local HTTP test stack is allowed') + report = verify(args.base_url.rstrip('/')) + if args.report: + args.report.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(json.dumps(report, indent=2)) diff --git a/backtest_be_fast/tests/unit/test_api_contract.py b/backtest_be_fast/tests/unit/test_api_contract.py index a76ad406..e1e9659b 100644 --- a/backtest_be_fast/tests/unit/test_api_contract.py +++ b/backtest_be_fast/tests/unit/test_api_contract.py @@ -24,7 +24,9 @@ def test_preserves_production_routes_requests_and_responses(): actual = response.json() # The independently retriable read API is additive. Every pre-existing # route and request/response schema must remain byte-for-byte equivalent. - assert set(actual['paths']) == set(expected['paths']) | {'/api/v1/market-data'} - assert 'get' in actual['paths'].pop('/api/v1/market-data') + reads = {'/api/v1/' + name for name in ('prices', 'exchange-rates', 'benchmarks', 'news')} + assert set(actual['paths']) == set(expected['paths']) | reads + for path in reads: + assert 'get' in actual['paths'].pop(path) for section in ("paths", "components"): assert _contract(actual[section]) == expected[section] diff --git a/backtest_be_fast/tests/unit/test_market_data_api.py b/backtest_be_fast/tests/unit/test_market_data_api.py index b6cac096..6354726f 100644 --- a/backtest_be_fast/tests/unit/test_market_data_api.py +++ b/backtest_be_fast/tests/unit/test_market_data_api.py @@ -1,4 +1,6 @@ -"""Feed reads cannot trigger simulations or consume simulation capacity.""" +"""Each feed owns its data, failures and retries; calculation stays separate.""" +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier from unittest.mock import AsyncMock, MagicMock import pytest @@ -6,58 +8,152 @@ from app.main import app from app.api.v1.endpoints import backtest, market_data -from app.services.backtest_runner import BacktestJobRunner +from app.core.cancellation import BacktestCancelled, CancelToken, bind_token pytestmark = pytest.mark.unit -QUERY = [('symbols', 'AAPL'), ('symbols', 'MSFT'), - ('start_date', '2023-01-01'), ('end_date', '2023-06-30')] +PERIOD = [('start_date', '2023-01-01'), ('end_date', '2023-06-30')] +QUERIES = { + 'prices': PERIOD + [('symbols', 'aapl'), ('symbols', 'MSFT'), ('symbols', 'AAPL')], + 'exchange-rates': PERIOD, + 'benchmarks': PERIOD, + 'news': [('symbols', 'AAPL'), ('symbols', 'MSFT'), ('limit', '7')], +} @pytest.fixture def runner(tmp_path, monkeypatch): - job = BacktestJobRunner( - slot_dir=str(tmp_path / 'feeds'), max_concurrent=1, per_client=1, - queue_timeout=0.02, exec_timeout=1, cancel_grace=0.2, - ) - monkeypatch.setattr(market_data, 'get_market_data_runner', lambda: job) + factory = market_data.get_market_data_runner + factory.cache_clear() + monkeypatch.setattr(market_data.settings, 'backtest_slot_dir', str(tmp_path / 'jobs')) + monkeypatch.setattr(market_data.settings, 'market_data_queue_timeout_seconds', 0.05) + job = factory() yield job job.shutdown() + factory.cache_clear() + +def reference_data(): + data = market_data.unified_data_service.empty_unified_data(['AAPL', 'MSFT'], 'ok') + data['sp500_benchmark'] = [{'close': 100}, {'close': 110}] + return data -def test_market_data_is_independent_of_simulation(monkeypatch, runner): - collector = MagicMock(return_value={ - 'sp500_benchmark': [{'close': 100}, {'close': 110}], - 'latest_news': {'AAPL': []}, 'supplemental_status': {'news': 'empty'}, - }) - simulation = AsyncMock(side_effect=AssertionError('must not run')) + +@pytest.mark.parametrize('section', QUERIES) +def test_reads_only_requested_resource(section, monkeypatch, runner): + collector = MagicMock(return_value=reference_data()) + simulation = AsyncMock(side_effect=AssertionError('must not calculate')) monkeypatch.setattr(market_data.unified_data_service, 'collect_all_unified_data', collector) monkeypatch.setattr(backtest.portfolio_manager_service, 'run_portfolio_backtest', simulation) with TestClient(app) as client: - response = client.get('/api/v1/market-data', params=QUERY) + response = client.get('/api/v1/' + section, params=QUERIES[section]) assert response.status_code == 200, response.text - assert response.json()['data']['sp500_total_return_pct'] == pytest.approx(10) - assert collector.call_args.kwargs['symbols'] == ['AAPL', 'MSFT'] - assert collector.call_args.kwargs['timeout_seconds'] > 0 + data = response.json()['data'] + expected = set(market_data.READ_FIELDS[section]) | {'supplemental_status'} + if section == 'benchmarks': + expected.add('sp500_total_return_pct') + assert data['sp500_total_return_pct'] == pytest.approx(10) + assert set(data) == expected + assert set(data['supplemental_status']) == set(market_data.READ_STATUSES[section]) + options = collector.call_args.kwargs + assert options['include_ticker_info'] == (section == 'prices') + assert options['include_stock_data'] == (section == 'prices') + assert options['include_volatility_events'] == (section == 'prices') + assert options['include_exchange_rates'] == (section == 'exchange-rates') + assert options['include_benchmarks'] == (section == 'benchmarks') + assert options['include_news'] == (section == 'news') + assert options['symbols'] == (['AAPL', 'MSFT'] if section in ('prices', 'news') else []) + if section == 'news': + assert options['start_date'] is None and options['end_date'] is None + assert options['news_display_count'] == 7 simulation.assert_not_called() -@pytest.mark.parametrize('params', [ - [('symbols', '../secret'), ('start_date', '2023-01-01'), ('end_date', '2023-06-30')], - [('start_date', '2023-06-30'), ('end_date', '2023-01-01')], - [('start_date', '2023-01-01'), ('end_date', '2099-01-01')], - [('start_date', '2000-01-01'), ('end_date', '2023-01-01')], - [('start_date', '2023-01-01'), ('end_date', '2023-06-30')] + [('symbols', 'AAPL')] * 21, +@pytest.mark.parametrize(('section', 'params'), [ + ('prices', PERIOD), + ('prices', PERIOD + [('symbols', '../secret')]), + ('prices', PERIOD + [('symbols', 'AAPL')] * 21), + ('prices', [('symbols', 'AAPL'), ('start_date', '2023-06-30'), ('end_date', '2023-01-01')]), + ('exchange-rates', [('start_date', '2000-01-01'), ('end_date', '2023-01-01')]), + ('benchmarks', [('start_date', '2023-01-01'), ('end_date', '2099-01-01')]), + ('news', []), + ('news', [('symbols', '../secret')]), + ('news', [('symbols', 'AAPL')] * 21), + ('news', [('symbols', 'AAPL'), ('limit', '0')]), + ('news', [('symbols', 'AAPL'), ('limit', '21')]), + ('news', [('symbols', 'AAPL'), ('start_date', '2023-01-01')]), + ('exchange-rates', PERIOD + [('symbols', 'AAPL')]), ]) -def test_invalid_read_is_rejected_before_io(params, monkeypatch): +def test_invalid_read_is_rejected_before_io(section, params, monkeypatch): collector = MagicMock(side_effect=AssertionError('must not fetch')) monkeypatch.setattr(market_data.unified_data_service, 'collect_all_unified_data', collector) with TestClient(app) as client: - response = client.get('/api/v1/market-data', params=params) - assert response.status_code == 422 + response = client.get('/api/v1/' + section, params=params) + assert response.status_code == 422, response.text collector.assert_not_called() -def test_all_flags_disabled_skips_all_reference_io(monkeypatch): +def test_four_resources_can_run_concurrently_from_same_ip(monkeypatch, runner): + barrier = Barrier(4) + + def collect(**kwargs): + barrier.wait(timeout=5) + return reference_data() + + monkeypatch.setattr(market_data.unified_data_service, 'collect_all_unified_data', collect) + assert runner.max_concurrent == 4 and runner.per_client == 4 + with TestClient(app, client=('198.51.100.20', 50000)) as client: + with ThreadPoolExecutor(max_workers=4) as executor: + responses = list(executor.map( + lambda section: client.get('/api/v1/' + section, params=QUERIES[section]), QUERIES)) + assert [response.status_code for response in responses] == [200] * 4 + + +@pytest.mark.parametrize('section', QUERIES) +def test_all_reads_share_the_global_capacity(section, monkeypatch, runner): + slots = [runner.global_pool.try_acquire() for _ in range(runner.max_concurrent)] + assert all(slots) + collector = MagicMock(side_effect=AssertionError('busy read must not start')) + monkeypatch.setattr(market_data.unified_data_service, 'collect_all_unified_data', collector) + try: + with TestClient(app) as client: + response = client.get('/api/v1/' + section, params=QUERIES[section]) + assert response.status_code == 503 + assert response.headers.get('retry-after') + collector.assert_not_called() + finally: + for slot in slots: + slot.release() + + +def test_news_does_not_fetch_metadata_or_require_dates(monkeypatch): + service = market_data.unified_data_service + metadata = MagicMock(side_effect=AssertionError('news must not fetch metadata')) + monkeypatch.setattr(service, 'collect_ticker_info', metadata) + monkeypatch.setattr(service, 'collect_latest_news', lambda *args: {'AAPL': [{'title': 'news'}]}) + data = service.collect_all_unified_data( + ['AAPL'], include_ticker_info=False, include_stock_data=False, + include_volatility_events=False, include_exchange_rates=False, include_benchmarks=False, + ) + assert data['latest_news']['AAPL'][0]['title'] == 'news' + metadata.assert_not_called() + + +def test_news_stops_before_cache_write_and_next_symbol_after_cancel(monkeypatch): + service = market_data.unified_data_service + token = CancelToken() + news = MagicMock() + news.search_news.side_effect = lambda *args, **kwargs: (token.cancel('test') or [{'title': 'news'}]) + repo = MagicMock() + repo.load_ticker_news.return_value = [] + monkeypatch.setattr(service, 'news_service', news) + monkeypatch.setattr(service, 'stock_repo', repo) + with bind_token(token), pytest.raises(BacktestCancelled): + service.collect_latest_news(['AAPL', 'MSFT']) + assert news.search_news.call_count == 1 + repo.save_ticker_news.assert_not_called() + + +def test_all_flags_disabled_skips_reference_io(monkeypatch): repo = MagicMock() repo.get_tickers_info_batch.return_value = {} monkeypatch.setattr(backtest, 'get_stock_repository', lambda: repo) @@ -77,18 +173,3 @@ def test_all_flags_disabled_skips_all_reference_io(monkeypatch): assert response.json()['data']['portfolio_statistics']['Total_Return'] == 12 assert set(response.json()['data']['supplemental_status'].values()) == {'skipped'} collector.assert_not_called() - - -def test_busy_feed_queue_does_not_claim_a_simulation_slot(monkeypatch, runner): - slot = runner.global_pool.try_acquire() - assert slot is not None - collector = MagicMock(side_effect=AssertionError('queued request must not start')) - monkeypatch.setattr(market_data.unified_data_service, 'collect_all_unified_data', collector) - try: - with TestClient(app) as client: - response = client.get('/api/v1/market-data', params=QUERY) - assert response.status_code == 503 - assert response.headers.get('retry-after') - collector.assert_not_called() - finally: - slot.release() diff --git a/docs/architecture/api-boundaries.md b/docs/architecture/api-boundaries.md index 1fb3ac14..190836a5 100644 --- a/docs/architecture/api-boundaries.md +++ b/docs/architecture/api-boundaries.md @@ -1,65 +1,87 @@ -# 계산과 참고 데이터의 경계 +# 계산 1개와 독립 조회 4개 -2026-10-25. 계산과 뉴스·차트 자료가 같은 HTTP 요청에 묶여, 이미 끝난 계산도 -외부 조회를 기다리고 참고 자료를 재시도할 때 계산까지 반복하던 문제를 분리했다. -새 DB·큐·서버는 추가하지 않는다. +2026-10-25. 계산 결과를 먼저 표시하고, 주가·환율·벤치마크·뉴스를 각각 조회·재시도한다. +한 조회의 지연·실패가 다른 조회의 표시나 이미 완료한 계산을 막지 않는다. +FastAPI 서버·DB는 그대로 사용하며 새 서비스나 영속 큐를 추가하지 않는다. -| API | 책임 | 한도 | +| API | 요청 | 반환하는 data 필드 | |---|---|---| -| `POST /api/v1/backtest` | 주가 로드와 시뮬레이션, 수익률·거래·자산 곡선 | 기존 컨테이너 전체 8건, IP별 2건, 대기 30초 + 실행 60초 | -| `GET /api/v1/market-data` | 참고 주가·종목 정보·환율·벤치마크·뉴스 | 별도 전체 2건, IP별 1건, 대기 2초 + 실행 20초 | +| `POST /api/v1/backtest` | 기존 포트폴리오·기간·전략·수수료 | 계산 결과. 기존 include 옵션에 따른 통합 응답도 지원 | +| `GET /api/v1/prices` | symbols, start_date, end_date | ticker_info, stock_data, volatility_events | +| `GET /api/v1/exchange-rates` | start_date, end_date | exchange_rates, exchange_stats | +| `GET /api/v1/benchmarks` | start_date, end_date | sp500_benchmark, nasdaq_benchmark, sp500_total_return_pct | +| `GET /api/v1/news` | symbols, limit(선택) | latest_news | -조회 한도는 `MAX_CONCURRENT_MARKET_DATA`, `MARKET_DATA_QUEUE_TIMEOUT_SECONDS`, -`MARKET_DATA_TIMEOUT_SECONDS`로 조정한다. 수집 자체의 기본 시간 예산은 15초다. -두 실행기는 같은 취소·파일 슬롯 구현을 재사용하지만 슬롯 경로와 계측은 분리한다. -느린 조회는 계산 슬롯을 점유하지 않는다. 시간 초과·연결 종료 후에도 실제 스레드가 -끝나기 전에는 슬롯을 반환하지 않는다. +## 요청·응답 계약 -## 요청과 호환성 +기존 계산 POST 본문·응답 필드와 다섯 `include_*` 기본값(true)은 유지한다. 콘솔은 +다섯 옵션을 모두 false로 보내 계산만 받는다. 계산에 필요한 상장일 검사 등은 유지하고, +별도 참고 자료 수집은 생략한다. 참고 필드는 빈 값·기본 메타데이터, 상태는 skipped다. -계산 POST의 본문·응답 필드는 유지한다. 다섯 `include_*` 기본값도 기존처럼 true다. -콘솔은 이를 모두 false로 보내며 이 경우 참고 메타데이터 조회도 건너뛴다. -결과의 참고 자료 필드는 비우고 `supplemental_status`를 skipped로 반환한다. +네 GET 모두 `{status: "success", data: {...}}` 형태이며, 해당 자료에 속한 +`supplemental_status`만 함께 반환한다. 상태는 ok/empty/skipped/timeout/error다. +뉴스가 실패한 사실을 다른 세 응답이 덮어쓸 수 없다. 계산 결과 필드는 GET에 없다. ```text -GET /api/v1/market-data?symbols=AAPL&symbols=MSFT&start_date=2023-01-01&end_date=2023-06-30 +GET /api/v1/prices?symbols=AAPL&symbols=MSFT&start_date=2023-01-01&end_date=2023-12-31 +GET /api/v1/exchange-rates?start_date=2023-01-01&end_date=2023-12-31 +GET /api/v1/benchmarks?start_date=2023-01-01&end_date=2023-12-31 +GET /api/v1/news?symbols=AAPL&symbols=MSFT&limit=15 ``` -심볼은 주식 입력과 같은 형식으로 검사·대문자화하고 중복을 제거한다. 입력은 최대 -20개, 기간은 최대 3,650일이며 잘못된 날짜·미래 종료일을 외부 I/O 전에 거부한다. -`symbols`를 생략하면 환율·지수 등 공통 자료만 조회할 수 있다. -기존 `include_news`, `include_stock_data`, `include_volatility_events`, -`include_exchange_rates`, `include_benchmarks`를 쿼리 옵션으로 사용할 수 있다. +prices/news의 symbols는 필수이며 반복 파라미터를 쓴다. 입력 최대 20개를 검증한 뒤 +대문자화·중복 제거한다. 주식 입력과 같은 심볼 형식 제한을 적용한다. +기간 조회의 시작일·종료일은 필수이며 역전·미래 종료일·3,650일 초과를 거부한다. +뉴스는 백테스트 기간과 무관한 최신 뉴스다. 날짜 파라미터를 받지 않고 limit은 +종목당 1~20개, 기본 15개다. 지원하지 않는 파라미터도 외부 I/O 전에 422로 거부한다. +환율·벤치마크는 symbols를 받지 않는다. -조회 응답은 `{status: "success", data: {...}}`이며 기존 참고 자료 필드와 -섹션별 수집 상태, `sp500_total_return_pct`(자료 부족 시 null)를 제공한다. -이 응답에는 시뮬레이션의 투자금·최종 평가금·거래·자산 곡선을 넣지 않는다. +주가와 급등락 이벤트는 같은 시세를 한 번 조회해 공유한다. 환율·벤치마크·뉴스는 +종목 메타데이터를 조회하지 않는다. 기존 캐시·수집·직렬화 코드를 재사용하며 계산식은 +바꾸지 않는다. 벤치마크 자료가 부족하면 sp500_total_return_pct는 null이다. -콘솔은 먼저 계산을 표시하고 참고 자료를 나중에 병합한다. 조회 장애 시 계산 결과를 -유지하고 조회만 재시도한다. 새 실행·화면 이탈은 이전 계산/조회 HTTP 요청을 취소한다. -오래된 응답이 새 결과를 덮어쓰지 않으며 참고 응답은 계산 필드를 덮어쓸 수 없다. +## 실행 상한과 취소 -## 배포와 검증 +- 계산: 기존 컨테이너 전체 8건, IP별 2건, 대기 30초 + 실행 60초. +- 조회: **네 API 합산** 전체 기본 4건, IP별 4건. 대기 2초 + 실행 20초. + 기본 수집 예산은 각 조회 15초다. 조회 종류마다 전체 풀을 새로 만들지 않는다. +- 전체 조회 상한은 `MAX_CONCURRENT_MARKET_DATA`, 대기·실행 시간은 + `MARKET_DATA_QUEUE_TIMEOUT_SECONDS`, `MARKET_DATA_TIMEOUT_SECONDS`로 조정한다. + 이전 환경 파일에 상한 2를 명시했다면 새 네 요청 흐름에 맞게 검토한다. +- IP 한도 초과 429, 전체 슬롯 대기 초과 503, 실행 초과 504는 detail과 Retry-After를 반환한다. +- 기존 파일 슬롯·협력적 취소 실행기를 재사용한다. 뉴스 루프는 취소 후 다음 종목을 + 시작하거나 뒤늦게 캐시에 쓰지 않는다. 이미 진행 중인 동기 외부 호출은 라이브러리 + 타임아웃까지 기다릴 수 있다. -백엔드를 먼저 배포하고 새 GET이 정상임을 확인한 다음 콘솔을 배포한다. -두 프록시 진입점 모두 `/api/v1/`를 전달해야 한다. 콘솔 프록시와 외부 공통 -게이트웨이의 읽기 제한은 앱의 최대 대기·실행 시간보다 길어야 한다. +콘솔은 요청별 AbortController를 사용한다. 특정 조회 재시도는 그 조회만 다시 실행하고, +새 계산·화면 이탈·초기화는 이전 계산과 모든 조회를 취소한다. 늦은 응답은 새 결과를 +덮어쓸 수 없다. 조회별 로딩·오류·빈 결과 상태는 서로 독립적이다. -기존 API 스냅샷은 새 GET 경로만 별도 허용하고 이전 경로·본문·응답 정의는 계속 -동일하게 검사한다. 회귀 검증은 잘못된 조회의 I/O 차단, 계산에서 참고 조회 생략, -조회 슬롯 포화, 느린 조회 중 계산 표시, 조회만 재시도, 오래된 응답 차단, -화면 이탈 취소를 포함한다. +## 호환성과 배포 -2026-10-25 로컬 검증에서 Docker 테스트 512개와 골든 E2E 1개가 통과했다. -별도로 배포용 BE·콘솔 이미지와 격리 MySQL을 실행했다. 운영 자격증명 없이 합성 -시세를 넣어 기존 POST와 분리 API의 계산값이 동일함을 확인했다. 브라우저에서 -GET만 503으로 실패시켰을 때 계산 결과를 유지했고, GET 복구 후 참고 자료만 -재시도해 차트를 채웠다. 재시도 전후 계산 POST 누적 호출 수는 5건으로 같았다. -이는 기능 검증이며 운영 데이터의 정확성이나 부하 처리 성능을 보장하는 수치는 아니다. +이 변경은 앞서 추가한 **GET /api/v1/market-data를 네 GET으로 대체**한다. +기존 계산 POST 호출자는 그대로 사용할 수 있지만 옛 콘솔의 통합 GET 호출은 호환되지 않는다. +운영 전환 시 두 저장소의 릴리스를 함께 계획해야 하며, 이전 탭은 새로고침해야 한다. +배포 중 옛 콘솔까지 계속 지원해야 한다면 운영 반영 전에 임시 호환 경로를 준비한다. +현재 프록시는 `/api/v1/` 전체를 전달하므로 경로별 Nginx 수정은 필요하지 않다. -## 남은 경계 +## 검증 -작업은 여전히 HTTP 연결에 묶인 동기 응답이다. 브라우저를 닫거나 배포·서버 재시작 시 -작업을 복구하지 않으며 결과 보관·작업 ID·대기열 내구성은 제공하지 않는다. -현재 짧은 개인 프로젝트 작업에는 이 구성을 유지한다. 장시간 계산·결과 보관이 -필요해지면 그 요구를 근거로 영속 작업 API와 별도 실행 프로세스를 도입한다. +Docker 단위 검증은 기존 POST 스냅샷을 유지하면서 네 조회의 입력·응답 범위, +같은 IP의 네 동시 요청, 네 API의 공유 실행 상한, 뉴스 취소를 확인한다. +콘솔은 병렬 응답 병합·느린 뉴스 중 다른 자료 표시·각 API의 개별 재시도·잘못된 +응답 형식·오래된 응답 차단·화면 이탈 취소·현금 포트폴리오의 불필요한 조회 생략을 검증한다. + +실행 이미지 HTTP 검증 스크립트는 격리 MySQL에 미리 넣은 합성 AAPL 시세(2023년 +200거래일 이상)와 현재 시각의 합성 뉴스 캐시를 사용한다. 운영 자격증명이나 실제 데이터는 +필요하지 않다. DB에 시드하는 스크립트가 아니라 준비된 로컬 테스트 서버에 요청하는 검사다. + +```bash +python backtest_be_fast/tests/integration/verify_reference_reads_http.py --base-url http://127.0.0.1:18080 +``` + +브라우저에서는 네 조회만 503으로 실패시킨 뒤 하나씩 복구·재시도한다. 각 GET만 1회 +늘고 계산 POST와 다른 GET의 호출 수가 같아야 한다. 자세한 콘솔 흐름은 +[프론트엔드 문서](https://github.com/kyj0503/backtest-console/blob/dev/docs/api-data-flow.md)를 참조한다. + +작업은 여전히 HTTP 연결에 묶여 있다. 작업 ID·영속 대기열·재시작 후 복구는 제공하지 않는다.