From 6ad3e99dd5f19305c9ab769acf99d6a4300b307a Mon Sep 17 00:00:00 2001 From: Junsu <103491329+easygap@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:34:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=B0=94=EC=8A=A4=EC=BC=93=20=ED=8F=89?= =?UTF-8?q?=EA=B0=80=C2=B7=EC=A2=85=EA=B0=80=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95,=20=EC=8B=9C=EC=9E=A5?= =?UTF-8?q?=20=EC=9E=90=EB=A3=8C=20=EC=88=98=EC=A7=91=EA=B3=BC=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=98=EC=9A=B4=EC=9A=A9=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 바스켓에 오전 스냅샷이 각각 84건, 62건 쌓였지만 종가 평가 기록은 한 건도 없었습니다. 매수가 낙폭 한도에 걸려 거절돼도 평가 화면에는 통과로 표시됐고, 장전 모의 체결이 그날의 정규장 매매를 막는 경우도 있었습니다. 이런 실행·기록 오류를 고치고, 시장 자료 수집부터 체결비용 기록, 장기 모의운용 검증까지 이어지도록 연결했습니다. ## 바뀐 동작 - **바스켓 상태 표시:** 현재 매수 차단 사유와 과거 주문 거절을 구분합니다. 보유 비중은 평가 시점의 시장가격으로 계산하고, 주식·CD금리 ETF·현금을 나눠 목표와 비교합니다. 1주 단위 제약과 월별 적립 부족도 표시합니다. - **매매 시간과 종가 기록:** 모의 바스켓 주문도 정규장 안에서만 체결합니다. 종가는 장마감 후 실제 매매·입출금 원장과 대조해 별도로 저장하며, 같은 날 다시 실행해도 중복으로 쌓이지 않습니다. - **시장 자료 수집:** 구성 종목과 업종 ETF의 상대 강도·거래량·변동성을 비교하고, DART 공시와 NAVER 종목별 수급을 수집합니다. 자료가 없는 경우와 수집에 실패한 경우를 구분하고, 원문 출처와 실제 확인 시각을 남깁니다. - **체결비용과 모의운용 검증:** 계획가격 대비 체결가격 차이, 수수료·세금, 부분 체결과 거절을 기록합니다. 모의운용 성과는 기준을 등록한 뒤 새로 쌓이는 종가로 계산하고, 같은 날짜의 비교지수와 상승·하락일 성과, 결측을 함께 확인합니다. - **연구 결과 검증:** 과거 재무 분석에 현재 값을 섞던 경로와 부채비율 계산을 수정했습니다. 새 전략 승인에는 후보·코드·설정·검증 구간이 일치하는 근거를 요구합니다. 출처와 반증 조건을 갖춘 투자 가설도 화면에서 확인할 수 있습니다. - **일일 점검:** `tools/daily_observation.py`가 종가 기록, 자료 수집, 모의운용 검증을 순서대로 실행합니다. 오전 수집이 오후 작업을 막지 않도록 실행 구간을 나누고, 휴장일 판정·중복 실행·시간 초과 처리도 보완했습니다. ## 확인한 결과 - 관련 Python 회귀 검사 919개를 통과했고, 이후 일일 운영 경로 수정에 대해 296개를 다시 검사했습니다. 화면 JavaScript 검사는 38개 모두 통과했습니다. 새 화면 검사도 GitHub Actions에 포함했습니다. - 운영 DB 사본에서 실제 계획→실행 경로를 확인했습니다. 장외 주문과 낙폭 한도 초과 주문은 차단됐고, 종가 2행 저장 후 재실행에서는 0행이 추가됐습니다. 저장한 종가는 원장 대조 검사도 통과했습니다. - 실제 수집에서는 9종목의 수급 36행과 두 비교지수의 정확한 날짜 자료를 확인했습니다. 장중 점검에서 빠진 종가를 정상으로 처리하지 않았고, 실행 전후 운영 DB 해시도 같았습니다. - 데스크톱 1440px과 모바일 390px에서 자료 표시와 화면 넘침을 확인했습니다. 자세한 재현 조건은 아래 보고서에 정리했습니다. ## 운영 시 확인할 점 DART는 현재 설정된 키가 `010(미등록 키)`로 거절돼 유효한 키 등록이 남아 있습니다. 새 모의운용 검증의 실제 종가 관측은 아직 0건이며, 장기 수익성이 입증된 상태는 아닙니다. 과거 장중 기록을 새 검증 성과로 채우지 않습니다. 운영 PC에는 매일 08:30·16:30·18:30 점검을 등록했습니다. 이 일정은 PC와 Codex 앱이 켜져 있어야 실행되는 로컬 설정입니다. 저장소를 내려받는 것만으로 일정이 등록되지는 않습니다. - [바스켓 점검과 수정 결과](docs/BASKET_SNAPSHOT_REVIEW_20261013.md) - [전문 운용 조사와 연구 검증 변경](docs/INVESTMENT_RESEARCH_20261013.md) - [자동 수집·체결비용·모의운용 검증 안내](docs/RESEARCH_OPERATIONS_20261013.md) --- .github/workflows/safety-regression.yml | 2 +- .gitignore | 1 + README.md | 10 +- config/market_intelligence.yaml | 26 + config/market_research.yaml | 83 ++ config/paper_validation.yaml | 20 + config/research_operations.yaml | 9 + config/research_theses.json | 72 ++ config/strategies.yaml | 3 +- core/basket_close.py | 339 ++++++++ core/basket_evaluation.py | 98 ++- core/basket_observability.py | 158 ++++ core/basket_rebalancer.py | 95 +- core/daily_observation.py | 161 ++++ core/data_collector.py | 8 +- core/execution_analytics.py | 368 ++++++++ core/fundamental_loader.py | 73 +- core/market_intelligence.py | 493 +++++++++++ core/market_research.py | 353 ++++++++ core/observation_benchmark.py | 143 +++ core/order_executor.py | 19 + core/paper_validation.py | 813 ++++++++++++++++++ core/portfolio_manager.py | 26 +- core/promotion_engine.py | 10 + core/research_admission.py | 168 ++++ core/research_evidence.py | 327 +++++++ core/research_operations.py | 245 ++++++ core/scheduler.py | 2 + core/strategy_ensemble.py | 19 +- core/weekly_report.py | 21 +- database/models.py | 36 + database/repositories.py | 140 ++- docs/BASKET_SNAPSHOT_REVIEW_20261013.md | 162 ++++ docs/INVESTMENT_RESEARCH_20261013.md | 120 +++ docs/PROFITABILITY_FINDINGS.md | 31 +- docs/PROJECT_GUIDE.md | 2 + docs/RESEARCH_OPERATIONS_20261013.md | 115 +++ docs/USER_GUIDE.md | 23 +- main.py | 131 +-- monitoring/operations_dashboard.py | 30 + monitoring/research_dashboard.py | 117 +++ monitoring/static/dashboard.css | 37 + monitoring/static/dashboard.js | 42 +- monitoring/static/operations.js | 81 ++ monitoring/static/research.js | 121 +++ monitoring/templates/dashboard.html | 40 +- monitoring/web_dashboard.py | 15 +- strategies/fundamental_factor.py | 135 ++- strategies/mean_reversion.py | 1 + tests/conftest.py | 26 +- tests/js/dashboard.test.cjs | 38 +- tests/js/operations.test.cjs | 55 ++ tests/js/research.test.cjs | 96 +++ tests/test_audit_cycle.py | 8 +- tests/test_audit_data_freshness.py | 9 + tests/test_audit_strategies_mean_reversion.py | 14 + tests/test_basket_close_collection.py | 238 +++++ tests/test_basket_overlay_integration.py | 14 + tests/test_basket_rebalancer.py | 6 + tests/test_basket_snapshot_observability.py | 213 +++++ tests/test_daily_observation.py | 113 +++ tests/test_execution_analytics.py | 244 ++++++ tests/test_fundamental_data_integrity.py | 190 ++++ tests/test_market_intelligence.py | 329 +++++++ tests/test_market_research.py | 241 ++++++ tests/test_observation_benchmark.py | 105 +++ tests/test_paper_validation.py | 580 +++++++++++++ tests/test_pytest_directory_isolation.py | 73 ++ tests/test_rebalance_snapshot.py | 2 + tests/test_research_admission.py | 136 +++ tests/test_research_candidate_sweep.py | 66 +- tests/test_research_dashboard.py | 111 +++ tests/test_research_evidence.py | 211 +++++ tests/test_research_operations.py | 227 +++++ tests/test_weekly_report.py | 3 +- tools/collect_basket_closes.py | 60 ++ tools/daily_observation.py | 21 + tools/evaluate_and_promote.py | 31 +- tools/execution_cost_report.py | 33 + tools/market_intelligence.py | 26 + tools/market_research.py | 45 + tools/paper_validation.py | 45 + tools/research_candidate_sweep.py | 209 +++-- tools/research_evidence.py | 55 ++ tools/research_operations.py | 25 + tools/verify_basket_snapshot_fixes.py | 153 ++++ tools/verify_research_admission.py | 71 ++ 87 files changed, 9358 insertions(+), 308 deletions(-) create mode 100644 config/market_intelligence.yaml create mode 100644 config/market_research.yaml create mode 100644 config/paper_validation.yaml create mode 100644 config/research_operations.yaml create mode 100644 config/research_theses.json create mode 100644 core/basket_close.py create mode 100644 core/basket_observability.py create mode 100644 core/daily_observation.py create mode 100644 core/execution_analytics.py create mode 100644 core/market_intelligence.py create mode 100644 core/market_research.py create mode 100644 core/observation_benchmark.py create mode 100644 core/paper_validation.py create mode 100644 core/research_admission.py create mode 100644 core/research_evidence.py create mode 100644 core/research_operations.py create mode 100644 docs/BASKET_SNAPSHOT_REVIEW_20261013.md create mode 100644 docs/INVESTMENT_RESEARCH_20261013.md create mode 100644 docs/RESEARCH_OPERATIONS_20261013.md create mode 100644 monitoring/operations_dashboard.py create mode 100644 monitoring/research_dashboard.py create mode 100644 monitoring/static/operations.js create mode 100644 monitoring/static/research.js create mode 100644 tests/js/operations.test.cjs create mode 100644 tests/js/research.test.cjs create mode 100644 tests/test_basket_close_collection.py create mode 100644 tests/test_basket_snapshot_observability.py create mode 100644 tests/test_daily_observation.py create mode 100644 tests/test_execution_analytics.py create mode 100644 tests/test_fundamental_data_integrity.py create mode 100644 tests/test_market_intelligence.py create mode 100644 tests/test_market_research.py create mode 100644 tests/test_observation_benchmark.py create mode 100644 tests/test_paper_validation.py create mode 100644 tests/test_pytest_directory_isolation.py create mode 100644 tests/test_research_admission.py create mode 100644 tests/test_research_dashboard.py create mode 100644 tests/test_research_evidence.py create mode 100644 tests/test_research_operations.py create mode 100644 tools/collect_basket_closes.py create mode 100644 tools/daily_observation.py create mode 100644 tools/execution_cost_report.py create mode 100644 tools/market_intelligence.py create mode 100644 tools/market_research.py create mode 100644 tools/paper_validation.py create mode 100644 tools/research_evidence.py create mode 100644 tools/research_operations.py create mode 100644 tools/verify_basket_snapshot_fixes.py create mode 100644 tools/verify_research_admission.py diff --git a/.github/workflows/safety-regression.yml b/.github/workflows/safety-regression.yml index e4764d60..5142fce7 100644 --- a/.github/workflows/safety-regression.yml +++ b/.github/workflows/safety-regression.yml @@ -45,7 +45,7 @@ jobs: package-manager-cache: false - name: Check dashboard date calculations and refresh behavior - run: node --test tests/js/dashboard.test.cjs + run: node --test tests/js/dashboard.test.cjs tests/js/research.test.cjs tests/js/operations.test.cjs - name: Run safety regression tests env: diff --git a/.gitignore b/.gitignore index ab2815ea..e13bba2d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ pytest_tmp*/ .claude/worktrees/ output/ .playwright-mcp/ +.playwright-cli/ # --- IDE / OS --- .idea/ diff --git a/README.md b/README.md index 33e4a385..004e7b60 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,15 @@ 종목별 보유 수량과 평균 매수가, 현재 비중과 목표 비중을 나란히 표시합니다. -비중은 **매수금액 기준**입니다. 현재 주가로 계산한 평가금액과 손익은 표 아래에 따로 표시합니다. +비중은 저장된 **평가 시점의 시장가격 기준**입니다. 가격이나 보유 수량을 확인할 수 없으면 비중을 미확인으로 표시합니다. 위 이미지는 이전 화면입니다. + +## 시장 관찰과 투자 가설 + +**시장 관찰**에서 바스켓 구성 종목과 10개 업종 ETF의 상대 강도, 거래량 변화, 변동성, 자료 기준일을 비교합니다. **시장 자료 갱신**을 누르면 이전 거래일까지의 가격을 수집합니다. 순위는 현재 관찰 목록 안의 비교이며 자동 주문을 만들지 않습니다. + +산업 변화와 기업 실적은 원문 출처, 확인 시각, 반증 조건, 다음 검토일을 가진 투자 가설로 기록합니다. 공개 시각이 불명확하거나 검토 기한이 지나면 별도로 표시합니다. [전문 운용 조사와 적용 내용](docs/INVESTMENT_RESEARCH_20261013.md) + +일일 운영 뒤 공시·종목별 수급을 자동 수집하고, 새 주문의 계획가격 대비 체결비용과 등록 이후 모의운용 성과를 누적합니다. 화면에서 수집 실패·측정 근거·상승/하락일 성과·결측을 확인할 수 있습니다. [자동 수집·체결비용·장기 검증 안내](docs/RESEARCH_OPERATIONS_20261013.md) ## 투자 내역 저장 diff --git a/config/market_intelligence.yaml b/config/market_intelligence.yaml new file mode 100644 index 00000000..82ef694d --- /dev/null +++ b/config/market_intelligence.yaml @@ -0,0 +1,26 @@ +# Research evidence only. These collectors cannot authorize or execute orders. +version: 1 +request_timeout_seconds: 12 +collection_budget_seconds: 120 +workers: 3 +disclosures: + enabled: true + lookback_calendar_days: 14 + max_pages_per_company: 4 + # Key comes only from DART_API_KEY / existing Config.dart, never this file. +investor_flows: + enabled: true + provider: naver_securities + lookback_sessions: 4 + # Provider offers only five recent rows; reserve one for excluded current day. + # Public web data, not a contracted KRX feed. Net quantities are shares, not KRW. +universe: + - {symbol: "005930", name: "삼성전자"} + - {symbol: "035420", name: "NAVER"} + - {symbol: "005380", name: "현대차"} + - {symbol: "051910", name: "LG화학"} + - {symbol: "005490", name: "POSCO홀딩스"} + - {symbol: "055550", name: "신한지주"} + - {symbol: "035720", name: "카카오"} + - {symbol: "012330", name: "현대모비스"} + - {symbol: "105560", name: "KB금융"} diff --git a/config/market_research.yaml b/config/market_research.yaml new file mode 100644 index 00000000..ef7846d1 --- /dev/null +++ b/config/market_research.yaml @@ -0,0 +1,83 @@ +# Observation list, not a trading universe. Updating this file cannot authorize orders. +version: 1 +name: "한국 주식·업종 관찰 레이더" +benchmark: + symbol: "069500" + name: "KODEX 200" + role: benchmark + cohort: benchmark + reference: "https://www.samsungfund.com/upload/kodex/newsroom/2026011317101936.pdf" +horizons: [20, 60, 120] +ranking_horizon: 60 +require_adjusted_prices: true +# Current constituents are an observation sample, never a historical market universe. +universe: + - {symbol: "005930", name: "삼성전자", cohort: basket_equities, role: basket_target, group: "반도체"} + - {symbol: "035420", name: "NAVER", cohort: basket_equities, role: basket_target, group: "인터넷"} + - {symbol: "005380", name: "현대차", cohort: basket_equities, role: basket_target, group: "자동차"} + - {symbol: "051910", name: "LG화학", cohort: basket_equities, role: basket_target, group: "화학"} + - {symbol: "005490", name: "POSCO홀딩스", cohort: basket_equities, role: basket_target, group: "철강"} + - {symbol: "055550", name: "신한지주", cohort: basket_equities, role: basket_target, group: "금융"} + - {symbol: "035720", name: "카카오", cohort: basket_equities, role: basket_target, group: "인터넷"} + - {symbol: "012330", name: "현대모비스", cohort: basket_equities, role: basket_target, group: "자동차부품"} + - {symbol: "105560", name: "KB금융", cohort: basket_equities, role: basket_target, group: "금융"} + - symbol: "091160" + name: "KODEX 반도체" + cohort: sector_etfs + role: observation_only + group: "반도체" + reference: "https://www.samsungfund.com/upload/kodex/newsroom/20260427141121713.pdf" + - symbol: "091170" + name: "KODEX 은행" + cohort: sector_etfs + role: observation_only + group: "은행" + reference: "https://m.samsungfund.com/etf/search.do?searchText=091170" + - symbol: "091180" + name: "KODEX 자동차" + cohort: sector_etfs + role: observation_only + group: "자동차" + reference: "https://www.samsungfund.com/upload/kodex/newsroom/2026011317101936.pdf" + - symbol: "305720" + name: "KODEX 2차전지산업" + cohort: sector_etfs + role: observation_only + group: "2차전지" + reference: "https://m.samsungfund.com/etf/product/view.do?id=2ETFB1" + - symbol: "266420" + name: "KODEX 헬스케어" + cohort: sector_etfs + role: observation_only + group: "헬스케어" + reference: "https://www.samsungfund.com/upload/kodex/newsroom/20251016084306186.pdf" + - symbol: "117700" + name: "KODEX 건설" + cohort: sector_etfs + role: observation_only + group: "건설" + reference: "https://www.samsungfund.com/etf/product/pensionlist.do" + - symbol: "117460" + name: "KODEX 에너지화학" + cohort: sector_etfs + role: observation_only + group: "에너지화학" + reference: "https://www.samsungfund.com/etf/product/pensionlist.do" + - symbol: "266370" + name: "KODEX IT" + cohort: sector_etfs + role: observation_only + group: "IT" + reference: "https://www.samsungfund.com/upload/kodex/newsroom/20251016084306186.pdf" + - symbol: "0115D0" + name: "KODEX K조선TOP10" + cohort: sector_etfs + role: observation_only + group: "조선" + reference: "https://www.samsungfund.com/fund/lounge/promotion/40.do" + - symbol: "0080G0" + name: "KODEX 방산TOP10" + cohort: sector_etfs + role: observation_only + group: "방산" + reference: "https://www.samsungfund.com/fund/lounge/promotion/40.do" diff --git a/config/paper_validation.yaml b/config/paper_validation.yaml new file mode 100644 index 00000000..7dd2433a --- /dev/null +++ b/config/paper_validation.yaml @@ -0,0 +1,20 @@ +# Forward paper observation protocol. These are research sample requirements, +# not target returns, proof of profitability, or permission to use a live account. +schema_version: 1 +protocol_version: "forward-paper-v2" +minimum_observations: 120 # Adjacent-session, benchmark-matched returns. +minimum_calendar_days: 180 +minimum_regime_observations: 20 # Separately required for rising/falling benchmark days. +maximum_missing_ratio: 0.05 +maximum_capture_delay_sessions: 2 +# A new policy/code fingerprint starts a new cohort; past closes never seed it. +# Price indices exclude dividends. They are opportunity-cost comparisons, not +# cash/exposure-matched or risk-adjusted measures. Cash and defensive ETF weights +# remain explicit in each cohort's immutable exposure policy. +# NAVER's public web API supplies price indices, not a contracted exchange feed. +# Provider and its implementation digest are fixed in the cohort protocol. +baskets: + kr_diversified_hold: + benchmark: {symbol: "KS11", name: "KOSPI 가격지수 (NAVER 공개)", basis: "price_index", provider: "naver_public"} + kr_pocket: + benchmark: {symbol: "KS200", name: "KOSPI 200 가격지수 (NAVER 공개)", basis: "price_index", provider: "naver_public"} diff --git a/config/research_operations.yaml b/config/research_operations.yaml new file mode 100644 index 00000000..f1a94ce2 --- /dev/null +++ b/config/research_operations.yaml @@ -0,0 +1,9 @@ +# Observational jobs run after trading work, in a separate bounded process. +enabled: true +retry_minutes: 60 +# Freshness follows completed trading sessions and the settled-close phase. +minimum_free_mb: 256 +jobs: + market: {enabled: true, timeout_seconds: 150} + intelligence: {enabled: true, timeout_seconds: 180} + paper_validation: {enabled: true, timeout_seconds: 150} diff --git a/config/research_theses.json b/config/research_theses.json new file mode 100644 index 00000000..8f6a4033 --- /dev/null +++ b/config/research_theses.json @@ -0,0 +1,72 @@ +{ + "schema_version": 1, + "curator": "2026-10-13 공식 원문 조사 초안 · 자동 뉴스 수집 아님", + "entries": [ + { + "id": "ai_electricity_constraints_20261013", + "title": "AI 확산과 전력 공급 제약을 함께 점검", + "theme": "AI · 데이터센터 · 전력 인프라", + "thesis": "IEA의 2026년 보고서는 데이터센터 투자 확대와 전력망·공급망 대응을 함께 다룬다. 연구 가설은 AI 수요 확대가 전력 인프라 투자 수요로 이어질 수 있다는 것이다. 국내 개별 기업의 수혜·수익성은 이 보고서만으로 확인할 수 없으며 수주, 매출 구성, 증설 비용과 가격을 추가 확인해야 한다.", + "invalidation_condition": "후속 자료에서 데이터센터 투자 축소, 효율 개선에 따른 전력 수요 전망 하향 또는 공급망 제약으로 인한 사업 지연이 확인되면 가설을 재검토한다. 기업별 실적 연결 근거가 없으면 종목 후보로 승격하지 않는다.", + "horizon": "산업 변화 관찰 6-24개월 · 월별 자료 재검토", + "review_at": "2026-11-13T09:00:00+09:00", + "expires_at": "2027-01-13T09:00:00+09:00", + "evidence_type": "industry_thesis", + "sources": [ + { + "url": "https://www.iea.org/reports/key-questions-on-energy-and-ai", + "title": "IEA — Key Questions on Energy and AI", + "published_at": null, + "published_date": "2026-04-16", + "publication_note": "공식 페이지의 Published 16 April 2026 확인. 시각·시간대가 없어 정확한 published_at은 미상. 현재 가격 신호 또는 기업 수혜 확정 근거가 아니다." + } + ], + "symbols": [], + "counterevidence": [ + "산업 수요 증가와 주주 수익은 다르다. 증설 비용, 경쟁, 규제, 이미 높은 주가가 수익성을 낮출 수 있다.", + "전력 공급 제약은 공급자의 매출 증가뿐 아니라 데이터센터 사업의 지연 요인일 수 있다.", + "보고서 공개 후 시간이 지났으므로 최신 투자 계획과 실제 전력 수요를 추가 확인해야 한다." + ] + }, + { + "id": "samsung_3q26_earnings_review", + "title": "삼성전자 3분기 가이던스와 확정 실적의 차이 검토", + "theme": "기업 실적 · 이익 지속성 · 자본 배분", + "thesis": "삼성전자 공식 뉴스룸은 2026년 10월 8일 3분기 실적 가이던스를 발표했고, IR 페이지는 10월 29일 오전 10시 KST 실적 설명회를 안내한다. 현재의 가이던스를 확정 결산으로 취급하지 않는다. 설명회와 후속 보고서에서 사업별 이익, 현금흐름, 투자 지출 및 전망을 검토해 보유 논거를 업데이트한다.", + "invalidation_condition": "확정 실적이 가이던스와 달라지거나 이익 증가가 일회성 항목에 의존하거나 현금 창출·후속 전망이 약해지면 기존 성장 가설을 재검토한다. 설명회 일정 변경 시 event_at도 새 버전으로 수정한다.", + "horizon": "2026년 3분기 실적 이벤트와 후속 분기 비교", + "event_at": "2026-10-29T10:00:00+09:00", + "review_at": "2026-10-29T18:00:00+09:00", + "expires_at": "2026-11-16T18:00:00+09:00", + "evidence_type": "scheduled_event", + "sources": [ + { + "url": "https://news.samsung.com/be/samsung-electronics-announces-earnings-guidance-for-third-quarter-2026", + "title": "Samsung Electronics Announces Earnings Guidance for Third Quarter 2026", + "published_at": null, + "published_date": "2026-10-08", + "publication_note": "공식 뉴스룸의 08-10-2026 확인. 공개 시각이 없어 published_at 미상. 잠정 가이던스이며 확정 결산 자료가 아니다." + }, + { + "url": "https://www.samsung.com/global/ir/", + "title": "Samsung Investor Relations — 3Q26 Earnings Call", + "published_at": null, + "published_date": null, + "publication_note": "2026-10-13 조회 시 2026-10-29 10:00 KST 일정 확인. 페이지 게시 시각은 미상이며 일정은 변경될 수 있다." + } + ], + "symbols": [ + { + "symbol": "005930", + "rationale": "삼성전자가 자사 분기 가이던스와 실적 설명회를 공지한 직접 기업 이벤트다. AI 수혜 정도 또는 매수 우위를 이 공지만으로 판단하지 않는다.", + "source_url": "https://www.samsung.com/global/ir/" + } + ], + "counterevidence": [ + "가이던스는 확정 실적이 아니며 세부 사업 및 현금흐름 정보가 충분하지 않다.", + "좋은 실적이 이미 가격에 반영됐다면 실적 성장 자체가 초과수익의 근거가 되지 않는다.", + "설명회 일정은 취소·변경될 수 있어 임박 시 원문을 재확인해야 한다." + ] + } + ] +} diff --git a/config/strategies.yaml b/config/strategies.yaml index 48c7098c..c3a2f276 100644 --- a/config/strategies.yaml +++ b/config/strategies.yaml @@ -161,7 +161,8 @@ mean_reversion: enabled: true # pykrx(우선) → yfinance(폴백) 순서로 조회 per_min: 0 # PER 하한 (0 = 적자 종목 제외). null 이면 검사 생략 per_max: 50 # PER 상한. null 이면 검사 생략 - debt_ratio_max: 200 # 부채비율(%) 상한. 미신뢰 소스(yfinance debtToEquity)는 자동 skip됨. DART/pykrx만 점수에 반영. + debt_ratio_max: 200 # 부채비율(%) 상한. 요구 기준과 다른 공급자 비율은 매수 필터를 통과하지 못함. + debt_ratio_basis: total_liabilities_to_equity_pct # 총부채/자본. 차입금/자본을 의도할 때만 interest_bearing_debt_to_equity_pct로 명시 변경. # --- 눌림목 전략 (C-3A) --- # 중기 상승 추세 + RSI 눌림목 + ADX 추세 확인. edge-trigger 진입. diff --git a/core/basket_close.py b/core/basket_close.py new file mode 100644 index 00000000..54965690 --- /dev/null +++ b/core/basket_close.py @@ -0,0 +1,339 @@ +"""Separate, reproducible close valuations from dated intraday observations. + +Only paper ledgers are finalized here. Historical rows lacking positions/quote +dates remain unverified; operating NAVs and their TWR chain are never rewritten. +""" +from datetime import datetime, timedelta +from contextlib import closing +import math +import os +from pathlib import Path +import sqlite3 +from zoneinfo import ZoneInfo + +KST = ZoneInfo("Asia/Seoul") +DIRECT_CLOSE_METHOD = "observed_close_to_close_start_period_flows" + + +def _local(value): + return value.astimezone(KST).replace(tzinfo=None) if value.tzinfo else value + + +def _close_lock(): + from core.runtime_lock import process_runtime_lock + directory = Path(os.environ.get("QUANT_RUNTIME_LOCK_DIR", Path(__file__).resolve().parents[1] / "data")) + return process_runtime_lock(directory / ".basket_close_collection.lock", label="바스켓 종가 관측") + + +def finalize_basket_closes(rebalancer, now=None): + with _close_lock() as acquired: + return _finalize_basket_closes(rebalancer, now) if acquired else [] + + +def _finalize_basket_closes(rebalancer, now=None): + from core.trading_hours import TradingHours + from core.portfolio_manager import twr_period_return + from database.models import TradeHistory, OperationEvent, get_session + from database.repositories import (append_portfolio_valuation, get_portfolio_valuations, + get_cash_flows) + + if rebalancer._ledger_mode() != "paper": + return [] + now = _local(now or datetime.now(KST)) + calendar = TradingHours(rebalancer.config) + key = rebalancer.account_key + valuations = get_portfolio_valuations(key, "paper") + closed = {v["date"][:10] for v in valuations if v["kind"] == "close"} + candidates = {} + for v in valuations: + day = v["date"][:10] + cutoff = datetime.combine(datetime.fromisoformat(day).date(), calendar.market_close) + # Give the provider at least 30 minutes to finalize today's daily bar. + if (v["kind"] == "intraday" and day not in closed and now >= cutoff + timedelta(minutes=30) + and calendar.is_trading_day(cutoff) + and datetime.fromisoformat(v["measured_at"]) <= cutoff): + candidates[day] = v + flows = get_cash_flows(key, "paper") + saved = [] + for day, baseline in sorted(candidates.items()): + boundary = datetime.fromisoformat(baseline["measured_at"]) + cutoff = datetime.combine(boundary.date(), calendar.market_close) + context = baseline["context"] + if "positions" not in context: + continue + quantities = {p["symbol"]: int(p["quantity"]) for p in context["positions"]} + cash = float(baseline["cash"]) + session = get_session() + try: + trades = session.query(TradeHistory).filter( + TradeHistory.account_key == key, TradeHistory.mode == "paper", + TradeHistory.executed_at > boundary, TradeHistory.executed_at <= cutoff, + ).order_by(TradeHistory.executed_at, TradeHistory.id).all() + finally: + session.close() + for t in trades: + if t.action not in ("BUY", "SELL"): + raise ValueError(f"Unknown trade action: {t.action}") + sign = 1 if t.action == "BUY" else -1 + quantities[t.symbol] = quantities.get(t.symbol, 0) + sign * t.quantity + # Slippage is already in the execution amount; do not charge it twice. + cash -= sign * t.total_amount + float(t.commission or 0) + float(t.tax or 0) + flow = sum(amount for at, amount in flows if boundary < at <= cutoff) + cash += flow + if any(q < 0 for q in quantities.values()) or cash < -1: + raise ValueError(f"Inconsistent close ledger: {key} {day}") + holdings = [] + for symbol, quantity in quantities.items(): + if quantity == 0: + continue + df = rebalancer.data_collector.fetch_korean_stock(symbol, day, day) + if df is None or df.empty or "close" not in df: + break + dates = df["date"] if "date" in df else df.index + matches = [i for i, d in enumerate(dates) if str(d)[:10] == day] + if len(matches) != 1: + break + price = float(df["close"].iloc[matches[0]]) + if not math.isfinite(price) or price <= 0: + break + holdings.append({"symbol": symbol, "quantity": quantity, "price": price, + "quote_date": day, "source": rebalancer.data_collector.get_last_source_info().get("source") or "unknown", + "observed_at": now.isoformat()}) + else: + total = cash + sum(p["quantity"] * p["price"] for p in holdings) + if baseline["total_value"] + flow <= 0: + raise ValueError(f"Close return has no funded baseline: {key} {day}") + index = (1 + baseline["cumulative_return"] / 100) * (1 + twr_period_return(baseline["total_value"], total, flow)) + prior = [v for v in valuations if v["kind"] == "close" and v["date"][:10] < day] + # Once direct observations start at index=1, never splice the old + # operating intraday index back into that prospective close series. + direct_chain = prior and prior[-1]["context"].get("return_method") == DIRECT_CLOSE_METHOD + if direct_chain: + prior_close = prior[-1] + flow = sum(amount for at, amount in flows + if datetime.fromisoformat(prior_close["measured_at"]) < at <= cutoff) + if prior_close["total_value"] + flow <= 0: + raise ValueError("Close return has no funded previous close") + index = (1 + prior_close["cumulative_return"] / 100) * total / (prior_close["total_value"] + flow) + prior_index = 1 + prior[-1]["cumulative_return"] / 100 if prior else None + daily = (index / prior_index - 1) * 100 if prior_index and prior_index > 0 else 0.0 + peak = max([1.0, index] + [1 + v["cumulative_return"] / 100 for v in prior]) + close_context = {"valuation_kind": "close", "positions": holdings, "quote_dates": [day], + "baseline_valuation_id": baseline["id"], "baseline_measured_at": boundary.isoformat(), + "trade_ids": [t.id for t in trades], "flow_since": flow, + "return_method": "linked_measurements_start_period_flows", + "daily_return_available": prior_index is not None} + if direct_chain: + with get_session() as session: + cutoff_trade_ids = [row[0] for row in session.query(TradeHistory.id).filter( + TradeHistory.account_key == key, TradeHistory.mode == "paper", + TradeHistory.executed_at <= cutoff, + ).order_by(TradeHistory.executed_at, TradeHistory.id).all()] + close_context.update(return_method=DIRECT_CLOSE_METHOD, first_close_in_series=False, + inventory_baseline_valuation_id=baseline["id"], + baseline_valuation_id=prior_close["id"], baseline_measured_at=prior_close["measured_at"], + ledger_cutoff_at=cutoff.isoformat(), flow_since=flow, trade_ids=cutoff_trade_ids, + initial_capital=float(rebalancer.portfolio_mgr.initial_capital), observation_only=True) + append_portfolio_valuation(account_key=key, mode="paper", day=day, measured_at=cutoff, + total_value=total, cash=cash, cumulative_return=(index - 1) * 100, + daily_return=daily, mdd=(1 - index / peak) * 100, context=close_context) + saved.append(day) + valuations = get_portfolio_valuations(key, "paper") + continue + # A stale/missing quote is visible even though no false close was stored. + from core.cycle_observability import record_cycle_event + with get_session() as session: + seen = session.query(OperationEvent.id).filter_by( + event_type="CLOSE_VALUATION_MISSING_PRICE", strategy=key, mode="paper", detail=f"{day}:{symbol}", + ).first() + if not seen: + record_cycle_event("CLOSE_VALUATION_MISSING_PRICE", f"종가 평가 보류: {key} {day} {symbol} 당일 가격 미확인", + strategy=key, mode="paper", severity="warning", detail=f"{day}:{symbol}") + return saved + + +def _finite(value, *, positive=False): + if isinstance(value, bool): + raise ValueError("boolean ledger number") + number = float(value) + if not math.isfinite(number) or (positive and number <= 0): + raise ValueError("invalid ledger number") + return number + + +def _close_coverage(config, baskets, day): + """Read presence only; never initialize a database or claim source validation.""" + expected = [name for name, basket in baskets.items() if basket.get("enabled") is True] + result = {"session": day, "expected": len(expected), "present": 0, "missing": len(expected), + "verification": "presence_only", "baskets": [ + {"basket": name, "status": "missing", "close_count": 0, "latest_close_session": None} + for name in expected]} + path = Path(config.database.get("sqlite_path", "data/quant_trader.db")) + if not path.is_absolute(): + path = Path(__file__).resolve().parents[1] / path + if config.database.get("type") == "postgresql" or not path.is_file(): + return {**result, "status": "unavailable", "reason": "close_ledger_missing"} + try: + with closing(sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True, timeout=5)) as connection: + connection.execute("PRAGMA query_only=ON") + table = connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='portfolio_valuations'").fetchone() + if not table: + return {**result, "status": "missing", "reason": "close_schema_missing"} + for item in result["baskets"]: + key = f"basket_rebalance:{item['basket']}" + count, latest = connection.execute( + "SELECT SUM(CASE WHEN substr(date,1,10)=? THEN 1 ELSE 0 END), MAX(substr(date,1,10)) " + "FROM portfolio_valuations WHERE account_key=? AND mode='paper' AND kind='close'", + (day, key), + ).fetchone() + item.update(close_count=int(count or 0), latest_close_session=latest, + status="present" if count else "missing") + result["present"] = sum(item["status"] == "present" for item in result["baskets"]) + result["missing"] = result["expected"] - result["present"] + return {**result, "status": "complete" if not result["missing"] else "missing"} + except sqlite3.Error: + return {**result, "status": "unavailable", "reason": "close_ledger_read_failed"} + + +def _collect_one_close(name, basket, config, collector, now, cutoff): + """Observe today's close using the actual paper trade/flow ledger at cutoff.""" + from database.models import TradeHistory, Position, CashFlow, get_session + from database.repositories import append_portfolio_valuation, get_portfolio_valuations + + key, day = f"basket_rebalance:{name}", cutoff.date().isoformat() + valuations = get_portfolio_valuations(key, "paper", kind="close") + if any(row["date"][:10] == day for row in valuations): + return {"basket": name, "status": "already_recorded", "day": day, "saved": False} + with get_session() as session: + trades = session.query(TradeHistory).filter_by(account_key=key, mode="paper").order_by(TradeHistory.executed_at, TradeHistory.id).all() + flows = session.query(CashFlow).filter_by(account_key=key, mode="paper").all() + positions = session.query(Position).filter_by(account_key=key, mode="paper").all() + initial = _finite(basket.get("initial_capital", config.risk_params.get("position_sizing", {}).get("initial_capital", 10000000)), positive=True) + cash, quantities, current_quantities, trade_ids = initial, {}, {}, [] + for trade in trades: + if trade.action not in {"BUY", "SELL"} or trade.executed_at > now: + raise ValueError("invalid trade chronology or side") + quantity = _finite(trade.quantity, positive=True) + if int(quantity) != quantity: + raise ValueError("noninteger ledger quantity") + sign = 1 if trade.action == "BUY" else -1 + current_quantities[trade.symbol] = current_quantities.get(trade.symbol, 0) + sign * int(quantity) + if trade.executed_at > cutoff: + continue + amount, commission, tax = (_finite(getattr(trade, field)) for field in ("total_amount", "commission", "tax")) + if amount <= 0 or min(commission, tax) < 0: + raise ValueError("negative trade amount or costs") + quantities[trade.symbol] = quantities.get(trade.symbol, 0) + sign * int(quantity) + cash -= sign * amount + commission + tax + trade_ids.append(trade.id) + held = {} + for position in positions: + quantity = _finite(position.quantity) + if quantity < 0 or int(quantity) != quantity or position.symbol in held: + raise ValueError("invalid current position quantity") + if quantity: + held[position.symbol] = int(quantity) + if held != {symbol: q for symbol, q in current_quantities.items() if q}: + raise ValueError("paper position and trade ledger disagree") + valid_flows = [(flow.occurred_at, _finite(flow.amount)) for flow in flows] + if any(at > now for at, _ in valid_flows): + raise ValueError("cash flow in the future") + cash += sum(amount for at, amount in valid_flows if at <= cutoff) + if cash < -1 or any(q < 0 for q in quantities.values()): + raise ValueError("invalid cutoff cash or inventory") + holdings = [] + for symbol, quantity in sorted(quantities.items()): + if not quantity: + continue + frame = collector.fetch_korean_stock(symbol, day, day) + if frame is None or frame.empty or "close" not in frame: + raise ValueError("exact session close missing") + dates = frame["date"] if "date" in frame else frame.index + matches = [i for i, value in enumerate(dates) if str(value)[:10] == day] + if len(matches) != 1: + raise ValueError("exact session close missing or duplicated") + source_info = collector.get_last_source_info() + source = (source_info.get("history") or {}).get(symbol) or source_info.get("source") + if not isinstance(source, str) or not source or source == "unknown": + raise ValueError("close source unavailable") + holdings.append({"symbol": symbol, "quantity": quantity, + "price": _finite(frame["close"].iloc[matches[0]], positive=True), + "quote_date": day, "source": source, "observed_at": now.isoformat()}) + total = cash + sum(row["quantity"] * row["price"] for row in holdings) + _finite(total, positive=True) + prior = [row for row in valuations if row["date"][:10] < day] + baseline = prior[-1] if prior else None + boundary = datetime.fromisoformat(baseline["measured_at"]) if baseline else cutoff + flow = sum(amount for at, amount in valid_flows if boundary < at <= cutoff) + if baseline: + denominator = _finite(baseline["total_value"], positive=True) + flow + if denominator <= 0: + raise ValueError("close interval has no funded baseline") + previous_index = 1 + _finite(baseline["cumulative_return"]) / 100 + _finite(previous_index, positive=True) + index = previous_index * total / denominator + daily = (index / previous_index - 1) * 100 + else: + index, daily = 1.0, 0.0 # First observation anchors a series, not historical profit. + peak = max([1.0, index] + [1 + row["cumulative_return"] / 100 for row in prior]) + context = {"valuation_kind": "close", "positions": holdings, "quote_dates": [day], + "return_method": DIRECT_CLOSE_METHOD, "first_close_in_series": baseline is None, + "baseline_valuation_id": baseline["id"] if baseline else None, + "baseline_measured_at": boundary.isoformat(), "ledger_cutoff_at": cutoff.isoformat(), + "flow_since": flow, "trade_ids": trade_ids, "initial_capital": initial, + "daily_return_available": baseline is not None, "observation_only": True} + append_portfolio_valuation(account_key=key, mode="paper", day=day, measured_at=cutoff, + total_value=total, cash=cash, cumulative_return=(index - 1) * 100, + daily_return=daily, mdd=(1 - index / peak) * 100, context=context) + return {"basket": name, "status": "recorded", "day": day, "saved": True, + "total_value": total, "positions": len(holdings), "first_close_in_series": baseline is None} + + +def collect_basket_closes(config=None, *, now=None, collector_factory=None): + """After-close observation only; before settlement inspect presence read-only. + + This intentionally never invents yesterday's inventory from today's holdings. + Existing finalize_basket_closes may recover dated genuine intraday baselines. + """ + from config.config_loader import Config + from core.trading_hours import TradingHours + config = config or Config.get() + observed = _local(now or datetime.now(KST)) + calendar = TradingHours(config) + cutoff = datetime.combine(observed.date(), calendar.market_close) + ready = calendar.is_trading_day(observed) and observed >= cutoff + timedelta(minutes=30) + completed = observed if ready else observed - timedelta(days=1) + while not calendar.is_trading_day(completed): + completed -= timedelta(days=1) + completed_day = completed.date().isoformat() + report = {"schema_version": 1, "observed_at": observed.isoformat(), "day": observed.date().isoformat(), + "expected_session": completed_day, "last_completed_session": completed_day, + "observation_only": True, "orders_submitted": 0, "rows_saved": 0, "baskets": []} + if str(config.trading.get("mode", "paper")).lower() == "live": + return {**report, "status": "disabled", "reason": "paper_ledgers_only"} + from core.basket_rebalancer import BasketRebalancer + baskets = BasketRebalancer._load_baskets_config() + report["coverage"] = _close_coverage(config, baskets, completed_day) + if not ready: + missing = report["coverage"]["status"] != "complete" + return {**report, "status": "previous_close_missing" if missing else "awaiting_close", + "reason": "last_completed_session_close_missing" if missing else "same_day_final_close_not_available_yet"} + from core.data_collector import DataCollector + factory = collector_factory or (lambda: DataCollector(config)) + with _close_lock() as acquired: + if not acquired: + return {**report, "status": "busy", "reason": "close_collection_already_running"} + for name, basket in baskets.items(): + if basket.get("enabled") is not True: + continue + try: + result = _collect_one_close(name, basket, config, factory(), observed, cutoff) + except Exception as exc: + result = {"basket": name, "status": "unavailable", "saved": False, + "reason": type(exc).__name__} + report["baskets"].append(result) + report["rows_saved"] = sum(row["saved"] for row in report["baskets"]) + successes = sum(row["status"] in {"recorded", "already_recorded"} for row in report["baskets"]) + report["status"] = "completed" if report["baskets"] and successes == len(report["baskets"]) else ("partial" if successes else "unavailable") + report["coverage"] = _close_coverage(config, baskets, completed_day) + return report diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index b3a48536..3eabedeb 100644 --- a/core/basket_evaluation.py +++ b/core/basket_evaluation.py @@ -152,7 +152,7 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s if m.get("nav_return_pct") is not None: bench = ( f" | KS11 {m['benchmark_return_pct']:+.2f}%" - if m.get("benchmark_return_pct") is not None else " | KS11 조회 불가" + if m.get("benchmark_return_pct") is not None else "" ) lines.append(f" 성과(참고): NAV {m['nav_return_pct']:+.2f}%{bench}") lines.append(" ※ 베타 전략 — '시장을 이겼는가'는 합격 기준이 아님(비용·무결성·추종이 기준)") @@ -166,6 +166,18 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s if comp is not None: lines.append(f" - 구성 격차 {comp:+.2f}%p (설계 수용: 균등가중 vs 시총지수)") errors = m.get("paper_order_errors") + if m.get("comparison_basis_note"): + lines.append(" 비교: " + m["comparison_basis_note"]) + close = m.get("close_comparison") + if close: + bench = close.get("benchmark_return_pct") + lines.append(f" 종가 구간 {close['start']} ~ {close['end']}: NAV {close['nav_return_pct']:+.2f}%" + (f" | KS11 {bench:+.2f}%" if bench is not None else " | KS11 같은 구간 자료 없음")) + entry = result.get("entry_status") or {} + if entry: + lines.append(f" 최근 평가의 진입 상태: {entry.get('status')} (측정 {entry.get('measured_at') or '없음'})") + lines.append(f" {entry.get('note', '')}") + rejected = m.get("recent_order_rejections") + lines.append(f" 최근 7일 주문 거부 기록: {rejected if rejected is not None else '조회 불가'}건 (동일 일자·원인은 중복 제외)") if errors: lines.append( f" 참고: 운영 중 주문·실행 오류 {errors}건 (합격 판단에는 넣지 않음 — 원인 확인 필요)" @@ -349,6 +361,7 @@ def compute_design_portfolio_return( today: Any, *, fetch: Any = None, + require_complete: bool = False, ) -> float | None: """설계 포트폴리오(목표비중 buy&hold, 주식 비중 design_stock_fraction)의 구간 수익%(단위: %). @@ -374,6 +387,8 @@ def compute_design_portfolio_return( except Exception: r = None if r is None: + if require_complete and float(weight) > 0: + return None continue wn = float(weight) / total_w weighted_sum += wn * float(r) @@ -457,7 +472,8 @@ def _config_date(value): except ValueError: return None - today = date.today() + from core.trading_hours import _now_kst + today = _now_kst().date() candidates = [_d(t.executed_at) for t in trades if getattr(t, "executed_at", None)] candidates += [_d(s.date) for s in snaps] operation_start = min(candidates) if candidates else today @@ -531,11 +547,23 @@ def _config_date(value): nav_end = _d(snaps[-1].date) if snaps else today benchmark_return_pct = None - if include_benchmark: + from database.repositories import get_portfolio_valuations + close_by_day = {v["date"][:10]: v for v in get_portfolio_valuations(basket_key, "paper", kind="close")} + design_from = _config_date((basket_cfg.get("promotion") or {}).get("design_effective_from")) + close_rows = [v for day, v in sorted(close_by_day.items()) if not design_from or date.fromisoformat(day) >= design_from] + comparison = None + if len(close_rows) >= 2: + start_close, end_close = close_rows[0], close_rows[-1] + base_index = 1 + start_close["cumulative_return"] / 100 + if base_index > 0: + comparison = {"start": start_close["date"][:10], "end": end_close["date"][:10], + "nav_return_pct": ((1 + end_close["cumulative_return"] / 100) / base_index - 1) * 100, + "benchmark_return_pct": None} + if include_benchmark and comparison: try: from core.data_collector import DataCollector - benchmark_return_pct = DataCollector.fetch_benchmark_return( - str(operation_start), str(nav_end), symbol="KS11", + comparison["benchmark_return_pct"] = DataCollector.fetch_benchmark_return( + comparison["start"], comparison["end"], symbol="KS11", exact_boundaries=True, ) except Exception: benchmark_return_pct = None # 참고 지표 — 실패해도 평가는 진행 @@ -557,6 +585,10 @@ def _config_date(value): ) promotion = basket_cfg.get("promotion") or {} + result.setdefault("metrics", {})["close_comparison"] = comparison + result["metrics"]["comparison_basis_note"] = ( + "별도 종가 평가 기준" if comparison else "동일 시점 종가 평가 기록 부족 — 벤치마크·귀속 비교 보류" + ) # A2(미해결 실패 주문 0건)는 paper에서는 애초에 걸릴 수가 없다 — dead-letter는 증권사 # 주문 경로에서만 쌓인다. 그래서 paper 트랙의 주문·사이클 오류 이벤트를 따로 세어 @@ -585,6 +617,28 @@ def _config_date(value): # 평가 결과에 metrics가 없을 수도 있다(판정 함수를 바꿔 끼운 경우 등) — 집계 실패가 # 평가 전체를 깨뜨리지 않게 setdefault로 넣는다. result.setdefault("metrics", {})["paper_order_errors"] = paper_order_errors + from core.basket_observability import snapshot_entry_status + latest = snaps[-1] if snaps else None + result["entry_status"] = snapshot_entry_status( + {k: getattr(latest, k, None) for k in ("date", "created_at", "mdd", "daily_return")} if latest else None, + basket_cfg, config.risk_params, + ) + session = get_session() + try: + rejected = session.query(OperationEvent).filter( + OperationEvent.strategy == basket_key, OperationEvent.mode == "paper", + OperationEvent.event_type == "ORDER_REJECTED", + OperationEvent.created_at >= datetime.combine(today - timedelta(days=6), datetime.min.time()), + ).order_by(OperationEvent.created_at.desc()).all() + result["metrics"]["recent_order_rejections"] = len(rejected) + result["entry_status"]["last_rejection"] = ( + {"at": str(rejected[0].created_at), "reason": rejected[0].message} if rejected else None + ) + except Exception as exc: + result["metrics"]["recent_order_rejections"] = None + logger.warning("paper 주문 거부 집계 실패: {}", exc) + finally: + session.close() # 성과 귀속(실행 격차/구성 격차) — 종목별 조회(네트워크)라 기본 off. # 일일 사이클(리포트 부가필드)은 호출하지 않고, CLI 평가 도구에서만 켠다. @@ -593,35 +647,24 @@ def _config_date(value): # (비중·종목)를 운영 시작일부터 소급 적용하면, 설계를 바꾼 사실 자체가 '실행 격차 # ≈ 0'으로 보이거나 엉뚱한 격차로 섞인다(kr_diversified_hold는 2026-08-07에 80% → # 60%, 10 → 9종목으로 바뀌었다). NAV·설계·벤치마크를 같은 창으로 다시 잰다. - if include_attribution: + from core.risk_overlays import parse_overlay_config + if include_attribution and comparison and not parse_overlay_config(basket_cfg).any_enabled: from core.basket_deploy import effective_stock_fraction design_fraction = effective_stock_fraction(basket_cfg, config.risk_params) holdings = basket_cfg.get("holdings") or {} - design_from = _config_date(promotion.get("design_effective_from")) - window_start = operation_start - window_nav = nav_return_pct - window_bench = benchmark_return_pct - if design_from and design_from > operation_start and snaps: - window_start = design_from - base = [s for s in snaps if _d(s.date) < design_from] - base_cum = float(base[-1].cumulative_return or 0.0) if base else 0.0 - end_cum = float(snaps[-1].cumulative_return or 0.0) - window_nav = ((1 + end_cum / 100) / (1 + base_cum / 100) - 1) * 100 - window_bench = None - if include_benchmark: - try: - from core.data_collector import DataCollector - window_bench = DataCollector.fetch_benchmark_return( - str(design_from), str(nav_end), symbol="KS11", - ) - except Exception as exc: - logger.warning("격차 계산 기간의 벤치마크 조회 실패: {}", exc) + window_start = date.fromisoformat(comparison["start"]) + nav_end = date.fromisoformat(comparison["end"]) + window_nav = comparison["nav_return_pct"] + window_bench = comparison["benchmark_return_pct"] design_return_pct = None if snaps and holdings: try: + from core.data_collector import DataCollector # 벤치마크·NAV와 같은 창(window_start ~ nav_end)으로 조회 — 창 불일치 방지. design_return_pct = compute_design_portfolio_return( holdings, design_fraction, window_start, nav_end, + fetch=lambda start, end, symbol: DataCollector.fetch_benchmark_return(start, end, symbol=symbol, exact_boundaries=True), + require_complete=True, ) except Exception as exc: logger.warning("설계 포트폴리오 수익률 계산 실패: {}", exc) @@ -683,4 +726,9 @@ def _config_date(value): # 적립금 미기록 같은 실제 할 일을 가린다(2026-09 적립 알림이 이 문구에 가려졌다). result["review_note"] = note + if result["entry_status"]["status"] == "BLOCKED": + result.setdefault("operation_verdict", result["verdict"]) + if result["verdict"] == "PASS_CANDIDATE": + result["verdict"] = "WAIT" + result["issues"].extend(result["entry_status"]["reasons"]) return result, basket_name diff --git a/core/basket_observability.py b/core/basket_observability.py new file mode 100644 index 00000000..77760d57 --- /dev/null +++ b/core/basket_observability.py @@ -0,0 +1,158 @@ +"""Read-only basket diagnostics. Historical evidence is not order authorization.""" +from __future__ import annotations + +import calendar +import math +from datetime import date, datetime, timedelta + + +def snapshot_entry_status(snapshot, basket, risk_params): + """Report risk blocks visible in the last measurement, never promise a fill.""" + result = { + "status": "RECHECK_REQUIRED", "allowed": None, "reasons": [], + "basis": "latest_snapshot", "measured_at": None, + "note": "주문 직전 가격·잔고·거래 시간과 전체 한도를 다시 확인합니다.", + } + if not snapshot: + result["note"] = "평가 기록 없음 — 주문 가능 여부 미확인" + return result + result["measured_at"] = str(snapshot.get("created_at") or snapshot.get("date") or "") + # An explicit basket drawdown rule owns both MDD and daily-loss policy. + delegated = bool(((basket.get("overlays") or {}).get("drawdown_guard") or {}).get("enabled")) + limits = risk_params.get("drawdown") or {} + if not delegated: + for field, limit_field, label in ( + ("mdd", "max_portfolio_mdd", "낙폭"), + ("daily_return", "max_daily_loss", "일일 손실"), + ): + try: + value = float(snapshot.get(field)) + limit = float(limits.get(limit_field) or 0) * 100 + loss = abs(value) if field == "mdd" else -value + if not math.isfinite(value) or not math.isfinite(limit): + raise ValueError("nonfinite") + if limit > 0 and loss >= limit: + result["reasons"].append(f"최근 평가 기준 {label} {loss:.2f}% ≥ 한도 {limit:.2f}%") + except (TypeError, ValueError): + result["note"] = "손실 한도 자료 미확인 — 주문 직전 재검사 필요" + if result["reasons"]: + result.update(status="BLOCKED", allowed=False) + return result + + +def allocation_diagnostics(total, cash, positions, targets, defensive_symbol=None, tolerance=0.08): + """positions contain measured market values, never acquisition-cost weights.""" + if not math.isfinite(float(total or 0)) or total <= 0: + return {"status": "UNKNOWN", "assets": [], "issues": ["시장가격 평가 자료 없음"]} + try: + measured = {p["symbol"]: float(p["quantity"]) * float(p["price"]) for p in positions} + except (TypeError, ValueError, KeyError): + return {"status": "UNKNOWN", "assets": [], "issues": ["종목별 시장가격 미확인"]} + if not all(math.isfinite(v) and v >= 0 for v in measured.values()): + return {"status": "UNKNOWN", "assets": [], "issues": ["종목별 평가금액 확인 필요"]} + if abs(sum(measured.values()) + cash - total) > max(1.0, total * 1e-6): + return {"status": "UNKNOWN", "assets": [], "issues": ["종목별 평가금액과 총평가액 불일치"]} + assets = [] + groups = [("equity", "주식", [s for s in set(measured) | set(targets) if s != defensive_symbol])] + if defensive_symbol: + groups.append(("defensive", "CD·방어 자산", [defensive_symbol])) + for key, label, symbols in groups: + actual = sum(measured.get(s, 0) for s in symbols) / total + target = sum(targets.get(s, 0) for s in symbols) + assets.append({"asset": key, "label": label, "actual": actual, "target": target, "gap": actual - target}) + target_cash = max(0.0, 1 - sum(targets.values())) + assets.append({"asset": "cash", "label": "현금", "actual": cash / total, "target": target_cash, "gap": cash / total - target_cash}) + issues = [f"{a['label']} 비중 {a['actual']:.1%}, 목표 {a['target']:.1%} ({a['gap'] * 100:+.1f}%p)" + for a in assets if abs(a["gap"]) > tolerance] + return {"status": "ATTENTION" if issues else "OK", "assets": assets, "issues": issues} + + +def monthly_contribution_status(plan, flows, track_start, today): + """Closed calendar months only; partial deposits do not reset the schedule. + + The opening month is funded by initial capital. The current month remains + pending unless an explicit due_day has elapsed. No synthetic deposit is made. + """ + result = {"status": "OK", "months": [], "missing_amount": 0.0, "issues": []} + if not plan.get("enabled") or plan.get("cadence", "monthly") != "monthly" or not track_start: + return result + amount = float(plan.get("amount") or 0) + if amount <= 0: + return result + start = date.fromisoformat(str(track_start)[:10]) + today = date.fromisoformat(str(today)[:10]) + month = (start.replace(day=28) + timedelta(days=4)).replace(day=1) + paid = {} + for flow in flows: + occurred = str((flow.get("occurred_at") if isinstance(flow, dict) else flow[0]) or "") + value = float((flow.get("amount") if isinstance(flow, dict) else flow[1]) or 0) + if occurred[:10] <= str(today) and value > 0: + paid[occurred[:7]] = paid.get(occurred[:7], 0) + value + while month <= today.replace(day=1): + due_day = plan.get("due_day") + due = month < today.replace(day=1) + if due_day is not None: + due = today >= month.replace(day=min(max(int(due_day), 1), calendar.monthrange(month.year, month.month)[1])) + key = month.strftime("%Y-%m") + shortfall = max(0.0, amount - paid.get(key, 0)) + result["months"].append({"month": key, "planned": amount, "recorded": paid.get(key, 0), "due": due, "shortfall": shortfall}) + if due and shortfall: + result["status"] = "ATTENTION" + result["missing_amount"] += shortfall + result["issues"].append(f"{key} 적립 기록 {paid.get(key, 0):,.0f}/{amount:,.0f}원 — {shortfall:,.0f}원 확인 필요") + month = (month.replace(day=28) + timedelta(days=4)).replace(day=1) + return result + + +def valuation_context(market_snapshot, positions, measured_at, valuation_date, market_close): + """A daily bar becomes a close only after the session and on its own date.""" + holdings = [] + for p in positions: + quote = market_snapshot.get(p.symbol) or {} + holdings.append({"symbol": p.symbol, "quantity": p.quantity, "price": quote.get("price"), + "quote_date": quote.get("quote_date"), "source": quote.get("source", "unknown"), + "observed_at": quote.get("observed_at")}) + dates = {p["quote_date"] for p in holdings} + day = str(valuation_date)[:10] + same_date = dates == {day} or not holdings + kind = "unverified" + if same_date and day == str(measured_at.date()): + if measured_at.time() < market_close: + kind = "intraday" + elif all(p["observed_at"] and datetime.fromisoformat(p["observed_at"]).time() >= market_close for p in holdings): + kind = "close" + return {"valuation_kind": kind, "positions": holdings, "quote_dates": sorted(str(d) for d in dates), + "measured_at": measured_at.isoformat(), "valuation_date": day} + + +def read_basket_diagnostics(basket, account_key, risk_params, mode="paper", today=None): + """Shared dashboard/health view; DB reads only, no broker/price requests.""" + from database.models import PortfolioSnapshot, get_session + from database.repositories import get_cash_flows, get_portfolio_valuations + from zoneinfo import ZoneInfo + + today = today or datetime.now(ZoneInfo("Asia/Seoul")).date() + session = get_session() + try: + rows = session.query(PortfolioSnapshot).filter_by(account_key=account_key, mode=mode).order_by(PortfolioSnapshot.date).all() + latest = rows[-1] if rows else None + snap = {k: getattr(latest, k) for k in ("date", "created_at", "total_value", "cash", "mdd", "daily_return")} if latest else None + first = rows[0].date if rows else None + finally: + session.close() + valuations = get_portfolio_valuations(account_key, mode) + matched = [v for v in valuations if snap and snap["created_at"] and v["measured_at"] == snap["created_at"].isoformat() + and v["total_value"] == snap["total_value"] and v["cash"] == snap["cash"]] + latest_measurement = matched[-1] if matched else None + context = (latest_measurement or {}).get("context") or {} + closes = [v for v in valuations if v["kind"] == "close"] + return { + "entry_status": snapshot_entry_status(snap, basket, risk_params), + "allocation": context.get("allocation") or {"status": "UNKNOWN", "assets": [], "issues": ["종목별 시장가격 측정 기록 대기"]}, + "plan_diagnostics": context.get("plan_diagnostics") or [], + "measurement": {"kind": (latest_measurement or {}).get("kind", "unverified"), + "measured_at": str(snap["created_at"]) if snap else None, + "quote_dates": context.get("quote_dates") or [], "positions": context.get("positions") or []}, + "latest_close": closes[-1] if closes else None, + "contribution_status": monthly_contribution_status(basket.get("contribution_plan") or {}, get_cash_flows(account_key, mode), first, today), + } diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 975fa85d..6ea22861 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -89,6 +89,9 @@ def __init__(self, symbol: str, action: str, quantity: int, price: float, reason self.quantity = quantity self.price = price self.reason = reason + # Capture when this plan chose its price; never reconstruct from old trades. + self.decision_price = price + self.decision_at = datetime.now(_KST).isoformat() def __repr__(self): return f"" @@ -246,23 +249,33 @@ def overlay_decision(self): save_overlay_state(self.basket_name, decision, mode=self._ledger_mode()) except OSError as exc: logger.warning("바스켓 '{}' 오버레이 상태 저장 실패: {}", self.basket_name, exc) + # Source dates advance every session; that alone is not a new incident. + import re + def issue_key(issues): + return sorted(re.sub(r"\d{4}-\d{2}-\d{2}", "", str(i)) for i in issues) + previous_issues = (prev or {}).get("data_issues") or [] + issue_changed = issue_key(previous_issues) != issue_key(decision.data_issues) if decision.data_issues: logger.warning("바스켓 '{}' 오버레이 데이터 문제: {}", self.basket_name, "; ".join(decision.data_issues)) # 로그만으로는 헬스·주간 리포트가 못 센다(9/17 이후 추세 필터 동결이 로그에만 - # 남아 있었다). 같은 날 같은 내용은 한 번만 남긴다. + # 남아 있었다). 원인 변경·발생·복구 때만 이벤트를 남긴다. try: - from core.cycle_observability import record_event_once_per_day - - record_event_once_per_day( - "OVERLAY_DATA_ISSUE", - f"바스켓 '{self.basket_name}' 위험 관리에 쓸 자료 문제: " + "; ".join(decision.data_issues), - severity="warning", - strategy=getattr(self, "account_key", None) or None, - mode=self._ledger_mode(), - dedupe_key="; ".join(decision.data_issues)[:500], - ) + from core.cycle_observability import record_cycle_event + + if issue_changed: + record_cycle_event( + "OVERLAY_DATA_ISSUE", + f"바스켓 '{self.basket_name}' 위험 관리에 쓸 자료 문제: " + "; ".join(decision.data_issues), + severity="warning", + strategy=getattr(self, "account_key", None) or None, + mode=self._ledger_mode(), + ) except Exception as exc: logger.warning("오버레이 데이터 문제 이벤트 기록 실패: {}", exc) + elif previous_issues: + from core.cycle_observability import record_cycle_event + record_cycle_event("OVERLAY_DATA_RECOVERED", f"바스켓 '{self.basket_name}' 위험 관리 자료 복구", + strategy=self.account_key, mode=self._ledger_mode()) if decision.reasons: logger.info("바스켓 '{}' 리스크 오버레이 발동: {} (배수 {})", self.basket_name, " · ".join(decision.reasons), decision.scale) else: @@ -426,6 +439,13 @@ def save_daily_nav_snapshot(self) -> bool: 커버리지에 영원히 안 잡히는 낭비였다). (account_key, date) upsert 멱등. """ try: + from core.trading_hours import TradingHours + from core.basket_observability import valuation_context + now = datetime.now(_KST).replace(tzinfo=None) + th = TradingHours(self.config) + if th.is_trading_day(now) and now.time() < th.market_open: + logger.info("바스켓 '{}' 장전 — 오늘 NAV 측정 보류", self.basket_name) + return False snapshot = getattr(self, "_market_snapshot", None) or self._fetch_market_snapshot() prices = {s: v["price"] for s, v in snapshot.items()} positions = get_all_positions( @@ -439,9 +459,14 @@ def save_daily_nav_snapshot(self) -> bool: self.basket_name, missing, ) return False + day = self._nav_attribution_date() + context = valuation_context(snapshot, positions, now, day, th.market_close) + context["allocation"] = self.diagnose_deployment(prices).get("allocation") + context["plan_diagnostics"] = getattr(self, "_plan_diagnostics", []) ok = self.portfolio_mgr.save_daily_snapshot( current_prices=prices or None, - snapshot_date=self._nav_attribution_date(), + snapshot_date=day, + valuation_context=context, ) # 저장 실패(예: DB 제약 충돌)를 성공으로 보고하면 관측성이 거짓 # SNAPSHOT_SAVED를 남긴다 — 반환값을 그대로 전파한다. @@ -462,6 +487,8 @@ def _nav_attribution_date(self) -> datetime: th = TradingHours(self.config) d = now + if th.is_trading_day(d) and d.time() < th.market_open: + d -= timedelta(days=1) for _ in range(15): # 최장 연휴 커버 if th.is_trading_day(d): return d @@ -618,6 +645,15 @@ def diagnose_deployment(self, prices: dict[str, float] = None) -> dict: }) overlay = self.overlay_decision() + from core.basket_observability import allocation_diagnostics + positions = get_all_positions(account_key=self.account_key, mode=self._ledger_mode()) + measured = [{"symbol": p.symbol, "quantity": p.quantity, "price": prices.get(p.symbol)} for p in positions] + allocation = allocation_diagnostics( + total_value, cash, measured, + {s: w * design_fraction for s, w in targets.items()}, + (self.basket.get("overlays") or {}).get("defensive_symbol"), + float(self.rebalance_cfg.get("drift_threshold", 0.05)), + ) return { "total_value": total_value, "stock_value": stock_value, @@ -627,6 +663,7 @@ def diagnose_deployment(self, prices: dict[str, float] = None) -> dict: "base_stock_fraction": self.base_stock_fraction(), "overlay": overlay.to_dict() if overlay is not None else None, "unfilled_slots": unfilled, + "allocation": allocation, } # ------------------------------------------------------------------ @@ -799,6 +836,7 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder SELL 주문을 먼저 배치하여 현금 확보 후 BUY 주문을 실행할 수 있도록 합니다. max_turnover_ratio를 초과하는 리밸런싱은 부분 실행합니다. """ + self._plan_diagnostics = [] prices = prices or self._fetch_current_prices() summary = self.portfolio_mgr.get_portfolio_summary(current_prices=prices) # live에서 KIS 잔고가 확인되지 않으면(broker_balance_ok=False) stale DB 자본으로 @@ -839,6 +877,7 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder actual_w = actuals.get(symbol, 0.0) drift = target_w - actual_w if drift > 0 and symbol in cooldown: + self._plan_diagnostics.append({"symbol": symbol, "code": "COOLDOWN", "reason": str(cooldown[symbol])}) logger.info( "종목 {} 매수 보류 — {} (비중 부족 {:.1%}는 차단 해제 후 교정)", symbol, cooldown[symbol], drift, @@ -846,6 +885,8 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder continue trade_value = abs(investable * target_w - investable * actual_w) if trade_value < min_trade: + self._plan_diagnostics.append({"symbol": symbol, "code": "MIN_TRADE", "gap_amount": trade_value, + "reason": f"조정액 {trade_value:,.0f}원 < 최소 거래 {min_trade:,.0f}원"}) if drift > 0 and actual_w <= 0: # 미보유 슬롯의 목표 금액이 min_trade_amount보다 작으면 이 분기에서 # 영원히 침묵 스킵된다 — 채움 불가(아래 quantity<=0)와 같은 부류의 @@ -858,10 +899,13 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder continue price = prices.get(symbol, 0) if price <= 0: + self._plan_diagnostics.append({"symbol": symbol, "code": "MISSING_PRICE", "reason": "시장가격 미확보"}) logger.warning("종목 {} 가격 조회 불가 — 스킵", symbol) continue quantity = int(trade_value / price) if quantity <= 0: + self._plan_diagnostics.append({"symbol": symbol, "code": "MIN_LOT", "gap_amount": trade_value, + "price": price, "reason": f"조정액 {trade_value:,.0f}원 < 1주 {price:,.0f}원"}) if drift > 0 and actual_w <= 0: # 미보유 슬롯인데 1주 가격이 목표 거래금액을 초과 — 현재 자본 # 규모로는 이 슬롯을 영원히 채울 수 없다(예: 자본 1,000만·목표 @@ -904,12 +948,14 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder for order, notional in candidates: budget_left = max_turnover_amount - total_trade_amount if budget_left < min_trade: + self._plan_diagnostics.append({"symbol": order.symbol, "code": "TURNOVER_LIMIT", "reason": "남은 회전 한도 부족"}) break if notional > budget_left: order.quantity = int(budget_left / order.price) notional = order.quantity * order.price order.reason += " (회전상한 부분 실행)" if order.quantity <= 0 or notional < min_trade: + self._plan_diagnostics.append({"symbol": order.symbol, "code": "MIN_TRADE_AFTER_ROUNDING", "reason": "정수 수량·회전 한도 적용 후 최소 거래액 미달"}) continue orders.append(order) total_trade_amount += notional @@ -1011,7 +1057,14 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder remaining -= notional total_trade_amount += notional + planned_quantities = {o.symbol: o.quantity for o in orders} orders = self._fit_cash_orders(orders, summary, prices, positions) + funded_quantities = {o.symbol: o.quantity for o in orders} + for symbol, quantity in planned_quantities.items(): + if funded_quantities.get(symbol, 0) < quantity: + self._plan_diagnostics.append({"symbol": symbol, "code": "CASH_RESERVE", "reason": "거래 비용·현금 유보액을 반영해 주문 축소"}) + ordered_symbols = {o.symbol for o in orders} + self._plan_diagnostics = [d for d in self._plan_diagnostics if d["symbol"] not in ordered_symbols or d["code"] == "CASH_RESERVE"] total_trade_amount = sum(o.quantity * o.price for o in orders) sells = [o for o in orders if o.action == "SELL"] buys = [o for o in orders if o.action == "BUY"] @@ -1106,6 +1159,13 @@ def execute( """ results = {"executed": 0, "skipped": 0, "failed": 0, "details": []} mode = str(self.config.trading.get("mode", "paper")).lower() + if mode != "live" and not dry_run and orders: + from core.trading_hours import TradingHours + check = TradingHours(self.config).can_place_order() + if not check["allowed"]: + results.update(skipped=len(orders), blocked=True, reason=check["reason"]) + results["details"] = [{"order": repr(o), "status": "blocked", "reason": check["reason"]} for o in orders] + return results if mode == "live" and not dry_run and not live_confirmed and orders: reason = "live 리밸런싱은 운영자 확인 및 live gate 통과 후만 실행 가능합니다." logger.error(reason) @@ -1190,6 +1250,7 @@ def execute( mark_prices=mark_prices or None, # 계좌 낙폭 가드는 이 바스켓이 자기 낙폭 규칙을 켜 둔 경우에만 넘긴다. basket_drawdown_rule=self._has_drawdown_rule(), + decision_context={"price": order.decision_price, "at": order.decision_at, "source": "basket_plan"}, ) else: res = executor.execute_sell( @@ -1197,6 +1258,7 @@ def execute( reason=f"리밸런싱: {order.reason}", strategy=self.execution_strategy, # 손절·트레일링 청산은 최소 보유 기간보다 우선한다(익절은 아님) emergency=_is_protective_exit(order.reason), + decision_context={"price": order.decision_price, "at": order.decision_at, "source": "basket_plan"}, ) if res.get("success"): @@ -1256,6 +1318,8 @@ def get_status_report(self) -> str: lines.append("--- 계획된 주문 ---") for o in orders: lines.append(f" {o.action:4} {o.symbol} {o.quantity}주 @{o.price:,.0f} | {o.reason}") + else: + lines.append("주문 없음: " + "; ".join(f"{d['symbol']} {d['reason']}" for d in getattr(self, "_plan_diagnostics", []))) return "\n".join(lines) @@ -1290,6 +1354,13 @@ def _fetch_market_snapshot(self) -> dict[str, dict]: if df is None or df.empty: continue entry = {"price": float(df["close"].iloc[-1])} + raw_date = df["date"].iloc[-1] if "date" in df.columns else df.index[-1] + # RangeIndex is not a quote timestamp. + entry["quote_date"] = str(raw_date)[:10] if hasattr(raw_date, "year") or isinstance(raw_date, str) else None + source_info = getattr(self.data_collector, "get_last_source_info", lambda: {})() + source = source_info.get("source") if isinstance(source_info, dict) else None + entry["source"] = source if isinstance(source, str) else "unknown" + entry["observed_at"] = datetime.now(_KST).replace(tzinfo=None).isoformat() if "volume" in df.columns: recent_vol = df["volume"].tail(20).dropna() if len(recent_vol) > 0 and float(recent_vol.mean()) > 0: diff --git a/core/daily_observation.py b/core/daily_observation.py new file mode 100644 index 00000000..4992146c --- /dev/null +++ b/core/daily_observation.py @@ -0,0 +1,161 @@ +"""Bounded daily observation runner: close valuation first, research second. + +No order or deposit commands belong here. Child output is structured JSON only; +provider exceptions and stderr are never copied to the durable status report. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time +import uuid +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parents[1] +KST = ZoneInfo("Asia/Seoul") + + +def _directory(output_dir=None): + return Path(output_dir or os.environ.get("QUANT_DAILY_OBSERVATION_DIR") or ROOT / "data/daily_observation").resolve() + + +def _now(): + return datetime.now(timezone.utc) + + +def _expected_session(now): + from config.config_loader import load_yaml + from core.market_research import _calendar, _sessions, expected_session + calendar = _calendar() + expected_session(now) # Fail closed if the locally saved calendar is unknown. + settings = load_yaml(str(ROOT / "config/settings.yaml")) + market_close = datetime.strptime(settings.get("trading", {}).get("market_close", "15:30"), "%H:%M").time() + local = now.astimezone(KST).replace(tzinfo=None) + # The scheduled close run starts one hour after the regular session ends. + due = datetime.combine(local.date(), market_close) + timedelta(hours=1) + day = local.date() if local >= due else local.date() - timedelta(days=1) + while not len(_sessions(day.isoformat(), day.isoformat(), calendar)): + day -= timedelta(days=1) + return day.isoformat() + + +def _save(report, directory): + directory.mkdir(parents=True, exist_ok=True) + temporary = directory / f".{uuid.uuid4().hex}.tmp" + try: + temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8") + temporary.replace(directory / "latest.json") + finally: + temporary.unlink(missing_ok=True) + + +def read_daily_observation(output_dir=None, *, now=None): + """Read saved execution status without starting a collector or opening a DB.""" + try: + report = json.loads((_directory(output_dir) / "latest.json").read_text(encoding="utf-8")) + stamp = datetime.fromisoformat(report["updated_at"]) + current = now or _now() + if (report.get("schema_version") != 1 or stamp.tzinfo is None + or report.get("status") not in {"running", "completed", "attention", "disk_pressure", "awaiting_close"} + or not isinstance(report.get("jobs"), dict) or stamp > current + timedelta(minutes=5)): + raise ValueError("invalid daily observation report") + if report["status"] == "running" and current - stamp > timedelta(minutes=20): + return {**report, "status": "interrupted", "issues": ["일일 관측 실행의 완료 기록이 없습니다."]} + if report.get("expected_close_session", "") < _expected_session(current): + return {**report, "status": "stale", "issues": ["새 거래일의 종가 관측 실행이 필요합니다."]} + return report + except FileNotFoundError: + return {"status": "not_started", "issues": ["아직 종가·수집 통합 실행 기록이 없습니다."]} + except (OSError, ValueError, TypeError, KeyError): + return {"status": "unavailable", "issues": ["일일 관측 실행 기록을 읽을 수 없습니다."]} + + +def _run_child(command, timeout): + process = subprocess.Popen([sys.executable, *command], cwd=str(ROOT), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), start_new_session=os.name != "nt", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}) + try: + stdout, _ = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + # The research CLI itself spawns provider children. Kill this owned tree, + # not just its parent, before releasing the daily runner's process lock. + if os.name == "nt": + subprocess.run(["taskkill.exe", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), check=False) + else: + os.killpg(process.pid, signal.SIGKILL) + process.communicate(timeout=15) + raise + data = json.loads(stdout) + if not isinstance(data, dict) or not isinstance(data.get("status"), str): + raise ValueError("invalid collector result") + return process.returncode, data + + +def run_daily_observation(output_dir=None): + from core.runtime_lock import process_runtime_lock + directory = _directory(output_dir) + with process_runtime_lock(directory / ".runner.lock", label="일일 종가·자료 관측") as acquired: + if not acquired: + return {"status": "busy", "issues": ["다른 일일 관측 실행이 진행 중입니다."]} + now = _now() + deadline = time.monotonic() + 900 + report = {"schema_version": 1, "run_id": uuid.uuid4().hex, "status": "running", + "started_at": now.isoformat(), "updated_at": now.isoformat(), + "expected_close_session": _expected_session(now), "orders_enabled": False, + "jobs": {}, "issues": []} + _save(report, directory) + commands = ( + ("close", [str(ROOT / "tools/collect_basket_closes.py"), "--json"], 240), + ("research", [str(ROOT / "tools/research_operations.py"), "--force", "--trigger", "daily_observation"], 720), + ) + for name, command, timeout in commands: + job = {"status": "running", "started_at": _now().isoformat()} + report["jobs"][name] = job + report["updated_at"] = _now().isoformat() + _save(report, directory) + if shutil.disk_usage(directory).free < 256 * 1024 * 1024: + job.update(status="disk_pressure", issue="저장 공간 부족으로 관측을 보류했습니다.") + else: + try: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired("daily_observation", 900) + code, data = _run_child(command, min(timeout, remaining)) + job.update(status="completed" if code == 0 else "attention", returncode=code, + data_status=data["status"]) + if name == "close": + job["result"] = {key: data[key] for key in ("expected_session", "last_completed_session", "baskets", "coverage", "issues") if key in data} + else: + job["result"] = {"jobs": data.get("jobs", {}), "issues": data.get("issues", [])} + allowed = {"completed", "awaiting_close"} if name == "close" else {"completed"} + if data["status"] not in allowed: + job["status"] = "attention" + elif name == "close" and data["status"] == "awaiting_close" and code == 0: + job["status"] = "awaiting_close" + except subprocess.TimeoutExpired: + job.update(status="timeout", issue="관측 실행 시간 제한 초과") + except Exception as exc: + job.update(status="failed", issue=type(exc).__name__) + job["completed_at"] = _now().isoformat() + report["updated_at"] = job["completed_at"] + _save(report, directory) + report["status"] = "completed" if all(job["status"] in {"completed", "awaiting_close"} for job in report["jobs"].values()) else "attention" + if report["status"] == "completed" and report["jobs"]["close"]["status"] == "awaiting_close": + report["status"] = "awaiting_close" + report["updated_at"] = _now().isoformat() + _save(report, directory) + # One small audit record per attempt; no raw logs or database copies. + runs = directory / "runs" + runs.mkdir(exist_ok=True) + with (runs / f"{report['run_id']}.json").open("x", encoding="utf-8") as handle: + json.dump(report, handle, ensure_ascii=False, indent=2, allow_nan=False) + return report diff --git a/core/data_collector.py b/core/data_collector.py index 45108836..ad6b1354 100644 --- a/core/data_collector.py +++ b/core/data_collector.py @@ -256,7 +256,7 @@ def __init__(self, config=None): @staticmethod def fetch_benchmark_return( - start_date: str, end_date: str, symbol: str = "KS11" + start_date: str, end_date: str, symbol: str = "KS11", *, exact_boundaries: bool = False ) -> Optional[float]: """벤치마크 지수의 누적 수익률(%)을 반환. 실패 시 None (fail closed). @@ -305,6 +305,12 @@ def fetch_benchmark_return( logger.warning("벤치마크 {} 쓸 수 있는 종가 부족({})", symbol, source) continue last_day = pd.Timestamp(closes.index.max()).date() + if exact_boundaries and ( + pd.Timestamp(closes.index.min()).date() != pd.Timestamp(start_date).date() + or last_day != pd.Timestamp(end_date).date() + ): + logger.warning("벤치마크 {} 비교 구간의 양 끝 종가 미확인({}): {}~{}", symbol, source, start_date, end_date) + continue if expected is not None and last_day < expected: logger.warning( "벤치마크 {} 자료가 {}에 멈춤(기준 거래일 {}, 소스 {}) — 이 값으로 계산하지 않는다", diff --git a/core/execution_analytics.py b/core/execution_analytics.py new file mode 100644 index 00000000..1e8f5323 --- /dev/null +++ b/core/execution_analytics.py @@ -0,0 +1,368 @@ +"""Execution-cost observations, with no authority over orders or portfolios. + +Price shortfall uses BUY=+1/SELL=-1 on cumulative fills, never sums repeated +broker snapshots. Slippage already embedded in fill prices is not charged twice. +Read reports through SQLite mode=ro; missing schemas remain explicitly missing. +""" +from __future__ import annotations + +from contextvars import ContextVar +from contextlib import closing +from datetime import datetime, timezone, timedelta +from functools import wraps +import inspect +import json +import math +from pathlib import Path +import sqlite3 +import uuid + +from loguru import logger + +KST = timezone(timedelta(hours=9)) +_ACTIVE = ContextVar("execution_cost_observation", default=None) + + +def _now(): + return datetime.now(KST).isoformat() + + +def _number(value, *, positive=False): + if isinstance(value, bool): + return None + try: + number = float(value) + return number if math.isfinite(number) and (number > 0 if positive else number >= 0) else None + except (TypeError, ValueError, OverflowError): + return None + + +def _stamp(value): + if value is None: + return None + try: + parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return (parsed.replace(tzinfo=KST) if parsed.tzinfo is None else parsed.astimezone(KST)).isoformat() + except (TypeError, ValueError): + return None + + +def _currency(symbol): + symbol = str(symbol).strip() + if len(symbol) == 6 and symbol.isdigit(): + return "KRW" + if symbol.isascii() and symbol.isalpha(): + return "USD" + return "UNKNOWN" + + +def _broker_environment(executor): + if executor.mode != "live": + return "paper_simulation" + mock = getattr(getattr(executor, "kis_api", None), "use_mock", None) + return "vts" if mock is True else ("production" if mock is False else "unknown") + + +def _elapsed(start, end): + start, end = _stamp(start), _stamp(end) + if start and end: + value = (datetime.fromisoformat(end) - datetime.fromisoformat(start)).total_seconds() + return value if value >= 0 else None + return None + + +def _append(payload, event_type, context=None): + try: + from database.repositories import append_execution_cost_event + append_execution_cost_event({**payload, "observed_at": _now()}, event_type) + return True + except Exception as exc: + if context is not None: + context["write_failed"] = True + # Never log SQL values, broker responses, credentials, or account numbers. + logger.warning("체결비용 관측 저장 실패: {} — 주문 동작은 유지, 분석 자료 누락", type(exc).__name__) + return False + + +def _safe_observer(function): + """Measurement failure must never change order, portfolio or retry behavior.""" + @wraps(function) + def wrapped(*args, **kwargs): + try: + return function(*args, **kwargs) + except Exception as exc: + context = _ACTIVE.get() + if context is not None: + context["write_failed"] = True + logger.warning("체결비용 관측 실패: {} — 주문 동작 유지, 분석 자료 누락", type(exc).__name__) + return None + return wrapped + + +def observe_execution(action): + """Observe public executor entry, including pre-order risk rejections.""" + def decorate(function): + signature = inspect.signature(function) + @wraps(function) + def wrapped(self, *args, **kwargs): + decision = kwargs.pop("decision_context", None) + decision = decision if isinstance(decision, dict) else {} + bound = signature.bind(self, *args, **kwargs) + values = bound.arguments + entered = _now() + reference = _number(decision.get("price"), positive=True) + reference_at = _stamp(decision.get("at")) + genuine_decision = reference is not None and reference_at is not None and reference_at <= entered + payload = { + "schema_version": 1, "attempt_id": uuid.uuid4().hex, + "account_key": getattr(self, "account_key", "") or "", "mode": self.mode, + "symbol": str(values.get("symbol", "")), "action": action, + "strategy": str(values.get("strategy", "")), "order_id": None, + "requested_qty": _number(values.get("quantity"), positive=True), + "reference_price": reference if genuine_decision else _number(values.get("price"), positive=True), + "reference_at": reference_at if genuine_decision else entered, + "reference_kind": "basket_plan" if genuine_decision and decision.get("source") == "basket_plan" else ("decision" if genuine_decision else "executor_input"), + "executor_entered_at": entered, "signal_at": _stamp(values.get("signal_at")), + "status": "INPUT_RECEIVED", "filled_qty": 0, "filled_price": None, + "commission_amount": None, "tax_amount": None, "fees_basis": "unavailable", + "fill_price_basis": "modeled" if self.mode != "live" else "broker_observed", + "currency": _currency(values.get("symbol", "")), + "broker_environment": _broker_environment(self), + "execution_session_id": str(values.get("execution_session_id", "")), + } + context = {"payload": payload, "order": None, "write_failed": False} + token = _ACTIVE.set(context) + _append(payload, "input", context) + try: + result = function(self, *args, **kwargs) + _finish_observation(context, result) + return result + except Exception: + if context["order"] is not None: + observe_order_state(context["order"], outcome="execution_exception") + else: + _append({**payload, "status": "EXECUTION_EXCEPTION", "outcome": "execution_exception"}, "exception", context) + raise + finally: + _ACTIVE.reset(token) + return wrapped + return decorate + + +@_safe_observer +def _finish_observation(context, result): + payload = context["payload"] + if context["order"] is not None: + observe_order_state(context["order"], result=result) + else: + payload.update(status="REJECTED_BEFORE_ORDER", outcome="rejected", + reason=str(result.get("reason", "not_executed"))[:400]) + _append(payload, "result", context) + if isinstance(result, dict): + result["execution_observation_id"] = payload["attempt_id"] + result["execution_observability"] = "incomplete" if context["write_failed"] else "recorded" + + +@_safe_observer +def observe_order_created(order): + context = _ACTIVE.get() + if context is not None: + context["order"] = order + order.execution_observation_id = context["payload"]["attempt_id"] + observe_order_state(order) + + +def _existing_payload(order_id): + context = _ACTIVE.get() + if context is not None and context.get("order") is not None and context["order"].order_id == order_id: + return dict(context["payload"]), context + try: + from database.repositories import latest_execution_cost_payload + return latest_execution_cost_payload(order_id), None + except Exception as exc: + logger.warning("체결비용 관측 조회 실패: {}", type(exc).__name__) + return None, None + + +@_safe_observer +def observe_order_state(order, *, result=None, outcome=None): + payload, context = _existing_payload(order.order_id) + if payload is None: + return # Legacy order: do not invent a decision benchmark. + payload.update(order_id=order.order_id, status=getattr(order.status, "value", order.status), + requested_qty=order.requested_qty, filled_qty=order.filled_qty, + filled_price=_number(order.filled_price, positive=True), + submitted_at=_stamp(order.submitted_at), fill_observed_at=_stamp(order.filled_at), + remaining_qty=max(0, order.requested_qty - order.filled_qty)) + if result is not None: + payload["outcome"] = "completed" if result.get("success") else ("pending" if result.get("order_pending") else "rejected") + payload["reason"] = str(result.get("reason", ""))[:400] + costs = result.get("costs") + if isinstance(costs, dict) and result.get("success"): + tax = _number(costs.get("tax")) + gains_tax = _number(costs.get("capital_gains_tax", 0)) + payload.update(commission_amount=_number(costs.get("commission")), + tax_amount=tax + gains_tax if tax is not None and gains_tax is not None else None, + fees_basis="estimated" if order.mode == "live" else "modeled", + cost_quantity=order.filled_qty) + if outcome: + payload["outcome"] = outcome + if context is not None: + context["payload"].update(payload) + _append(payload, "result" if result is not None or outcome else "order_state", context) + + +@_safe_observer +def observe_reconciled_order(record: dict, *, execution_price_observed=None): + payload, context = _existing_payload(record["order_id"]) + if payload is None: + return + old_qty = payload.get("filled_qty", 0) + quantity = record.get("filled_qty", 0) + price = _number(record.get("filled_price"), positive=True) + if execution_price_observed is not True: + # Only preserve our previously observed average for the *same* quantity. + # The order ledger may itself carry forward an old partial-fill price. + price = _number(payload.get("filled_price"), positive=True) if quantity == old_qty else None + payload.update(status=record["status"], filled_qty=quantity, filled_price=price, + remaining_qty=max(0, (payload.get("requested_qty") or 0) - quantity), + broker_remaining_qty=record.get("remaining_qty"), + reconciled_observed_at=_now(), outcome="reconciled") + if payload.get("cost_quantity") != quantity: + payload.update(commission_amount=None, tax_amount=None, fees_basis="unavailable") + if context is not None: + context["payload"].update(payload) + _append(payload, "reconciliation", context) + + +def _cost_row(payload): + row = dict(payload) + reference, fill, quantity = (_number(row.get("reference_price"), positive=True), + _number(row.get("filled_price"), positive=True), + _number(row.get("filled_qty"), positive=True)) + denominator = reference * quantity if reference and quantity else None + price_cost = (1 if row.get("action") == "BUY" else -1) * (fill - reference) * quantity if denominator and fill else None + commission, tax = _number(row.get("commission_amount")), _number(row.get("tax_amount")) + fees = commission + tax if commission is not None and tax is not None else None + total = price_cost + fees if price_cost is not None and fees is not None else None + row.update(price_shortfall_amount=price_cost, price_shortfall_bps=price_cost / denominator * 10000 if price_cost is not None else None, + explicit_cost_amount=fees, price_plus_fees_amount=total, + price_plus_fees_bps=total / denominator * 10000 if total is not None else None, + filled_reference_notional=denominator, opportunity_cost_amount=None, opportunity_cost_included=False, + opportunity_cost_reason="no_independent_end_mark_for_unfilled_quantity", + fully_observed_total_cost_amount=total if row.get("fees_basis") == "broker_observed" else None, + reference_to_submit_seconds=_elapsed(row.get("reference_at"), row.get("submitted_at")), + submit_to_fill_observed_seconds=_elapsed(row.get("submitted_at"), row.get("fill_observed_at")), + timestamp_basis="local_observation_not_exchange_execution_timestamp") + if quantity and not fill: + row["measurement_status"] = "fill_price_unavailable" + elif not reference: + row["measurement_status"] = "reference_unavailable" + else: + row["measurement_status"] = "measured" if price_cost is not None else "no_fill" + return row + + +def execution_cost_report(*, account_key=None, mode=None, since=None, limit=200, db_path=None): + """Read-only API; newest attempts with cumulative order state, no DB creation.""" + if db_path is None: + from config.config_loader import Config + config = Config.get().database + if config.get("type") == "postgresql": + return {"status": "unavailable", "reason": "sqlite_report_only", "orders": [], "summary_by_basis": [], "coverage": {}} + db_path = Path(__file__).resolve().parents[1] / config.get("sqlite_path", "data/quant_trader.db") + path = Path(db_path).resolve() + report = {"schema_version": 1, "status": "unavailable", "as_of": _now(), "orders": [], + "summary_by_basis": [], "coverage": {}, "limitations": [ + "BUY는 체결가 상승, SELL은 체결가 하락이 양의 비용입니다.", + "기준은 basket_plan/decision 또는 executor_input이며 과거 주문 가격을 소급 추정하지 않습니다.", + "모의 체결과 비용 추정은 실제 브로커 측정과 분리합니다. 체결가에 포함된 슬리피지를 다시 더하지 않습니다.", + "미체결 잔량의 기회비용과 독립적인 주문 도착가격은 수집되지 않아 포함하지 않습니다.", + "latest 상태의 누적 체결량 기준이며 같은 주문의 부분체결/재조회 이벤트를 합산하지 않습니다.", + ]} + if not path.is_file(): + return {**report, "reason": "database_missing"} + try: + with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as connection: + connection.row_factory = sqlite3.Row + tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if "execution_cost_events" not in tables: + return {**report, "reason": "execution_observation_schema_missing"} + clauses, values = [], [] + if account_key is not None: + clauses.append("account_key=?") + values.append(account_key) + if mode is not None: + clauses.append("mode=?") + values.append(mode) + where = " WHERE " + " AND ".join(clauses) if clauses else "" + since_at = _stamp(since) if since is not None else None + if since is not None and since_at is None: + return {**report, "reason": "invalid_since_timestamp"} + latest_where = " WHERE id IN (SELECT MAX(id) FROM execution_cost_events" + where + " GROUP BY attempt_id)" + latest_values = list(values) + if since_at: + latest_where += " AND json_extract(payload_json, '$.executor_entered_at') >= ?" + latest_values.append(since_at) + total_matching = connection.execute("SELECT COUNT(*) FROM execution_cost_events" + latest_where, latest_values).fetchone()[0] + rows = connection.execute( + "SELECT payload_json FROM execution_cost_events" + latest_where + " ORDER BY id DESC LIMIT ?", + [*latest_values, max(1, min(int(limit), 1000))], + ).fetchall() + legacy = set() + unlinked_legacy_trades = 0 + for table in ("order_records", "trade_history"): + if table not in tables: + continue + columns = {row[1] for row in connection.execute(f"PRAGMA table_info({table})")} + if "order_id" not in columns: + continue + legacy_where = where + (" AND " if where else " WHERE ") + ( + "order_id IS NOT NULL AND order_id != '' AND NOT EXISTS " + "(SELECT 1 FROM execution_cost_events e WHERE e.order_id=t.order_id " + "AND e.account_key=t.account_key AND e.mode=t.mode)" + ) + query = f"SELECT DISTINCT order_id FROM {table} t" + legacy_where + for record in connection.execute(query, values): + legacy.add(record[0]) + if table == "trade_history": + missing_where = where + (" AND " if where else " WHERE ") + "(order_id IS NULL OR order_id='')" + unlinked_legacy_trades = connection.execute("SELECT COUNT(*) FROM trade_history" + missing_where, values).fetchone()[0] + orders = [_cost_row(json.loads(record[0])) for record in rows] + groups = {} + for row in orders: + key = (row["mode"], row["reference_kind"], row["fill_price_basis"], row["fees_basis"], row.get("currency", "UNKNOWN"), row.get("broker_environment", "unknown")) + group = groups.setdefault(key, {"mode": key[0], "reference_kind": key[1], "fill_price_basis": key[2], "fees_basis": key[3], + "currency": key[4], + "broker_environment": key[5], + "attempts": 0, "filled_orders": 0, "priced_fills": 0, "filled_reference_notional": 0., "price_shortfall_amount": 0., + "price_plus_fees_amount": 0., "fees_covered_fills": 0, "fees_covered_notional": 0.}) + group["attempts"] += 1 + group["filled_orders"] += int((row.get("filled_qty") or 0) > 0) + if row["price_shortfall_amount"] is not None: + group["priced_fills"] += 1 + group["filled_reference_notional"] += row["filled_reference_notional"] + group["price_shortfall_amount"] += row["price_shortfall_amount"] + if row["price_plus_fees_amount"] is not None: + group["fees_covered_fills"] += 1 + group["fees_covered_notional"] += row["filled_reference_notional"] + group["price_plus_fees_amount"] += row["price_plus_fees_amount"] + for group in groups.values(): + group["price_shortfall_bps"] = group["price_shortfall_amount"] / group["filled_reference_notional"] * 10000 if group["filled_reference_notional"] else None + group["price_plus_fees_bps"] = group["price_plus_fees_amount"] / group["fees_covered_notional"] * 10000 if group["fees_covered_notional"] else None + if not group["priced_fills"]: + group["price_shortfall_amount"] = None + if not group["fees_covered_fills"]: + group["price_plus_fees_amount"] = None + report.update(status="ready" if orders else "unavailable", reason=None if orders else "no_new_execution_observations", + orders=orders, summary_by_basis=list(groups.values()), coverage={ + "attempts": len(orders), "total_matching_attempts": total_matching, "truncated": total_matching > len(orders), + "linked_orders": sum(bool(row.get("order_id")) for row in orders), + "filled_orders": sum((row.get("filled_qty") or 0) > 0 for row in orders), + "unpriced_fills": sum(row["measurement_status"] == "fill_price_unavailable" for row in orders), + "without_reference": sum(row["reference_price"] is None for row in orders), "legacy_unmeasured_orders": len(legacy), + "legacy_unlinked_trades": unlinked_legacy_trades, + "legacy_scope": "all_available_history_for_account_and_mode", "since": since}) + except (sqlite3.Error, ValueError, TypeError, KeyError) as exc: + report.update(status="unavailable", reason=f"execution_observation_read_failed:{type(exc).__name__}") + return report diff --git a/core/fundamental_loader.py b/core/fundamental_loader.py index 1f49728a..05a73a0c 100644 --- a/core/fundamental_loader.py +++ b/core/fundamental_loader.py @@ -6,6 +6,7 @@ """ from typing import Optional +import math from loguru import logger @@ -32,6 +33,25 @@ def _korean_ticker(symbol: str) -> str: return f"{s}.KS" +def yahoo_debt_to_equity_pct(info: dict) -> Optional[float]: + """Yahoo debtToEquity is already expressed in percent (145.03 means 145.03%). + + This measures interest-bearing debt/equity, not Korean total liabilities/equity. + Keep the basis explicit at callers; negative equity is not a healthy low ratio. + """ + value = info.get("debtToEquity") + try: + if value is not None: + ratio = float(value) + return ratio if math.isfinite(ratio) and ratio >= 0 else None + debt, equity = float(info["totalDebt"]), float(info["totalStockholderEquity"]) + if math.isfinite(debt) and math.isfinite(equity) and debt >= 0 and equity > 0: + return debt / equity * 100.0 + except (KeyError, TypeError, ValueError, OverflowError): + pass + return None + + def _get_fundamentals_pykrx(symbol: str) -> dict: """pykrx로 한국 종목 PER·부채비율 조회. 설치되지 않았거나 실패하면 빈 결과.""" result = {"per": None, "debt_ratio": None, "available": False, "source": "pykrx"} @@ -54,7 +74,7 @@ def _get_fundamentals_pykrx(symbol: str) -> dict: per_val = per_df["PER"].dropna() if not per_val.empty: per = float(per_val.iloc[-1]) - result["per"] = per if per > 0 else None + result["per"] = per if math.isfinite(per) and per > 0 else None result["available"] = result["per"] is not None if result["available"]: @@ -82,26 +102,15 @@ def _get_fundamentals_yfinance(symbol: str) -> dict: if per is not None: try: per = float(per) - if per < 0: + if not math.isfinite(per) or per <= 0: per = None except (TypeError, ValueError): per = None result["per"] = per - debt_eq = info.get("debtToEquity") - if debt_eq is not None: - try: - result["debt_ratio"] = float(debt_eq) * 100.0 - except (TypeError, ValueError): - pass - if result["debt_ratio"] is None: - td = info.get("totalDebt") - te = info.get("totalStockholderEquity") - if td is not None and te is not None and float(te) != 0: - try: - result["debt_ratio"] = float(td) / float(te) * 100.0 - except (TypeError, ValueError, ZeroDivisionError): - pass + result["debt_ratio"] = yahoo_debt_to_equity_pct(info) + result["debt_ratio_basis"] = "interest_bearing_debt_to_equity_pct" + result["field_sources"] = {key: "yfinance" for key in ("per", "debt_ratio") if result[key] is not None} result["available"] = result["per"] is not None or result["debt_ratio"] is not None except Exception as e: @@ -119,8 +128,9 @@ def get_fundamentals(symbol: str) -> dict: """ # 1차: pykrx (한국 종목 정확도 높음) result = _get_fundamentals_pykrx(symbol) - if result["available"]: - return result + result["field_sources"] = {key: "pykrx" for key in ("per", "debt_ratio") if result.get(key) is not None} + # pykrx provides PER but no debt field. A successful PER lookup must not + # short-circuit the separate debt lookup and reject every PER-successful stock. # 2차: yfinance (폴백) yf_result = _get_fundamentals_yfinance(symbol) @@ -128,13 +138,16 @@ def get_fundamentals(symbol: str) -> dict: # pykrx에서 못 가져온 필드를 yfinance로 보충 if result["per"] is None and yf_result["per"] is not None: result["per"] = yf_result["per"] + result["field_sources"]["per"] = "yfinance" if result["debt_ratio"] is None and yf_result["debt_ratio"] is not None: result["debt_ratio"] = yf_result["debt_ratio"] + result["field_sources"]["debt_ratio"] = "yfinance" + result["debt_ratio_basis"] = yf_result.get("debt_ratio_basis", "interest_bearing_debt_to_equity_pct") result["available"] = result["per"] is not None or result["debt_ratio"] is not None - result["source"] = "pykrx+yfinance" if result["source"] == "pykrx" else "yfinance" + result["source"] = "+".join(dict.fromkeys(result["field_sources"].values())) or "none" return result - result["source"] = "none" + result["source"] = "+".join(dict.fromkeys(result["field_sources"].values())) or "none" return result @@ -143,6 +156,7 @@ def check_fundamental_filter( per_min: Optional[float] = 0, per_max: Optional[float] = 50, debt_ratio_max: Optional[float] = 200, + debt_ratio_basis: str = "total_liabilities_to_equity_pct", ) -> tuple[bool, str]: """ PER·부채비율이 정상 범위인지 확인. @@ -152,6 +166,8 @@ def check_fundamental_filter( per_min: PER 하한 (None이면 검사 생략). 0이면 적자(PER 음수) 제외 per_max: PER 상한 (None이면 검사 생략) debt_ratio_max: 부채비율(%) 상한 (None이면 검사 생략) + debt_ratio_basis: 기본 총부채/자본. Yahoo의 차입금/자본을 사용하려면 + interest_bearing_debt_to_equity_pct를 명시해야 한다. Returns: (통과 여부, 사유 문자열) @@ -175,11 +191,24 @@ def check_fundamental_filter( reasons.append(f"PER {per:.1f} > {per_max}") if debt_ratio_max is not None: + basis_labels = { + "total_liabilities_to_equity_pct": "총부채/자본", + "interest_bearing_debt_to_equity_pct": "차입금/자본", + } dr = data.get("debt_ratio") - if dr is None: + actual_basis = data.get("debt_ratio_basis") + if debt_ratio_basis not in basis_labels: + reasons.append("펀더멘털 필터 설정 오류: 알 수 없는 부채비율 기준") + elif dr is None: reasons.append("부채비율 미제공") + elif actual_basis != debt_ratio_basis: + reasons.append( + f"부채비율 기준 불일치: 요구 {basis_labels[debt_ratio_basis]}, " + f"제공 {basis_labels.get(actual_basis, '미확인')}" + ) elif dr > debt_ratio_max: - reasons.append(f"부채비율 {dr:.1f}% > {debt_ratio_max}%") + label = basis_labels[debt_ratio_basis] + reasons.append(f"{label} {dr:.1f}% > {debt_ratio_max}%") if reasons: return False, "; ".join(reasons) diff --git a/core/market_intelligence.py b/core/market_intelligence.py new file mode 100644 index 00000000..939efbb4 --- /dev/null +++ b/core/market_intelligence.py @@ -0,0 +1,493 @@ +"""Public disclosures and investor-flow observations with immutable evidence. + +DART receipt dates do not contain publication times. NAVER exposes recent daily +net quantities, not monetary flow or a historical point-in-time database. Every +record therefore retains its actual first observation separately from source dates. +No database, portfolio, execution, broker, or notification module is imported. +""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import timedelta +from io import BytesIO +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import time +import xml.etree.ElementTree as ET +import zipfile + +import requests +import yaml + +from core.market_research import ROOT, _calendar, _now, _sessions, expected_session + +SCHEMA_VERSION = 1 +DART_GUIDE = "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019001" +NAVER_URL = "https://m.stock.naver.com/api/stock/{symbol}/integration" + + +def intelligence_directory() -> Path: + return Path(os.environ.get("QUANT_MARKET_INTELLIGENCE_DIR", ROOT / "output" / "market_intelligence")) + + +def _digest(value) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False).encode()).hexdigest() + + +_RECORD_METADATA = {"kind", "record_id", "revision_id", "observed_at", "first_observed_at", "available_to_system_at", "previous_revision_id", "version_path"} + + +def _record_digest(record): + return _digest({key: value for key, value in record.items() if key not in _RECORD_METADATA}) + + +def _timestamp(value): + if not isinstance(value, str) or not re.search(r"(?:Z|[+-]\d{2}:\d{2})$", value): + raise ValueError("timezone-aware timestamp required") + return _now(value) + + +def load_intelligence_config(path=None) -> dict: + from config.config_loader import load_yaml + config = load_yaml(str(path or ROOT / "config" / "market_intelligence.yaml")) + _validate(config) + return config + + +def _validate(config): + universe = config.get("universe") + if not isinstance(universe, list) or not 1 <= len(universe) <= 30: + raise ValueError("observation universe must contain 1 to 30 equities") + codes = [row.get("symbol") for row in universe if isinstance(row, dict)] + if len(codes) != len(universe) or any(not isinstance(code, str) or not re.fullmatch(r"\d{6}", code) for code in codes) or len(set(codes)) != len(codes): + raise ValueError("unique six-digit equity codes are required") + for key, low, high in (("request_timeout_seconds", 1, 30), ("collection_budget_seconds", 5, 150), ("workers", 1, 4)): + value = config.get(key, {"request_timeout_seconds": 12, "collection_budget_seconds": 120, "workers": 3}[key]) + if type(value) is not int or not low <= value <= high: + raise ValueError(f"invalid {key}") + for section, key, low, high, default in (("disclosures", "lookback_calendar_days", 1, 90, 14), ("disclosures", "max_pages_per_company", 1, 10, 4), ("investor_flows", "lookback_sessions", 1, 5, 4)): + settings = config.get(section) or {} + if not isinstance(settings.get("enabled", True), bool): + raise ValueError(f"invalid {section}.enabled") + value = settings.get(key, default) + if type(value) is not int or not low <= value <= high: + raise ValueError(f"invalid {section}.{key}") + if (config.get("investor_flows") or {}).get("provider", "naver_securities") != "naver_securities": + raise ValueError("unsupported investor-flow provider") + if any(word in json.dumps(config).lower() for word in ('"api_key"', '"crtfc_key"', '"password"', '"secret"')): + raise ValueError("credentials must be environment settings, not observation config") + + +class SourceError(RuntimeError): + """Safe provider reason; never includes request URLs or credential values.""" + + +class PublicTransport: + def get_bytes(self, url, *, params=None, timeout=12): + try: + response = requests.get(url, params=params, timeout=timeout, allow_redirects=False, + headers={"User-Agent": "quant-trader-research/1.0", "Accept": "application/json,application/zip"}) + if response.status_code != 200: + raise SourceError(f"http_{response.status_code}") + if len(response.content) > 15_000_000: + raise SourceError("response_too_large") + return response.content + except SourceError: + raise + except requests.RequestException as exc: + raise SourceError(type(exc).__name__) from None + + def get_json(self, url, *, params=None, timeout=12): + try: + return json.loads(self.get_bytes(url, params=params, timeout=timeout)) + except (ValueError, UnicodeError): + raise SourceError("invalid_json") from None + + +def _guard(deadline): + if time.monotonic() >= deadline: + raise SourceError("collection_budget_exhausted") + + +def _corp_codes(transport, key, timeout, deadline): + _guard(deadline) + content = transport.get_bytes("https://opendart.fss.or.kr/api/corpCode.xml", params={"crtfc_key": key}, timeout=timeout) + try: + if not zipfile.is_zipfile(BytesIO(content)): + # Authentication/rate failures are XML even though successful output + # is a ZIP. Retain only the official status code, never free text. + response = ET.fromstring(content) + code = response.findtext("status", "") + if re.fullmatch(r"\d{3}", code) and code != "000": + raise SourceError(f"dart_status_{code}") + raise SourceError("invalid_corporation_archive") + with zipfile.ZipFile(BytesIO(content)) as archive: + names = [entry for entry in archive.infolist() if entry.filename.lower().endswith(".xml")] + if len(names) != 1 or names[0].file_size > 50_000_000: + raise SourceError("invalid_corporation_archive") + root = ET.fromstring(archive.read(names[0])) + mapping = {} + for row in root.findall("list"): + symbol, corp = row.findtext("stock_code", "").strip(), row.findtext("corp_code", "").strip() + if re.fullmatch(r"\d{6}", symbol) and re.fullmatch(r"\d{8}", corp): + mapping[symbol] = corp + if not mapping: + raise SourceError("corporation_mapping_empty") + return mapping, hashlib.sha256(content).hexdigest() + except (zipfile.BadZipFile, ET.ParseError, OSError, KeyError): + raise SourceError("invalid_corporation_archive") from None + + +def _dated(value): + if not isinstance(value, str) or not re.fullmatch(r"\d{8}", value): + return None + try: + from datetime import datetime + return datetime.strptime(value, "%Y%m%d").date().isoformat() + except ValueError: + return None + + +def _record(kind, key, payload, observed_at): + return {"kind": kind, "record_id": key, "revision_id": _digest(payload), "observed_at": observed_at, + "first_observed_at": observed_at, "available_to_system_at": observed_at, **payload} + + +def collect_disclosures(spec, *, corp_code, key, transport, start, end, timeout, deadline, max_pages): + symbol = spec["symbol"] + records, seen, issues = [], set(), [] + status, total_pages, requested_pages, reported_count = "ready", 1, 0, None + try: + if not corp_code: + raise SourceError("corp_code_missing") + for page in range(1, max_pages + 1): + _guard(deadline) + data = transport.get_json("https://opendart.fss.or.kr/api/list.json", timeout=timeout, + params={"crtfc_key": key, "corp_code": corp_code, "bgn_de": start.replace("-", ""), + "end_de": end.replace("-", ""), "last_reprt_at": "N", "page_no": str(page), "page_count": "100"}) + observed_at = _now().isoformat() + requested_pages += 1 + if not isinstance(data, dict): + raise SourceError("invalid_disclosure_schema") + code = str(data.get("status", "")) + if code == "013": + if page != 1: + raise SourceError("unexpected_empty_page") + break # Official no-data response is distinct from missing/failed response. + if code != "000": + raise SourceError(f"dart_status_{code}" if re.fullmatch(r"\d{3}", code) else "dart_status_unknown") + rows = data.get("list") + if not isinstance(rows, list): + raise SourceError("invalid_disclosure_schema") + try: + new_total_pages = int(data["total_page"]) + new_count = int(data["total_count"]) + except (ValueError, TypeError, KeyError): + raise SourceError("invalid_pagination") from None + if new_total_pages < 1 or new_count < 1 or not rows: + raise SourceError("invalid_pagination") + if reported_count is not None and (new_count != reported_count or new_total_pages != total_pages): + issues.append("pagination_changed_during_collection") + total_pages, reported_count = new_total_pages, new_count + for raw in rows: + if not isinstance(raw, dict): + issues.append("invalid_disclosure_row") + continue + receipt, published = str(raw.get("rcept_no", "")), _dated(str(raw.get("rcept_dt", ""))) + if not re.fullmatch(r"\d{14}", receipt) or not published or not start <= published <= end or raw.get("corp_code") != corp_code or raw.get("stock_code") != symbol or not isinstance(raw.get("report_nm"), str) or not raw["report_nm"].strip(): + issues.append("invalid_disclosure_row") + continue + if receipt in seen: + continue + seen.add(receipt) + title = str(raw.get("report_nm", "")) + raw_fields = {field: raw.get(field) for field in ("corp_code", "corp_name", "stock_code", "report_nm", "rcept_no", "rcept_dt", "flr_nm", "rm", "corp_cls")} + payload = {"symbol": symbol, "name": spec.get("name", symbol), "provider": "OpenDART", "title": title, + "published_date": published, "published_at": None, "published_precision": "date_only", + "is_correction": "정정" in title, "has_later_correction": "정" in str(raw.get("rm", "")), + "is_withdrawal": "철" in str(raw.get("rm", "")), "corrects_record_id": None, + "source_url": f"https://dart.fss.or.kr/dsaf001/main.do?rcpNo={receipt}", "raw_fields": raw_fields} + records.append(_record("disclosure", f"dart:{receipt}", payload, observed_at)) + if page >= total_pages: + break + if requested_pages < total_pages: + issues.append("pagination_limit_reached") + elif reported_count is not None and len(records) != reported_count: + issues.append("disclosure_count_mismatch") + except SourceError as exc: + issues.append(str(exc)) + except Exception as exc: + issues.append(type(exc).__name__) + if issues: + status = "partial" if records else "unavailable" + return {"symbol": symbol, "name": spec.get("name", symbol), "status": status, + "confirmed_empty": status == "ready" and not records, "issues": sorted(set(issues)), + "pages_fetched": requested_pages, "pages_reported": total_pages, "records": records} + + +def _quantity(value): + if type(value) is int: + return value + if isinstance(value, str) and re.fullmatch(r"[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)", value.strip()): + return int(value.strip().replace(",", "")) + return None + + +def collect_investor_flows(spec, *, transport, sessions, timeout, deadline): + symbol, records, issues = spec["symbol"], [], [] + source_url = NAVER_URL.format(symbol=symbol) + try: + _guard(deadline) + data = transport.get_json(source_url, timeout=timeout) + observed_at = _now().isoformat() + if not isinstance(data, dict) or data.get("itemCode") != symbol or not isinstance(data.get("dealTrendInfos"), list): + raise SourceError("invalid_flow_schema") + seen = set() + for raw in data["dealTrendInfos"]: + if not isinstance(raw, dict) or raw.get("itemCode") != symbol: + issues.append("invalid_flow_row") + continue + trade_date = _dated(raw.get("bizdate")) + if not trade_date: + issues.append("invalid_flow_date") + continue + if trade_date not in sessions: + continue # Current/provisional and older out-of-window rows are excluded. + if trade_date in seen: + issues.append("duplicate_flow_session") + records = [record for record in records if record["trade_date"] != trade_date] + continue + seen.add(trade_date) + quantities = {"individual_net_buy_shares": _quantity(raw.get("individualPureBuyQuant")), + "institution_net_buy_shares": _quantity(raw.get("organPureBuyQuant")), + "foreign_net_buy_shares": _quantity(raw.get("foreignerPureBuyQuant"))} + missing = [field for field, value in quantities.items() if value is None] + if missing: + issues.append("missing_investor_quantities") + payload = {"symbol": symbol, "name": spec.get("name", symbol), "provider": "NAVER Securities public web", + "trade_date": trade_date, "published_at": None, "published_precision": "not_provided", + "unit": "shares", "source_url": source_url, "source_page": f"https://stock.naver.com/domestic/stock/{symbol}/investmentinfo", + "market_scope": "provider_unspecified_venue_coverage", "status": "partial" if missing else "ready", + "missing_fields": missing, **quantities, + "raw_fields": {field: raw.get(field) for field in ("itemCode", "bizdate", "individualPureBuyQuant", "organPureBuyQuant", "foreignerPureBuyQuant")}} + records.append(_record("investor_flow", f"naver:{symbol}:{trade_date}", payload, observed_at)) + missing_sessions = sorted(set(sessions) - {row["trade_date"] for row in records}) + if missing_sessions: + issues.append("missing_flow_sessions") + except SourceError as exc: + issues.append(str(exc)) + except Exception as exc: + issues.append(type(exc).__name__) + status = "ready" if records and not issues else ("partial" if records else "unavailable") + return {"symbol": symbol, "name": spec.get("name", symbol), "status": status, "confirmed_empty": False, + "issues": sorted(set(issues)), "missing_sessions": sorted(set(sessions) - {row["trade_date"] for row in records}), "records": records} + + +def _section(provider, results): + records = [record for result in results for record in result["records"]] + ready = sum(result["status"] == "ready" for result in results) + status = "ready" if ready == len(results) and results else ("partial" if records or ready else "unavailable") + return {"provider": provider, "status": status, "records": records, + "coverage": {"requested": len(results), "ready": ready, "failed": sum(result["status"] == "unavailable" for result in results), + "partial": sum(result["status"] == "partial" for result in results), "confirmed_empty": sum(result.get("confirmed_empty", False) for result in results)}, + "symbols": [{key: value for key, value in result.items() if key != "records"} for result in results]} + + +def collect_market_intelligence(config=None, *, transport=None, dart_api_key=None, now=None) -> dict: + """Collect public research data within a bounded budget; never writes a DB.""" + config = config or load_intelligence_config() + _validate(config) + started = _now() + evaluation = _now(now) if now is not None else started + end = expected_session(evaluation) + calendar = _calendar() + count = (config.get("investor_flows") or {}).get("lookback_sessions", 4) + sessions = [str(day.date()) for day in _sessions((evaluation.date() - timedelta(days=35)).isoformat(), end, calendar)][-count:] + # Disclosures may include today's actually observed receipts; exact published + # time remains unknown. Daily flow excludes current-day provisional quantities. + disclosure_end = evaluation.date().isoformat() + start = (evaluation.date() - timedelta(days=(config.get("disclosures") or {}).get("lookback_calendar_days", 14))).isoformat() + transport = transport or PublicTransport() + timeout = config.get("request_timeout_seconds", 12) + deadline = time.monotonic() + config.get("collection_budget_seconds", 120) + if dart_api_key is None: + from config.config_loader import Config + dart_api_key = (Config.get().dart or {}).get("api_key", "") + mapping, mapping_hash, dart_issue = {}, None, None + if not (config.get("disclosures") or {}).get("enabled", True): + dart_issue = "collector_disabled" + elif not dart_api_key: + dart_issue = "dart_api_key_missing" + else: + try: + mapping, mapping_hash = _corp_codes(transport, dart_api_key, timeout, deadline) + except SourceError as exc: + dart_issue = str(exc) + except Exception as exc: + dart_issue = type(exc).__name__ + results = {"disclosures": [], "investor_flows": []} + with ThreadPoolExecutor(max_workers=config.get("workers", 3)) as pool: + futures = {} + for spec in config["universe"]: + if dart_issue: + results["disclosures"].append({"symbol": spec["symbol"], "name": spec.get("name"), "status": "unavailable", "confirmed_empty": False, "issues": [dart_issue], "records": []}) + else: + future = pool.submit(collect_disclosures, spec, corp_code=mapping.get(spec["symbol"]), key=dart_api_key, + transport=transport, start=start, end=disclosure_end, timeout=timeout, deadline=deadline, + max_pages=(config.get("disclosures") or {}).get("max_pages_per_company", 4)) + futures[future] = "disclosures" + if (config.get("investor_flows") or {}).get("enabled", True): + future = pool.submit(collect_investor_flows, spec, transport=transport, sessions=sessions, timeout=timeout, deadline=deadline) + futures[future] = "investor_flows" + else: + results["investor_flows"].append({"symbol": spec["symbol"], "name": spec.get("name"), "status": "unavailable", "issues": ["collector_disabled"], "records": []}) + for future in as_completed(futures): + results[futures[future]].append(future.result()) + for values in results.values(): + values.sort(key=lambda row: row["symbol"]) + sections = {"disclosures": _section("OpenDART", results["disclosures"]), "investor_flows": _section("NAVER Securities public web", results["investor_flows"])} + statuses = [section["status"] for section in sections.values()] + return {"schema_version": SCHEMA_VERSION, "research_only": True, "replay": now is not None, + "status": "ready" if all(status == "ready" for status in statuses) else ("unavailable" if all(status == "unavailable" for status in statuses) else "partial"), + "collection_started_at": started.isoformat(), "generated_at": _now().isoformat(), "evaluation_at": evaluation.isoformat(), + "expected_session": end, "config_hash": _digest(config), "corporation_mapping_hash": mapping_hash, + "disclosure_window": {"start": start, "end": disclosure_end}, "flow_sessions": sessions, **sections, + "sources": [{"provider": "OpenDART", "documentation_url": DART_GUIDE, + "endpoint": "https://opendart.fss.or.kr/api/list.json", "authentication": "configured_api_key"}, + {"provider": "NAVER Securities public web", "endpoint_template": NAVER_URL, + "documentation_url": None, "authentication": "public", "contracted_api": False}], + "limitations": ["공시 접수일은 날짜 정밀도이며 실제 공개 시각을 제공하지 않습니다. 관측 전 시점에 알았던 정보로 취급하지 않습니다.", + "수급은 NAVER 증권 공개 웹 자료의 순매수 수량(주)이며 거래대금·시장 전체 합계·계약된 KRX 원본 피드가 아닙니다.", + "거래소별 집계 범위와 공급자 공개 시각은 제공되지 않습니다. 현재일 수급은 제외합니다.", + "공시 정정은 별도 접수번호 또는 공급자 내용 변경으로 보존하며 원 공시와의 연결을 추정하지 않습니다.", + "연구 자료 수집이며 주문·전략 승격·목표 비중을 변경하지 않습니다."]} + + +def _atomic_json(path, value, *, immutable=False): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".intelligence-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2, allow_nan=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + if immutable: + # Hard-link creation is atomic and refuses an existing name. Unlike + # replace(), this cannot overwrite evidence if writers overlap. + os.link(temporary, path) + else: + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def save_market_intelligence(report, output_dir=None) -> dict: + """Persist immutable evidence; caller should serialize latest-report writers.""" + directory = Path(output_dir) if output_dir else intelligence_directory() + # Work on a copy so a caller cannot mistake saved first-observation metadata for + # an in-memory collection time. The returned paths locate the canonical report. + report = json.loads(json.dumps(report, allow_nan=False)) + previous = read_market_intelligence(directory / "latest.json") + prior = {row["record_id"]: row for section in ("disclosures", "investor_flows") for row in (previous.get(section) or {}).get("records", [])} + wanted = {row["record_id"] for section in ("disclosures", "investor_flows") for row in report[section]["records"]} - set(prior) + if wanted: + priorities = {} + # Versions retain their FIRST observation. To recover the most recent + # state after a failed attempt, use collection history: A->B->A->failure + # must link the next revision to A, not to B's newer first observation. + for path in (directory / "snapshots").glob("*.json"): + snapshot = json.loads(path.read_text(encoding="utf-8")) + if path.name != f"{snapshot['expected_session']}_{_digest(snapshot)[:24]}.json": + raise ValueError("immutable snapshot identity mismatch") + for section in ("disclosures", "investor_flows"): + for candidate in snapshot[section]["records"]: + identity = candidate["record_id"] + if identity not in wanted: + continue + if candidate["revision_id"] != _record_digest(candidate): + raise ValueError("immutable evidence identity mismatch") + priority = (_timestamp(candidate["observed_at"]), _timestamp(snapshot["generated_at"])) + if priority == priorities.get(identity) and candidate["revision_id"] != prior[identity]["revision_id"]: + raise ValueError("ambiguous source revision chronology") + if identity not in priorities or priority > priorities[identity]: + prior[identity], priorities[identity] = candidate, priority + for section in ("disclosures", "investor_flows"): + for row in report[section]["records"]: + identity = hashlib.sha256(row["record_id"].encode()).hexdigest()[:24] + version = directory / "versions" / f"{identity}_{row['revision_id']}.json" + if version.exists(): + stored = json.loads(version.read_text(encoding="utf-8")) + if stored.get("record_id") != row["record_id"] or stored.get("revision_id") != row["revision_id"] or _record_digest(stored) != row["revision_id"]: + raise ValueError("immutable evidence identity mismatch") + row["first_observed_at"] = stored["first_observed_at"] + row["available_to_system_at"] = row["first_observed_at"] + old = prior.get(row["record_id"]) + row["previous_revision_id"] = old.get("revision_id") if old and old.get("revision_id") != row["revision_id"] else None + row["version_path"] = f"versions/{version.name}" + if not version.exists(): + try: + _atomic_json(version, row, immutable=True) + except FileExistsError: + # A concurrent writer won creation. Refuse promotion; the + # next serialized collection will reconcile first observation. + raise ValueError("concurrent evidence writer; retry collection") from None + snapshot_id = _digest(report) + snapshot = directory / "snapshots" / f"{report['expected_session']}_{snapshot_id[:24]}.json" + if not snapshot.exists(): + try: + _atomic_json(snapshot, report, immutable=True) + except FileExistsError: + pass # Identical content-addressed snapshot already exists. + _atomic_json(directory / "latest.json", report) + return {"latest": str(directory / "latest.json"), "snapshot": str(snapshot), "snapshot_id": snapshot_id} + + +def read_market_intelligence(path=None, now=None) -> dict: + """Read-only dashboard accessor with deadline/config freshness checks.""" + target = Path(path) if path else intelligence_directory() / "latest.json" + try: + report = json.loads(target.read_text(encoding="utf-8")) + if not isinstance(report, dict) or report.get("schema_version") != SCHEMA_VERSION or report.get("research_only") is not True: + raise ValueError("invalid intelligence schema") + statuses = {"ready", "partial", "unavailable"} + if report.get("status") not in statuses or type(report.get("replay")) is not bool: + raise ValueError("invalid report status") + if not re.fullmatch(r"[0-9a-f]{64}", report.get("config_hash", "")): + raise ValueError("invalid config provenance") + for section in ("disclosures", "investor_flows"): + if not isinstance(report.get(section), dict) or not isinstance(report[section].get("records"), list) or report[section].get("status") not in statuses or len(report[section]["records"]) > 30000: + raise ValueError("invalid section schema") + if report["status"] == "ready" and any(report[section]["status"] != "ready" for section in ("disclosures", "investor_flows")): + raise ValueError("inconsistent readiness") + generated = _timestamp(report["generated_at"]) + started = _timestamp(report["collection_started_at"]) + evaluation = _timestamp(report["evaluation_at"]) + current = _now(now) + if generated > current or started > generated or (not report["replay"] and evaluation != started): + raise ValueError("future generation timestamp") + for section in ("disclosures", "investor_flows"): + seen = set() + for row in report[section]["records"]: + if not isinstance(row, dict) or not started <= _timestamp(row["observed_at"]) <= generated or _timestamp(row["first_observed_at"]) > _timestamp(row["observed_at"]) or row["available_to_system_at"] != row["first_observed_at"]: + raise ValueError("invalid observation provenance") + if row["record_id"] in seen or row["revision_id"] != _record_digest(row): + raise ValueError("invalid record version") + seen.add(row["record_id"]) + report["read_expected_session"] = expected_session(current) + report["config_changed"] = path is None and report.get("config_hash") != _digest(load_intelligence_config()) + # A verified completed session does not expire merely because a weekend + # or exchange holiday lasts longer than 36 hours. Source failures retain + # their own partial/unavailable status; collection heartbeat is separate. + report["is_stale"] = report["expected_session"] != report["read_expected_session"] or report["config_changed"] + if report["is_stale"] and report.get("status") != "unavailable": + report["status"] = "stale" + return report + except (OSError, ValueError, TypeError, KeyError, AttributeError, yaml.YAMLError): + return {"schema_version": SCHEMA_VERSION, "research_only": True, "status": "unavailable", "reason": "intelligence_snapshot_unavailable", + "disclosures": {"status": "unavailable", "records": []}, "investor_flows": {"status": "unavailable", "records": []}} diff --git a/core/market_research.py b/core/market_research.py new file mode 100644 index 00000000..6c32c6a4 --- /dev/null +++ b/core/market_research.py @@ -0,0 +1,353 @@ +"""Timestamped market observations; deliberately has no portfolio or order dependency. + +Every comparison uses the benchmark's exact session dates. Current-day candles are +excluded even after the close: a daily supplier can publish provisional closes and +the project does not yet have an exchange finalization feed. Missing observations +remain missing; they never become neutral scores or synthetic prices. +""" +from __future__ import annotations + +import hashlib +import json +import math +import os +import tempfile +from datetime import date, datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +import numpy as np +import pandas as pd +import yaml + +ROOT = Path(__file__).resolve().parents[1] +KST = ZoneInfo("Asia/Seoul") +SCHEMA_VERSION = 1 + + +def research_directory() -> Path: + return Path(os.environ.get("QUANT_MARKET_RESEARCH_DIR", ROOT / "output" / "market_research")) + + +def _now(value=None) -> datetime: + stamp = pd.Timestamp(value if value is not None else datetime.now(KST)) + if stamp.tzinfo is None: + stamp = stamp.tz_localize(KST) + return stamp.tz_convert(KST).to_pydatetime() + + +def load_research_config(path=None) -> dict: + from config.config_loader import load_yaml + + config = load_yaml(str(path or ROOT / "config" / "market_research.yaml")) + _validate_config(config) + return config + + +def _validate_config(config: dict) -> None: + if not isinstance(config, dict) or not isinstance(config.get("universe", []), list): + raise ValueError("research configuration must contain an observation list") + if not 1 <= len(config.get("universe", [])) <= 100: + raise ValueError("research observation universe must contain 1 to 100 symbols") + horizons = config.get("horizons", [20, 60, 120]) + if not horizons or any(type(n) is not int or n < 1 or n > 504 for n in horizons): + raise ValueError("horizons must be positive session counts <= 504") + if config.get("ranking_horizon", 60) not in horizons: + raise ValueError("ranking_horizon must occur in horizons") + rows = [config.get("benchmark", {})] + config.get("universe", []) + if any(not isinstance(row, dict) for row in rows): + raise ValueError("benchmark and observations must be mappings") + symbols = [str(row.get("symbol", "")) for row in rows] + if any(not symbol or not symbol.isalnum() for symbol in symbols) or len(set(symbols)) != len(symbols): + raise ValueError("benchmark and observation symbols must be nonempty and unique") + if not isinstance(config.get("require_adjusted_prices", True), bool): + raise ValueError("require_adjusted_prices must be boolean") + + +def _calendar(holidays=None) -> set[str]: + if holidays is not None: + return {str(day)[:10] for day in holidays} + path = ROOT / "config" / "holidays.yaml" + # Read-only: the research path must never trigger a calendar update. + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + values = data.get("holidays", data.get("dates", [])) + if not isinstance(values, list) or not values: + raise ValueError("trading holiday calendar is missing") + return {str(day)[:10] for day in values} + + +def expected_session(now=None, *, holidays=None) -> str: + current = _now(now).date() + days = _calendar(holidays) + if holidays is None and str(current.year) not in {d[:4] for d in days}: + raise ValueError("trading holiday calendar does not cover this year") + previous = current - timedelta(days=1) + while previous.weekday() >= 5 or previous.isoformat() in days: + previous -= timedelta(days=1) + return previous.isoformat() + + +def _sessions(start: str, end: str, holidays: set[str]) -> pd.DatetimeIndex: + return pd.DatetimeIndex([day for day in pd.bdate_range(start, end) if day.date().isoformat() not in holidays]) + + +def _frame(raw, cutoff: str, holidays: set[str]) -> tuple[pd.DataFrame, list[str]]: + if raw is None or raw.empty: + return pd.DataFrame(), ["price_missing"] + frame = raw.copy() + frame.columns = [str(column).lower() for column in frame.columns] + if "date" in frame: + frame = frame.set_index("date") + try: + dates = pd.to_datetime(frame.index) + if dates.tz is not None: + dates = dates.tz_convert(KST).tz_localize(None) + frame.index = dates.normalize() + except (ValueError, TypeError, AttributeError): + return pd.DataFrame(), ["invalid_dates"] + frame = frame.loc[(frame.index <= pd.Timestamp(cutoff)) & ~frame.index.isna()].sort_index() + frame = frame.loc[(frame.index.dayofweek < 5) & ~frame.index.strftime("%Y-%m-%d").isin(holidays)] + if frame.index.has_duplicates: + return pd.DataFrame(), ["duplicate_session"] + if frame.empty or "close" not in frame: + return pd.DataFrame(), ["price_missing"] + frame["close"] = pd.to_numeric(frame["close"], errors="coerce") + frame.loc[~np.isfinite(frame["close"]) | (frame["close"] <= 0), "close"] = np.nan + if "volume" in frame: + frame["volume"] = pd.to_numeric(frame["volume"], errors="coerce") + frame.loc[~np.isfinite(frame["volume"]) | (frame["volume"] < 0), "volume"] = np.nan + return frame, [] + + +def _source(value) -> dict: + if isinstance(value, dict): + return {"name": value.get("name", value.get("source", "unknown")), "adjusted": value.get("adjusted"), "observed_at": value.get("observed_at")} + name = str(value or "unknown") + adjusted = True if name in {"FinanceDataReader", "yfinance", "FinanceDataReader+yfinance"} else (False if name == "KIS" else None) + return {"name": name, "adjusted": adjusted, "observed_at": None} + + +def _rounded(value): + return round(float(value), 6) if value is not None and math.isfinite(float(value)) else None + + +def _observation(spec: dict, frame: pd.DataFrame, benchmark: pd.DataFrame, expected: str, + horizons: list[int], source: dict, holidays: set[str], issues: list[str], require_adjusted: bool) -> dict: + row = {key: spec.get(key, "") for key in ("symbol", "name", "cohort", "role", "group", "reference")} + row.update(as_of=None, source=source, issues=list(issues), status="unavailable", horizons={}, volume_ratio_20d=None, + volatility_20d_pct=None, drawdown_120d_pct=None, above_sma60=None, rank=None) + if frame.empty: + row["horizons"] = {str(n): None for n in horizons} + return row + row["as_of"] = frame.index[-1].date().isoformat() + row["input_hash"] = hashlib.sha256(frame[[column for column in ("close", "volume") if column in frame]].to_csv().encode()).hexdigest() + row["missing_sessions"] = max(0, len(_sessions(row["as_of"], expected, holidays)) - 1) + if row["as_of"] != expected: + row["issues"].append("stale_price") + if require_adjusted and source.get("adjusted") is not True: + row["issues"].append("adjustment_unverified") + if "+" in str(source.get("name", "")): + row["issues"].append("mixed_price_sources") + if "mixed_price_sources" in row["issues"] or "adjustment_unverified" in row["issues"]: + # Adjustment labels do not prove that two providers share the same scale. + # A splice can manufacture momentum, so even displayed returns stay absent. + row["horizons"] = {str(n): None for n in horizons} + row["status"] = "partial" + return row + latest = pd.Timestamp(expected) + # All assets have the same end date; a halted/missing asset is never silently + # compared over a different interval from the benchmark or other observations. + for count in horizons: + value = None + if not benchmark.empty and latest in benchmark.index and len(benchmark.loc[:latest]) > count: + dates = benchmark.loc[:latest].index[-(count + 1):] + expected_dates = _sessions(str(dates[0].date()), expected, holidays) + a = frame["close"].reindex(dates) + b = benchmark["close"].reindex(dates) + if dates.equals(expected_dates) and a.notna().all() and b.notna().all(): + ret, bench_ret = (a.iloc[-1] / a.iloc[0] - 1) * 100, (b.iloc[-1] / b.iloc[0] - 1) * 100 + value = {"return_pct": _rounded(ret), "benchmark_return_pct": _rounded(bench_ret), + "excess_pct": _rounded(ret - bench_ret), "start_date": str(dates[0].date()), "end_date": expected} + row["horizons"][str(count)] = value + if value is None: + row["issues"].append(f"incomplete_{count}d_window") + for size in (21, 60, 121): + tail = frame.tail(size) + if len(tail) != size or row["as_of"] != expected or not tail.index.equals(_sessions(str(tail.index[0].date()), expected, holidays)) or tail["close"].isna().any(): + continue + if size == 21: + row["volatility_20d_pct"] = _rounded(tail["close"].pct_change(fill_method=None).dropna().std(ddof=1) * np.sqrt(252) * 100) + if "volume" in tail and tail["volume"].notna().all() and tail["volume"].iloc[:-1].mean() > 0: + row["volume_ratio_20d"] = _rounded(tail["volume"].iloc[-1] / tail["volume"].iloc[:-1].mean()) + elif size == 60: + row["above_sma60"] = bool(tail["close"].iloc[-1] > tail["close"].mean()) + elif size == 121: + row["drawdown_120d_pct"] = _rounded((tail["close"] / tail["close"].cummax() - 1).min() * 100) + if row["volume_ratio_20d"] is None: + row["issues"].append("volume_unavailable") + row["status"] = "ready" if not row["issues"] else "partial" + return row + + +def build_market_research(config: dict, prices: dict, *, sources=None, generated_at=None, evaluation_at=None, + holidays=None, previous=None, replay=True) -> dict: + """Build a JSON-safe observation report from prices without I/O or trading.""" + _validate_config(config) + generated = _now(generated_at) + now = _now(evaluation_at) if evaluation_at is not None else generated + calendar = _calendar(holidays) + expected = expected_session(now, holidays=holidays) + config_hash = hashlib.sha256(json.dumps(config, ensure_ascii=False, sort_keys=True).encode()).hexdigest() + horizons = config.get("horizons", [20, 60, 120]) + benchmark_spec = config["benchmark"] + frames, frame_issues = {}, {} + for spec in [benchmark_spec] + config.get("universe", []): + symbol = spec["symbol"] + frames[symbol], frame_issues[symbol] = _frame(prices.get(symbol), expected, calendar) + benchmark = frames[benchmark_spec["symbol"]] + args = (benchmark, expected, horizons) + rows = [_observation(spec, frames[spec["symbol"]], *args, _source((sources or {}).get(spec["symbol"])), calendar, + frame_issues[spec["symbol"]], config.get("require_adjusted_prices", True)) + for spec in [benchmark_spec] + config.get("universe", [])] + benchmark_row, observations = rows[0], rows[1:] + benchmark_eligible = benchmark_row["as_of"] == expected and not {"adjustment_unverified", "mixed_price_sources"}.intersection(benchmark_row["issues"]) + if not benchmark_eligible: + for row in observations: + row["horizons"] = {str(n): None for n in horizons} + row["issues"].append("benchmark_not_comparable") + if row["status"] == "ready": + row["status"] = "partial" + rank_horizon = str(config.get("ranking_horizon", 60)) + cohorts = sorted({row["cohort"] for row in observations}) + breadth, leaders, laggards = [], [], [] + for cohort in cohorts: + cohort_rows = [row for row in observations if row["cohort"] == cohort] + eligible = [row for row in cohort_rows if benchmark_eligible and row["as_of"] == expected and "adjustment_unverified" not in row["issues"] and row["horizons"].get(rank_horizon) is not None] + eligible.sort(key=lambda row: (-row["horizons"][rank_horizon]["excess_pct"], row["symbol"])) + for rank, row in enumerate(eligible, start=1): + row["rank"] = rank + trend_rows = [row for row in eligible if row["above_sma60"] is not None] + breadth.append({"cohort": cohort, "scope": "configured_observation_universe", "total": len(cohort_rows), "eligible": len(eligible), + "above_sma60_count": sum(row["above_sma60"] for row in trend_rows), "sma60_denominator": len(trend_rows), + "above_sma60_pct": _rounded(100 * sum(row["above_sma60"] for row in trend_rows) / len(trend_rows)) if trend_rows else None, + "outperforming_count": sum(row["horizons"][rank_horizon]["excess_pct"] > 0 for row in eligible)}) + leaders.extend(eligible[:3]) + laggards.extend(list(reversed(eligible[-3:]))) + lead_changes = [] + if previous and previous.get("replay", False) == replay and previous.get("config_hash") == config_hash and isinstance(previous.get("as_of"), str) and previous["as_of"] < expected: + prior = {row["symbol"]: row for row in previous.get("observations", []) if row.get("rank") is not None} + comparable_cohorts = {cohort for cohort in cohorts + if {row["symbol"] for row in observations if row["cohort"] == cohort and row["rank"] is not None} + == {row["symbol"] for row in prior.values() if row.get("cohort") == cohort}} + for row in observations: + old = prior.get(row["symbol"]) + if old and row["cohort"] in comparable_cohorts and row["rank"] is not None and old["rank"] != row["rank"]: + lead_changes.append({"symbol": row["symbol"], "name": row["name"], "cohort": row["cohort"], + "previous_rank": old["rank"], "rank": row["rank"], "previous_as_of": previous["as_of"]}) + ready = sum(row["status"] == "ready" for row in observations) + ranked = sum(row["rank"] is not None for row in observations) + return {"schema_version": SCHEMA_VERSION, "name": config.get("name", "시장 관찰 레이더"), "research_only": True, + "status": "ready" if ready == len(observations) and benchmark_row["status"] == "ready" else ("partial" if ranked else "unavailable"), + "generated_at": generated.isoformat(), "evaluation_at": now.isoformat(), "replay": replay, + "as_of": expected if ranked else None, "expected_session": expected, + "config_hash": config_hash, "ranking_horizon": int(rank_horizon), "benchmark": benchmark_row, + "coverage": {"total": len(observations), "ready": ready, "ranked": ranked, "missing": sum(row["status"] == "unavailable" for row in observations)}, + "breadth": breadth, "leaders": leaders, "laggards": laggards, "observations": observations, "lead_changes": lead_changes, + "limitations": ["연구 관찰값이며 주문·목표 비중·전략 승인을 변경하지 않습니다.", "당일 봉은 장 마감 후에도 제외하고 이전 거래일까지만 사용합니다.", + "현재 설정한 표본 안의 비교이며 전체 시장의 폭이나 과거 투자 가능 유니버스를 뜻하지 않습니다.", + "상대 수익률은 가격 기준이며 ETF 총수익·배당·세금·체결 비용을 보장하지 않습니다.", + "업종 ETF는 산업 관찰용 대리 변수입니다. 정책·소비·기술 변화의 인과관계를 검증한 신호가 아닙니다."]} + + +def collect_market_research(config=None, collector=None, now=None, *, previous=None) -> dict: + """Fetch public OHLCV into memory. No database initialization or write occurs.""" + config = config or load_research_config() + _validate_config(config) + replay = now is not None + now = _now(now) + expected = expected_session(now) + if collector is None: + from types import SimpleNamespace + from core.data_collector import DataCollector + # Do not enable broker fallback merely because the trading config does. + collector = DataCollector(SimpleNamespace(settings={"data_source": {"allow_kis_fallback": False}})) + start = (date.fromisoformat(expected) - timedelta(days=int(max(config.get("horizons", [120])) * 2.2 + 50))).isoformat() + prices, sources, failures = {}, {}, {} + for spec in [config["benchmark"]] + config.get("universe", []): + symbol = spec["symbol"] + try: + prices[symbol] = collector.fetch_korean_stock(symbol, start_date=start, end_date=expected) + info = collector.get_last_source_info() if hasattr(collector, "get_last_source_info") else {} + name = (info.get("history") or {}).get(symbol) or info.get("source") or "unknown" + source = _source(name) + source["observed_at"] = _now().isoformat() + sources[symbol] = source + except Exception as exc: + # Exception text from providers may contain URLs/credentials; retain class only. + failures[symbol] = type(exc).__name__ + report = build_market_research(config, prices, sources=sources, generated_at=_now(), evaluation_at=now, previous=previous, replay=replay) + report["collection_failures"] = failures + return report + + +def save_market_research(report: dict, output_dir=None) -> dict: + """Immutable snapshot first, atomically replace latest second; no JSONL tearing.""" + directory = Path(output_dir) if output_dir else research_directory() + snapshots = directory / "snapshots" + snapshots.mkdir(parents=True, exist_ok=True) + content = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n" + digest = hashlib.sha256(content.encode()).hexdigest()[:20] + snapshot = snapshots / f"{report['expected_session']}_{digest}.json" + if snapshot.exists() and snapshot.read_text(encoding="utf-8") != content: + raise ValueError("immutable research snapshot content mismatch") + for target in ([snapshot] if not snapshot.exists() else []) + [directory / "latest.json"]: + fd, temporary = tempfile.mkstemp(prefix=".research-", suffix=".tmp", dir=target.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return {"latest": str(directory / "latest.json"), "snapshot": str(snapshot)} + + +def read_market_research(path=None, now=None) -> dict: + """Dashboard accessor: no network, writes, or fabricated fresh observations.""" + target = Path(path) if path else research_directory() / "latest.json" + try: + report = json.loads(target.read_text(encoding="utf-8")) + if not isinstance(report, dict) or report.get("schema_version") != SCHEMA_VERSION or report.get("research_only") is not True: + raise ValueError("unsupported research snapshot") + observations = report.get("observations") + if not isinstance(observations, list) or len(observations) > 100 or not all(isinstance(row, dict) for row in observations): + raise ValueError("invalid observations") + if report.get("status") not in {"ready", "partial", "unavailable"} or not isinstance(report.get("benchmark"), dict): + raise ValueError("invalid research snapshot") + if not isinstance(report.get("generated_at"), str): + raise ValueError("generation timestamp is missing") + generated = _now(report["generated_at"]) + if generated > _now(now): + raise ValueError("research snapshot is from the future") + for row in [report["benchmark"]] + observations: + source = row.get("source") or {} + if not isinstance(source, dict): + raise ValueError("invalid source provenance") + observed_at = source.get("observed_at") + if observed_at and report.get("replay") is not True and _now(observed_at) > generated: + raise ValueError("source was observed after the report generation timestamp") + expected = expected_session(now) + as_of = report.get("as_of") + report["read_expected_session"] = expected + report["is_stale"] = as_of != expected + if path is None: + current_hash = hashlib.sha256(json.dumps(load_research_config(), ensure_ascii=False, sort_keys=True).encode()).hexdigest() + report["config_changed"] = report.get("config_hash") != current_hash + report["is_stale"] = report["is_stale"] or report["config_changed"] + if report["is_stale"] and report.get("status") != "unavailable": + report["status"] = "stale" + return report + except (OSError, ValueError, TypeError, KeyError, yaml.YAMLError): + return {"schema_version": SCHEMA_VERSION, "research_only": True, "status": "unavailable", "reason": "research_snapshot_unavailable", "observations": []} diff --git a/core/observation_benchmark.py b/core/observation_benchmark.py new file mode 100644 index 00000000..9a445bc8 --- /dev/null +++ b/core/observation_benchmark.py @@ -0,0 +1,143 @@ +"""Recent NAVER public price-index observations for paper validation only. + +This is NAVER's public web feed, not a licensed/direct KRX feed. Index levels are +price indices, not dividend-reinvested total returns. No database or order access. +""" +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +import hashlib +import json +import math +import re +from zoneinfo import ZoneInfo + +import pandas as pd +import requests + +from core.market_research import _calendar, _sessions + +KST = ZoneInfo("Asia/Seoul") +SOURCE = "NAVERPublicIndex" +SERIES = {"KS11": "KOSPI", "KS200": "KPI200", "KQ11": "KOSDAQ"} +ENDPOINT = "https://m.stock.naver.com/api/index/{series}/price" + + +class BenchmarkUnavailable(RuntimeError): + """Sanitized source failure; messages never contain response bodies.""" + + +def _date(value): + if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + raise BenchmarkUnavailable("invalid_benchmark_date") + try: + return date.fromisoformat(value) + except ValueError: + raise BenchmarkUnavailable("invalid_benchmark_date") from None + + +def _price(value): + if isinstance(value, bool): + raise BenchmarkUnavailable("invalid_benchmark_price") + if isinstance(value, str): + value = value.strip() + if not re.fullmatch(r"(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?", value): + raise BenchmarkUnavailable("invalid_benchmark_price") + value = value.replace(",", "") + try: + value = float(value) + except (ValueError, TypeError, OverflowError): + raise BenchmarkUnavailable("invalid_benchmark_price") from None + if not math.isfinite(value) or value <= 0: + raise BenchmarkUnavailable("invalid_benchmark_price") + return value + + +class BenchmarkCollector: + """One bounded recent-history request; missing exact dates never forward-fill. + + Optional transport/clock injection is for isolated tests. A clock override is + explicitly marked replay in source metadata. Real collection uses server time. + """ + def __init__(self, *, transport=None, clock=None): + self._transport = transport or requests + self._clock = clock + self._last_info = {"source": SOURCE, "status": "not_collected"} + + def _now(self): + value = self._clock() if self._clock else datetime.now(KST) + if not isinstance(value, datetime) or value.tzinfo is None: + raise BenchmarkUnavailable("aware_collection_clock_required") + return value.astimezone(KST) + + def get_last_source_info(self): + return dict(self._last_info) + + def fetch_korean_stock(self, symbol, start_date=None, end_date=None): + self._last_info = {"source": SOURCE, "status": "unavailable", "symbol": str(symbol)} + try: + if symbol not in SERIES: + raise BenchmarkUnavailable("unsupported_benchmark_symbol") + started = self._now() + # A small publication buffer after the 15:30 regular-market close. + # Today's provisional row is ignored when querying an earlier day. + cutoff = started.date() if started.time() >= time(16) else started.date() - timedelta(days=1) + start = _date(start_date) + end = _date(end_date) + if start > end or end > cutoff: + raise BenchmarkUnavailable("benchmark_session_not_completed") + if (started.date() - start).days > 120: + raise BenchmarkUnavailable("benchmark_recent_history_only") + holidays = _calendar() + years = {value[:4] for value in holidays} + if any(str(year) not in years for year in range(start.year, started.year + 1)): + raise BenchmarkUnavailable("benchmark_calendar_year_missing") + dates = [str(day.date()) for day in _sessions(start.isoformat(), end.isoformat(), holidays)] + if not dates or len(dates) > 60: + raise BenchmarkUnavailable("invalid_benchmark_session_window") + url = ENDPOINT.format(series=SERIES[symbol]) + try: + response = self._transport.get(url, params={"pageSize": 60, "page": 1}, timeout=10, + allow_redirects=False, + headers={"User-Agent": "quant-trader-research/1.0", "Accept": "application/json"}) + if response.status_code != 200: + raise BenchmarkUnavailable(f"benchmark_http_{int(response.status_code)}") + rows = response.json() + except requests.RequestException as exc: + raise BenchmarkUnavailable("benchmark_request_failed:" + type(exc).__name__) from None + except (ValueError, TypeError): + raise BenchmarkUnavailable("invalid_benchmark_json") from None + observed = self._now() + if observed < started: + raise BenchmarkUnavailable("benchmark_clock_rollback") + if not isinstance(rows, list) or not rows or len(rows) > 60: + raise BenchmarkUnavailable("invalid_benchmark_schema") + prices, seen = {}, set() + for row in rows: + if not isinstance(row, dict): + raise BenchmarkUnavailable("invalid_benchmark_row") + day = _date(row.get("localTradedAt")) + if day > cutoff: + continue # A valid current/provisional response is not an error. + if day in seen: + raise BenchmarkUnavailable("duplicate_benchmark_session") + seen.add(day) + if start <= day <= end: + prices[day.isoformat()] = _price(row.get("closePrice")) + if any(day not in prices for day in dates): + raise BenchmarkUnavailable("exact_benchmark_session_missing") + self._last_info = {"source": SOURCE, "status": "ready", "symbol": symbol, "series": SERIES[symbol], + "provider": "NAVER Securities public web", "source_url": url, + "price_basis": "price_index", "direct_krx_feed": False, + "observed_at": observed.isoformat(), "quote_dates": dates, + "publication_timestamp": None, "replay": self._clock is not None, + "response_hash": hashlib.sha256(json.dumps(rows, sort_keys=True, ensure_ascii=False).encode()).hexdigest()} + frame = pd.DataFrame({"close": [prices[day] for day in dates]}, index=pd.to_datetime(dates)) + frame.index.name = "date" + return frame + except BenchmarkUnavailable as exc: + self._last_info["reason"] = str(exc) + raise + except Exception as exc: + self._last_info["reason"] = "benchmark_unavailable:" + type(exc).__name__ + raise BenchmarkUnavailable(self._last_info["reason"]) from None diff --git a/core/order_executor.py b/core/order_executor.py index eb2b1746..ff33a392 100644 --- a/core/order_executor.py +++ b/core/order_executor.py @@ -28,6 +28,7 @@ from monitoring.logger import log_trade from core.order_guard import OrderGuard from core.position_lock import PositionLock +from core.execution_analytics import observe_execution, observe_order_created, observe_order_state from core.order_state import OrderBook, OrderStatus try: @@ -293,6 +294,7 @@ def _restore_persistent_open_orders(self) -> None: logger.error("DB 미완료 주문 상태 복구 실패: {}", e) def _persist_order_record(self, order) -> None: + observe_order_state(order) if self.mode != "live": return save_order_record(order) @@ -1516,6 +1518,7 @@ def _position_invested_value(position) -> float: raise ValueError("포지션 투자금에 NaN/Inf 또는 음수가 포함됨") return value + @observe_execution("BUY") def execute_buy( self, symbol: str, @@ -1833,6 +1836,7 @@ def _execute_buy_impl( requested_price=expected_price, strategy=strategy, account_key=self.account_key, mode=self.mode, ) + observe_order_created(order) # 2) 중복 주문 확인 (OrderBook 기반 + 기존 guard) existing_open = [o for o in self.order_book.get_open_orders(symbol) @@ -1999,6 +2003,7 @@ def _execute_buy_impl( return result + @observe_execution("BUY") def execute_buy_quantity( self, symbol: str, @@ -2467,6 +2472,7 @@ def _execute_buy_quantity_impl( account_key=self.account_key, mode=self.mode, ) + observe_order_created(order) existing_open = [ o for o in self.order_book.get_open_orders(symbol) if o.order_id != order.order_id @@ -2638,6 +2644,7 @@ def _execute_buy_quantity_impl( ) return result + @observe_execution("SELL") def execute_sell( self, symbol: str, @@ -2766,6 +2773,7 @@ def _execute_sell_impl( requested_price=expected_price, strategy=strategy, account_key=self.account_key, mode=self.mode, ) + observe_order_created(order) if self.mode == "live": ttl_seconds = self._safe_order_guard_ttl( @@ -3476,6 +3484,7 @@ def _reconcile_persistent_open_order_records(self, open_orders: list[dict]) -> l filled_price=resolved_filled_price, remaining_qty=max(resolved_remaining_qty, 0), reason="broker_execution_confirmed_after_recovery_check", + execution_price_observed=self._positive_order_price(filled_price) is not None, ) if updated: OrderGuard.clear(symbol) @@ -3573,6 +3582,16 @@ def _pre_order_check( if not drawdown_check["allowed"]: return drawdown_check + # 실시간 paper 바스켓은 executor를 직접 호출해도 거래 시간을 지킨다. + strict_paper_session = self.mode == "paper" and str( + getattr(self, "account_key", "") or "" + ).startswith("basket_rebalance:") + if strict_paper_session: + time_check = self.trading_hours.can_place_order() + if not time_check["allowed"]: + logger.warning("⏰ 주문 차단: {}", time_check["reason"]) + return time_check + # 페이퍼/스케줄 신규 진입은 runtime/preflight 확인에 실패하면 차단한다. if self.mode != "live": paper_check = self._paper_entry_pre_order_check( diff --git a/core/paper_validation.py b/core/paper_validation.py new file mode 100644 index 00000000..37e64637 --- /dev/null +++ b/core/paper_validation.py @@ -0,0 +1,813 @@ +"""Prospective paper-basket observations. Never initializes or writes a ledger. + +The stored close TWR uses the application's start-of-period cash-flow convention; +it is an approximation when flows occur inside a valuation interval, not a GIPS +compliance claim. Local hash-chained files detect accidental edits, not a trusted +external timestamp authority. No result grants live-trading permission. +""" +from __future__ import annotations + +import hashlib +import json +import math +import os +import sqlite3 +import uuid +from contextlib import contextmanager +from datetime import date, datetime, time, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parents[1] +KST = ZoneInfo("Asia/Seoul") +SCHEMA_VERSION = 1 +DIRECT_CLOSE_METHOD = "observed_close_to_close_start_period_flows" +OBSERVATION_RULE = "confirmed_close_plus_30m_cutoff_ledger_v2" +# Deliberately excludes main/scheduler, dashboards, research and reporting code. +# New execution dependencies must be added explicitly when the trading path changes. +OPERATING_CODE_FILES = ( + "core/basket_rebalancer.py", "core/basket_risk.py", "core/basket_close.py", "core/basket_deploy.py", + "core/portfolio_manager.py", "core/risk_manager.py", "core/risk_overlays.py", + "core/order_executor.py", "core/order_guard.py", "core/order_state.py", + "core/position_lock.py", "core/trading_hours.py", "core/data_collector.py", + "core/market_regime.py", "core/blackswan_detector.py", "core/earnings_filter.py", "core/dart_loader.py", + "core/instrument_classes.py", "database/models.py", "database/repositories.py", + "config/config_loader.py", +) +LIMITATIONS = [ + "연구용 모의운용 관측이며 실계좌 자동 승격 권한이 없습니다.", + "기록된 종가 TWR은 입출금을 측정 구간 시작에 반영하는 근사치입니다. GIPS 인증 성과가 아닙니다.", + "모의 체결 비용은 실제 체결 품질을 입증하지 않습니다. 슬리피지는 체결 가격에 포함돼 재차 차감하지 않습니다.", + "비교지수는 배당 제외 가격지수이며 현금·방어자산 비중이 다른 계좌와의 차이는 위험조정 알파가 아닙니다.", + "NAVER 공개 비교지수는 공개 웹 API의 가격지수이며 계약된 거래소 데이터 피드가 아닙니다.", + "상승·하락 표본은 비교지수의 일별 등락으로 구분하며 장기 강세장·약세장 완주를 뜻하지 않습니다.", + "관측 최소치 충족은 수익성 합격 판정이 아닙니다. 정책 변경 이전 관측은 별도 코호트로 보존됩니다.", +] + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def _stamp(value): + return value.astimezone(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z") + + +def _dt(value): + value = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return (value.replace(tzinfo=KST) if value.tzinfo is None else value).astimezone(timezone.utc) + + +def _hash(value): + return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest() + + +def _number(value, positive=False): + if isinstance(value, bool): + raise ValueError("boolean is not a ledger number") + number = float(value) + if not math.isfinite(number) or (positive and number <= 0): + raise ValueError("invalid ledger number") + return number + + +def _round(value): + return round(value, 8) if value is not None and math.isfinite(value) else None + + +def _yaml(path): + # Duplicate YAML keys are rejected by the project's safe loader. + from config.config_loader import load_yaml + from yaml import YAMLError + try: + return load_yaml(str(path)) + except YAMLError: + # Parser exceptions can include entire input lines, including credentials. + raise ValueError("invalid_yaml_configuration") from None + + +def _directory(output_dir=None): + return Path(output_dir or os.environ.get("QUANT_PAPER_VALIDATION_DIR") or ROOT / "data" / "paper_validation").resolve() + + +def _protocol(config_path=None): + value = _yaml(config_path or os.environ.get("QUANT_PAPER_VALIDATION_CONFIG") or ROOT / "config" / "paper_validation.yaml") + allowed = {"schema_version", "protocol_version", "minimum_observations", "minimum_calendar_days", + "minimum_regime_observations", "maximum_missing_ratio", "maximum_capture_delay_sessions", "baskets"} + if not isinstance(value, dict) or set(value) != allowed or value["schema_version"] != 1: + raise ValueError("invalid paper-validation protocol schema") + if not isinstance(value["protocol_version"], str) or not value["protocol_version"].strip(): + raise ValueError("protocol_version is required") + for key in ("minimum_observations", "minimum_calendar_days", "minimum_regime_observations", "maximum_capture_delay_sessions"): + if type(value[key]) is not int or not 1 <= value[key] <= 10000: + raise ValueError(f"invalid {key}") + if not 0 <= _number(value["maximum_missing_ratio"]) < 1: + raise ValueError("invalid maximum_missing_ratio") + if not isinstance(value["baskets"], dict) or not value["baskets"]: + raise ValueError("baskets are required") + for name, spec in value["baskets"].items(): + benchmark = spec.get("benchmark", {}) if isinstance(spec, dict) else {} + if (not isinstance(name, str) or set(spec) != {"benchmark"} + or set(benchmark) != {"symbol", "name", "basis", "provider"} + or benchmark.get("symbol") not in {"KS11", "KS200", "KQ11"} + or benchmark.get("provider") not in {"naver_public", "data_collector"} + or benchmark.get("basis") != "price_index" or not benchmark.get("name")): + raise ValueError("a named Korean price-index benchmark is required for each basket") + return value + + +def _calendar(): + value = _yaml(ROOT / "config" / "holidays.yaml") + holidays = {str(item) for item in value.get("holidays", [])} + if not holidays: + raise ValueError("trading_calendar_missing") + for day in holidays: + date.fromisoformat(day) + return holidays + + +def _sessions(start, end, holidays): + first, last = date.fromisoformat(start), date.fromisoformat(end) + if first > last: + return [] + years = {int(day[:4]) for day in holidays} + if any(year not in years for year in range(first.year, last.year + 1)): + raise ValueError("trading_calendar_year_missing") + result = [] + while first <= last: + if first.weekday() < 5 and first.isoformat() not in holidays: + result.append(first.isoformat()) + first += timedelta(days=1) + return result + + +def _expected(now, holidays, market_close="15:30"): + # Paper evidence uses an explicitly confirmed close, unlike the T-1 research + # radar. Availability starts 30 minutes after the configured market close. + local = now.astimezone(KST) + available = datetime.combine(local.date(), time.fromisoformat(market_close), KST) + timedelta(minutes=30) + end = local.date() if local >= available else local.date() - timedelta(days=1) + return _sessions((end - timedelta(days=31)).isoformat(), end.isoformat(), holidays)[-1] + + +def _cohort_protocol(protocol, name): + benchmark = protocol["baskets"][name]["benchmark"] + value = {**{k: v for k, v in protocol.items() if k != "baskets"}, + "benchmark": benchmark, "observation_rule": OBSERVATION_RULE} + if benchmark["provider"] == "naver_public": + value["benchmark_collector_code"] = hashlib.sha256((ROOT / "core" / "observation_benchmark.py").read_bytes()).hexdigest() + return value + + +def _load_events(directory): + events, previous = [], None + for index, path in enumerate(sorted((directory / "events").glob("*.json")), 1): + event = json.loads(path.read_text(encoding="utf-8")) + digest = event.pop("hash", None) + if (event.get("sequence") != index or event.get("previous_hash") != previous + or digest != _hash(event) or event.get("schema_version") != SCHEMA_VERSION + or path.name != f"{index:08d}_{digest}.json"): + raise ValueError("paper_validation_journal_integrity_error") + if events and _dt(event["observed_at"]) < _dt(events[-1]["observed_at"]): + raise ValueError("paper_validation_clock_rollback") + event["hash"] = digest + events.append(event) + previous = digest + return events + + +def _append(directory, events, kind, data, now): + if events and now < _dt(events[-1]["observed_at"]): + raise ValueError("paper_validation_clock_rollback") + event = {"schema_version": 1, "sequence": len(events) + 1, + "previous_hash": events[-1]["hash"] if events else None, + "observed_at": _stamp(now), "kind": kind, "data": data} + digest = _hash(event) + event["hash"] = digest + folder = directory / "events" + folder.mkdir(parents=True, exist_ok=True) + target = folder / f"{len(events) + 1:08d}_{digest}.json" + temporary = folder / f".{uuid.uuid4().hex}.tmp" + try: + with temporary.open("x", encoding="utf-8", newline="\n") as stream: + json.dump(event, stream, ensure_ascii=False, sort_keys=True, allow_nan=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.link(temporary, target) # Atomic publication, never overwrite a revision. + finally: + temporary.unlink(missing_ok=True) + events.append(event) + + +def _observations(events, cohort_id): + """A retry adds a later observation; the original failed attempt stays intact.""" + rows = {} + for event in events: + data = event["data"] + if data.get("cohort_id") != cohort_id: + continue + if event["kind"] == "close_observed": + rows[data["observation"]["date"]] = json.loads(json.dumps(data["observation"])) + elif event["kind"] == "benchmark_observed" and data["date"] in rows: + row = rows[data["date"]] + if row["source_hash"] != data["valuation_source_hash"]: + raise ValueError("benchmark_close_link_mismatch") + row["benchmark"] = data["benchmark"] + row["issues"] = [issue for issue in row["issues"] if not issue.startswith("benchmark_")] + return [rows[key] for key in sorted(rows)] + + +@contextmanager +def _exclusive_lock(directory): + # OS locks release on process exit/timeout, unlike an O_EXCL sentinel file. + with (directory / ".collect.lock").open("a+b") as stream: + stream.seek(0, os.SEEK_END) + if stream.tell() == 0: + stream.write(b"0") + stream.flush() + stream.seek(0) + try: + if os.name == "nt": + import msvcrt + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + raise ValueError("paper_validation_collection_already_running") from None + try: + yield + finally: + stream.seek(0) + if os.name == "nt": + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + +def _operating_material(basket, settings, risk): + policy = {k: v for k, v in basket.items() if k not in {"name", "purpose", "enabled", "primary", "monitoring", "promotion"}} + # Only a digest of configuration is persisted. Never serialize credentials or + # arbitrary data-source settings to the journal/dashboard. + data_source = {k: settings.get("data_source", {}).get(k) for k in ("preferred", "allow_kis_fallback")} + material = {"basket": policy, "risk": risk, "trading": settings.get("trading", {}), "data_source": data_source, + "dart_enabled": settings.get("dart", {}).get("enabled", False)} + if policy.get("signal_weighted") or settings.get("trading", {}).get("market_regime_filter"): + strategies = _yaml(ROOT / "config" / "strategies.yaml") + material["strategies"] = strategies if policy.get("signal_weighted") else strategies.get("regime_adaptive", {}) + config_hash = _hash(material) + code = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in OPERATING_CODE_FILES} + if policy.get("signal_weighted"): + for path in sorted((ROOT / "strategies").glob("*.py")) + [ROOT / "core" / "indicator_engine.py"]: + code[path.relative_to(ROOT).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + exposure = {k: policy.get(k) for k in ("holdings", "target_stock_weight", "diversification", "overlays", "contribution")} + return config_hash, _hash(code), code, exposure + + +def _read_database(path): + if not path.is_file(): + raise FileNotFoundError("paper_ledger_missing") + # Do not call init_database/Config database engines, including for an empty DB. + with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=10) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") + tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")} + result, issues = {}, [] + for name in ("portfolio_valuations", "trade_history", "cash_flows"): + if name not in tables: + result[name] = None + issues.append(f"{name}_missing") + else: + result[name] = [dict(row) for row in connection.execute(f"SELECT * FROM {name} WHERE mode='paper'")] + result["historical_snapshot_counts"] = ({row[0]: row[1] for row in connection.execute( + "SELECT account_key, COUNT(*) FROM portfolio_snapshots WHERE mode='paper' GROUP BY account_key")} + if "portfolio_snapshots" in tables else {}) + return result, issues + + +def _source_close(row, all_rows, now, market_close="15:30", initial_capital=None): + day = str(row["date"])[:10] + measured, recorded = _dt(row["measured_at"]), _dt(row["recorded_at"]) + if (measured.astimezone(KST).date().isoformat() != day or not measured <= recorded <= now + or measured.astimezone(KST).time() != time.fromisoformat(market_close)): + raise ValueError("close_timestamp_invalid") + context = json.loads(row["detail"]) + method = context.get("return_method") + direct = method == DIRECT_CLOSE_METHOD + if method not in {"linked_measurements_start_period_flows", DIRECT_CLOSE_METHOD} or context.get("quote_dates") != [day]: + raise ValueError("close_method_unverified") + total, cash = _number(row["total_value"], True), _number(row["cash"]) + index = 1 + _number(row["cumulative_return"]) / 100 + if index <= 0 or cash < -1 or cash > total + 1: + raise ValueError("close_nav_invalid") + positions = context.get("positions") + if not isinstance(positions, list): + raise ValueError("close_positions_missing") + values, sources, symbols = 0.0, set(), set() + for position in positions: + quantity = _number(position["quantity"], True) + price = _number(position["price"], True) + source = position.get("source") + if (quantity != int(quantity) or position.get("quote_date") != day or not source or source == "unknown" + or position["symbol"] in symbols or not measured <= _dt(position["observed_at"]) <= recorded): + raise ValueError("close_quote_unverified") + values += quantity * price + sources.add(source) + symbols.add(position["symbol"]) + if abs(cash + values - total) > max(1.0, total * 1e-8): + raise ValueError("close_holdings_nav_mismatch") + flow = _number(context.get("flow_since")) + baseline = all_rows.get(context.get("baseline_valuation_id")) + extra = {"return_method": method} + first = False + if direct: + capital = _number(context.get("initial_capital"), True) + trade_ids = context.get("trade_ids") + if (initial_capital is None or capital != _number(initial_capital, True) + or _dt(context.get("ledger_cutoff_at")) != measured + or type(context.get("first_close_in_series")) is not bool + or not isinstance(trade_ids, list) or any(type(item) is not int for item in trade_ids) + or len(trade_ids) != len(set(trade_ids))): + raise ValueError("close_ledger_context_unverified") + prior = [item for item in all_rows.values() if item.get("kind") == "close" + and item["account_key"] == row["account_key"] and _dt(item["measured_at"]) < measured] + prior.sort(key=lambda item: (_dt(item["measured_at"]), item["id"])) + first = context["first_close_in_series"] + if first: + if (prior or context.get("baseline_valuation_id") is not None + or _dt(context.get("baseline_measured_at")) != measured or flow != 0): + raise ValueError("close_bootstrap_unverified") + elif not prior or baseline is None or baseline["id"] != prior[-1]["id"]: + raise ValueError("close_baseline_unverified") + extra.update(initial_capital=capital, ledger_trade_ids=sorted(trade_ids), first_close_in_series=first) + if first: + expected_index = 1.0 + else: + if (not baseline or baseline.get("kind") != ("close" if direct else "intraday") + or baseline["account_key"] != row["account_key"] + or _dt(baseline["measured_at"]) != _dt(context.get("baseline_measured_at")) + or not _dt(baseline["measured_at"]) <= measured + or not _dt(baseline["measured_at"]) <= _dt(baseline["recorded_at"]) <= recorded + or (str(baseline["date"])[:10] >= day if direct else str(baseline["date"])[:10] != day)): + raise ValueError("close_baseline_unverified") + denominator = _number(baseline["total_value"], True) + flow + if denominator <= 0: + raise ValueError("close_flow_base_invalid") + expected_index = (1 + _number(baseline["cumulative_return"]) / 100) * total / denominator + if not math.isclose(index, expected_index, rel_tol=1e-8, abs_tol=1e-8): + raise ValueError("close_twr_mismatch") + return {"date": day, "valuation_id": row["id"], "source_hash": _hash(row), "measured_at": _stamp(measured), + "recorded_at": _stamp(recorded), "total_value": total, "cash": cash, "twr_index": index, + "invested_ratio": (total - cash) / total, "sources": sorted(sources), + "baseline_valuation_id": baseline["id"] if baseline else None, + "baseline_source_hash": _hash(baseline) if baseline else None, + "baseline_measured_at": context["baseline_measured_at"], "close_flow_since": flow, + "positions": positions, **extra} + + +def _make_benchmark_collector(benchmark): + if benchmark["provider"] == "naver_public": + from core.observation_benchmark import BenchmarkCollector + return BenchmarkCollector() + from core.data_collector import DataCollector + return DataCollector(SimpleNamespace(settings={"data_source": {"preferred": "auto", "allow_kis_fallback": False}})) + + +def _benchmark(collector, symbol, day, now, provider="data_collector"): + try: + frame = collector.fetch_korean_stock(symbol, day, day) + source_info = collector.get_last_source_info() + source = source_info.get("source") + if not source or source == "unknown" or "+" in source or source == "KIS": + return None, "benchmark_source_unverified" + if (provider == "naver_public") != (source == "NAVERPublicIndex"): + return None, "benchmark_provider_mismatch" + if source_info.get("replay") is True: + return None, "benchmark_replay_not_forward" + if frame is None or frame.empty: + return None, "benchmark_price_missing" + frame = frame.copy() + frame.columns = [str(column).lower() for column in frame.columns] + dates = frame["date"] if "date" in frame else frame.index + matches = [index for index, value in enumerate(dates) if str(value)[:10] == day] + if len(matches) != 1 or "close" not in frame: + return None, "benchmark_exact_date_missing" + observed = _utc_now() + if source_info.get("observed_at") is not None and _dt(source_info["observed_at"]) > observed: + return None, "benchmark_observation_in_future" + quote = {"symbol": symbol, "date": day, "close": _number(frame["close"].iloc[matches[0]], True), + "source": source, "basis": "price_index", "observed_at": _stamp(observed)} + if provider == "naver_public": + quote["provenance"] = {key: source_info.get(key) for key in ( + "source_url", "response_hash", "provider", "publication_timestamp", "direct_krx_feed", "observed_at")} + return quote, None + except Exception as exc: + return None, f"benchmark_unavailable:{type(exc).__name__}" + + +def _ledger_interval(database, key, start, end, close): + result, issues = {"trades": None, "flows": None}, [] + for table, stamp, target in (("trade_history", "executed_at", "trades"), ("cash_flows", "occurred_at", "flows")): + rows = database.get(table) + if rows is None: + issues.append(f"{table}_missing") + continue + selected = [] + try: + for row in rows: + if row["account_key"] != key: + continue + at = _dt(row[stamp]) + if start < at <= end: + if target == "trades": + if row["action"] not in {"BUY", "SELL"}: + raise ValueError("trade_action_invalid") + item = {"id": row["id"], "executed_at": row[stamp], "action": row["action"], "source_hash": _hash(row)} + item.update({field: _number(row[field]) for field in ("total_amount", "commission", "tax", "slippage")}) + if any(item[field] < 0 for field in ("total_amount", "commission", "tax", "slippage")): + raise ValueError("negative_trade_cost") + else: + item = {"id": row["id"], "occurred_at": row[stamp], "amount": _number(row["amount"]), "source_hash": _hash(row)} + selected.append(item) + result[target] = sorted(selected, key=lambda row: row["id"]) + except (KeyError, TypeError, ValueError): + issues.append(f"{table}_invalid") + if database.get("cash_flows") is not None: + try: + flow = sum(_number(row["amount"]) for row in database["cash_flows"] if row["account_key"] == key + and _dt(close["baseline_measured_at"]) < _dt(row["occurred_at"]) <= end) + if not math.isclose(flow, close["close_flow_since"], abs_tol=1e-6): + issues.append("close_cash_flow_mismatch") + except (TypeError, KeyError, ValueError): + issues.append("cash_flows_invalid") + if close.get("return_method") == DIRECT_CLOSE_METHOD: + # The first close has no return interval, but its cash/holdings must still + # reconcile to the complete ledger. Preserve membership and hashes so a + # later INSERT/revision before that first close cannot silently pass. + result["cutoff_ledger"] = None + try: + trades = [row for row in database["trade_history"] if row["account_key"] == key and _dt(row["executed_at"]) <= end] + flows = [row for row in database["cash_flows"] if row["account_key"] == key and _dt(row["occurred_at"]) <= end] + if sorted(row["id"] for row in trades) != close["ledger_trade_ids"]: + issues.append("close_ledger_trade_ids_mismatch") + cash = _number(close["initial_capital"], True) + sum(_number(row["amount"]) for row in flows) + quantities = {} + for row in trades: + if row["action"] not in {"BUY", "SELL"} or not row["symbol"]: + raise ValueError("trade_action_or_symbol_invalid") + qty = _number(row["quantity"], True) + amount, commission, tax = (_number(row[field]) for field in ("total_amount", "commission", "tax")) + if qty != int(qty) or min(amount, commission, tax) < 0: + raise ValueError("trade_quantity_or_cost_invalid") + sign = 1 if row["action"] == "BUY" else -1 + quantities[row["symbol"]] = quantities.get(row["symbol"], 0) + sign * qty + cash -= sign * amount + commission + tax + expected_quantities = {symbol: qty for symbol, qty in quantities.items() if qty != 0} + actual_quantities = {row["symbol"]: row["quantity"] for row in close["positions"]} + if expected_quantities != actual_quantities: + issues.append("close_ledger_quantity_mismatch") + if not math.isclose(cash, close["cash"], rel_tol=1e-8, abs_tol=1e-6): + issues.append("close_ledger_cash_mismatch") + result["cutoff_ledger"] = {name: [{"id": row["id"], "source_hash": _hash(row)} for row in sorted(rows, key=lambda row: row["id"])] + for name, rows in (("trades", trades), ("flows", flows))} + except (TypeError, KeyError, ValueError): + issues.append("close_ledger_unverified") + return result, issues + + +def _summarize(cohort, observations, expected, holidays, issues): + protocol = cohort["protocol"] + observations = sorted(observations, key=lambda row: row["date"]) + dates = [row["date"] for row in observations] + start = _dt(cohort["started_at"]).astimezone(KST) + # Registration is an observation boundary, never a synthetic funded close. + first_possible = start.date().isoformat() + if start.time() >= time.fromisoformat(cohort["market_close"]): + first_possible = (start.date() + timedelta(days=1)).isoformat() + expected_dates = _sessions(first_possible, expected, holidays) + missing = sorted(set(expected_dates) - set(dates)) + ratio = len(missing) / len(expected_dates) if expected_dates else None + performance = {"start_date": dates[0] if dates else None, "end_date": dates[-1] if dates else None, + "twr_pct": None, "benchmark_return_pct": None, "excess_return_pct": None, + "drawdown_pct": None, "return_method": "stored_close_twr_start_period_flows"} + regimes = {name: {"observations": 0, "portfolio_return_pct": None, "benchmark_return_pct": None, + "excess_return_pct": None, "classification": "benchmark_adjacent_session_return_sign", + "return_scope": "compounded_selected_days_not_a_contiguous_period"} for name in ("up", "down", "flat")} + pairs = [] + for previous, current in zip(observations, observations[1:]): + a, b = previous.get("benchmark"), current.get("benchmark") + if (len(_sessions(previous["date"], current["date"], holidays)) == 2 and a and b + and a["source"] == b["source"] and not previous["issues"] and not current["issues"]): + pairs.append((current["twr_index"] / previous["twr_index"] - 1, b["close"] / a["close"] - 1)) + if len(observations) >= 2: + first, last = observations[0], observations[-1] + performance["twr_pct"] = _round((last["twr_index"] / first["twr_index"] - 1) * 100) + benchmarks = [row.get("benchmark") for row in observations] + valid = [row for row in benchmarks if row] + if benchmarks[0] and benchmarks[-1] and len({row["source"] for row in valid}) == 1: + result = (benchmarks[-1]["close"] / benchmarks[0]["close"] - 1) * 100 + performance["benchmark_return_pct"] = _round(result) + performance["excess_return_pct"] = _round(performance["twr_pct"] - result) + peak, drawdown = first["twr_index"], 0.0 + for row in observations: + peak = max(peak, row["twr_index"]) + drawdown = min(drawdown, row["twr_index"] / peak - 1) + performance["drawdown_pct"] = _round(drawdown * 100) + performance["drawdown_scope"] = "observed_closes_only" + for name, values in regimes.items(): + selected = [(a, b) for a, b in pairs if ("up" if b > 0 else "down" if b < 0 else "flat") == name] + values["observations"] = len(selected) + if selected: + a, b = [(math.prod(1 + pair[index] for pair in selected) - 1) * 100 for index in (0, 1)] + values.update(portfolio_return_pct=_round(a), benchmark_return_pct=_round(b), excess_return_pct=_round(a - b)) + intervals = [row["ledger"] for row in observations[1:]] + costs = {"commission": None, "tax": None, "explicit_cost": None, "modeled_slippage_embedded": None, + "gross_turnover_ratio": None, "trade_count": None, "net_external_flow": None, + "flow_count": None, "cost_basis": "already_in_nav_do_not_subtract_again", + "turnover_method": "gross_notional_over_mean_observed_close_nav_not_annualized"} + if intervals and all(row["trades"] is not None for row in intervals): + trades = [trade for row in intervals for trade in row["trades"]] + costs.update(commission=_round(sum(t["commission"] for t in trades)), tax=_round(sum(t["tax"] for t in trades)), + modeled_slippage_embedded=_round(sum(t["slippage"] for t in trades)), trade_count=len(trades)) + costs["explicit_cost"] = _round(costs["commission"] + costs["tax"]) + mean_nav = sum(row["total_value"] for row in observations) / len(observations) + costs["gross_turnover_ratio"] = _round(sum(t["total_amount"] for t in trades) / mean_nav) + if intervals and all(row["flows"] is not None for row in intervals): + flows = [flow for row in intervals for flow in row["flows"]] + costs.update(net_external_flow=_round(sum(flow["amount"] for flow in flows)), flow_count=len(flows)) + all_issues = set(issues) + for row in observations: + all_issues.update(row["issues"]) + if not row.get("benchmark"): + all_issues.add("benchmark_missing") + if len({row["benchmark"]["source"] for row in observations if row.get("benchmark")}) > 1: + all_issues.add("benchmark_source_changed") + if missing: + all_issues.add("close_sessions_missing") + benchmark_missing = [row["date"] for row in observations if not row.get("benchmark")] + paired_missing_ratio = (len(set(missing) | set(benchmark_missing)) / len(expected_dates)) if expected_dates else None + calendar_days = max(0, (date.fromisoformat(dates[-1]) - date.fromisoformat(dates[0])).days) if dates else 0 + integrity_issues = {issue for issue in all_issues if issue != "close_sessions_missing" + and not issue.startswith(("benchmark_missing", "benchmark_price_missing", "benchmark_exact_date_missing", "benchmark_unavailable:"))} + if any(issue.startswith(("observed_ledger_", "close_cash_flow_", "close_ledger_", "cash_flows_")) for issue in integrity_issues): + performance.update(twr_pct=None, excess_return_pct=None, drawdown_pct=None, status="unverified_ledger") + else: + performance["status"] = "observed" if len(observations) >= 2 else "insufficient_sample" + requirements = {"observations": len(pairs) >= protocol["minimum_observations"], + "calendar_days": calendar_days >= protocol["minimum_calendar_days"], + "up_regime": regimes["up"]["observations"] >= protocol["minimum_regime_observations"], + "down_regime": regimes["down"]["observations"] >= protocol["minimum_regime_observations"], + "coverage": paired_missing_ratio is not None and paired_missing_ratio <= protocol["maximum_missing_ratio"], + "integrity": not integrity_issues} + if not observations: + status = "awaiting_forward" if not expected_dates and not all_issues else "data_gaps" + elif missing and missing[-1] == expected: + status = "stale" + elif not requirements["integrity"] or not requirements["coverage"]: + status = "data_gaps" + elif all(requirements.values()): + status = "ready_for_review" + else: + status = "insufficient_sample" + return {"basket": cohort["basket"], "account_key": cohort["account_key"], "cohort_id": cohort["cohort_id"], + "started_at": cohort["started_at"], "market_close": cohort["market_close"], "active": True, "status": status, + "observations": len(observations), "return_observations": len(pairs), + "minimum_observations": protocol["minimum_observations"], "minimum_calendar_days": protocol["minimum_calendar_days"], + "minimum_regime_observations": protocol["minimum_regime_observations"], "calendar_days": calendar_days, + "requirements": requirements, "performance": performance, "regimes": regimes, "costs": costs, + "coverage": {"expected_sessions": len(expected_dates), "observed_sessions": len(observations), + "missing_sessions": missing, "missing_ratio": _round(ratio), "expected_through": expected, + "benchmark_missing_sessions": benchmark_missing, "paired_missing_ratio": _round(paired_missing_ratio), + "maximum_missing_ratio": protocol["maximum_missing_ratio"]}, + "exposure_policy": cohort["exposure_policy"], "benchmark": cohort["benchmark"], + "latest_invested_ratio": observations[-1]["invested_ratio"] if observations else None, + "fingerprint": cohort["fingerprint"], "registration_reason": cohort["registration_reason"], + "historical_closes_excluded": cohort["historical_closes_excluded"], + "historical_snapshots_excluded": cohort["historical_snapshots_excluded"], "issues": sorted(all_issues), + "research_only": True, "automatic_live_promotion": False, "profitability_pass": None} + + +def _collect(directory, events, db_path, protocol, collector, now): + settings = _yaml(ROOT / "config" / "settings.yaml") + risk = _yaml(ROOT / "config" / "risk_params.yaml") + baskets = _yaml(ROOT / "config" / "baskets.yaml").get("baskets", {}) + path = Path(db_path or os.environ.get("QUANT_DB_PATH") or settings.get("database", {}).get("sqlite_path", "data/quant_trader.db")) + path = (ROOT / path).resolve() if not path.is_absolute() else path.resolve() + database, database_issues = _read_database(path) + holidays = _calendar() + expected = _expected(now, holidays, settings.get("trading", {}).get("market_close", "15:30")) + registrations = [event["data"] for event in events if event["kind"] == "cohort_registered"] + closed = {event["data"]["cohort_id"] for event in events if event["kind"] == "cohort_ended"} + active = {row["basket"]: row for row in registrations if row["cohort_id"] not in closed} + selected = {name for name in protocol["baskets"] if baskets.get(name, {}).get("enabled") is True} + for name, cohort in active.copy().items(): + if name not in selected: + _append(directory, events, "cohort_ended", {"cohort_id": cohort["cohort_id"], "reason": "basket_disabled_or_protocol_removed"}, _utc_now()) + del active[name] + results = [] + supplied_collector = collector + for name in sorted(selected): + collector = supplied_collector # A basket's provider never leaks into another policy. + key = f"basket_rebalance:{name}" + config_hash, code_hash, files, exposure = _operating_material(baskets[name], settings, risk) + benchmark = protocol["baskets"][name]["benchmark"] + own_protocol = _cohort_protocol(protocol, name) + initial_capital = baskets[name].get("initial_capital") + if initial_capital is None: + initial_capital = risk.get("position_sizing", {}).get("initial_capital", 10000000) + initial_capital = _number(initial_capital, True) + fingerprint = {"config": config_hash, "code": code_hash, "protocol": _hash(own_protocol), + "source_database": _hash(str(path))} + rows = [row for row in database["portfolio_valuations"] or [] if row["account_key"] == key] + close_rows = [row for row in rows if row["kind"] == "close"] + by_id = {row["id"]: row for row in rows} + cohort = active.get(name) + if cohort is None or cohort["fingerprint"] != fingerprint: + reasons = [k + "_changed" for k in fingerprint if cohort and cohort["fingerprint"].get(k) != fingerprint[k]] + if cohort: + _append(directory, events, "cohort_ended", {"cohort_id": cohort["cohort_id"], "reason": reasons}, _utc_now()) + registered = _utc_now() + cohort = {"cohort_id": uuid.uuid4().hex, "basket": name, "account_key": key, "started_at": _stamp(registered), + "protocol": own_protocol, "fingerprint": fingerprint, "operating_code_files": files, + "exposure_policy": exposure, "benchmark": benchmark, "registration_reason": reasons or ["first_registration"], + "market_close": settings.get("trading", {}).get("market_close", "15:30"), "initial_capital": initial_capital, + "excluded_valuation_ids": sorted(row["id"] for row in close_rows), + "historical_closes_excluded": len(close_rows), + "historical_snapshots_excluded": database["historical_snapshot_counts"].get(key, 0)} + _append(directory, events, "cohort_registered", cohort, registered) + observations = _observations(events, cohort["cohort_id"]) + issues = list(database_issues) + for index, observation in enumerate(observations): + original = by_id.get(observation["valuation_id"]) + if original is None or _hash(original) != observation["source_hash"]: + issues.append("observed_ledger_revised_or_missing") + if observation["baseline_valuation_id"] is not None: + baseline = by_id.get(observation["baseline_valuation_id"]) + if baseline is None or _hash(baseline) != observation["baseline_source_hash"]: + issues.append("observed_ledger_revised_or_missing") + if original is not None and observation.get("return_method") == DIRECT_CLOSE_METHOD: + try: + _source_close(original, by_id, _utc_now(), cohort["market_close"], cohort.get("initial_capital")) + except (TypeError, KeyError, ValueError, OverflowError): + issues.append("observed_ledger_revised_or_missing") + previous = _dt(observations[index - 1]["measured_at"]) if index else _dt(observation["measured_at"]) + ledger, audit_issues = _ledger_interval(database, key, previous, _dt(observation["measured_at"]), observation) + if ledger != observation["ledger"] or audit_issues: + issues.append("observed_ledger_revised_or_missing") + if (observation.get("benchmark") is None + and len(_sessions(observation["date"], expected, holidays)) - 1 <= protocol["maximum_capture_delay_sessions"]): + if collector is None: + collector = _make_benchmark_collector(benchmark) + quote, error = _benchmark(collector, benchmark["symbol"], observation["date"], now, benchmark["provider"]) + if quote: + _append(directory, events, "benchmark_observed", {"cohort_id": cohort["cohort_id"], "date": observation["date"], + "valuation_source_hash": observation["source_hash"], "benchmark": quote}, _utc_now()) + observations = _observations(events, cohort["cohort_id"]) + seen = {row["date"] for row in observations} + excluded = set(cohort["excluded_valuation_ids"]) + for row in sorted(close_rows, key=lambda row: (str(row["date"]), row["id"])): + day = str(row["date"])[:10] + if row["id"] in excluded or day > expected or _dt(row["measured_at"]) <= _dt(cohort["started_at"]): + continue + if day in seen: + if any(ob["date"] == day and ob["valuation_id"] != row["id"] for ob in observations): + issues.append("duplicate_close_date") + continue + if observations and day <= observations[-1]["date"]: + issues.append("late_close_not_appended") + continue + if not _sessions(day, day, holidays): + issues.append("non_session_close") + continue + if len(_sessions(day, expected, holidays)) - 1 > protocol["maximum_capture_delay_sessions"]: + continue # Historical recovery may exist in the source, but is not forward evidence. + try: + close = _source_close(row, by_id, _utc_now(), cohort["market_close"], cohort.get("initial_capital")) + previous = _dt(observations[-1]["measured_at"]) if observations else _dt(close["measured_at"]) + close["ledger"], close["issues"] = _ledger_interval(database, key, previous, _dt(close["measured_at"]), close) + if collector is None: + collector = _make_benchmark_collector(benchmark) + close["benchmark"], error = _benchmark(collector, benchmark["symbol"], day, now, benchmark["provider"]) + if error: + close["issues"].append(error) + observed = _utc_now() + close["observed_at"] = _stamp(observed) + close["calendar_hash"] = _hash(sorted(holidays)) + _append(directory, events, "close_observed", {"cohort_id": cohort["cohort_id"], "observation": close}, observed) + observations.append(close) + seen.add(day) + except (ValueError, TypeError, KeyError, OverflowError) as exc: + issues.append(f"invalid_close:{day}:{type(exc).__name__}") + summary = _summarize(cohort, observations, expected, holidays, issues) + prior = next((event for event in reversed(events) if event["kind"] == "assessment" and event["data"]["cohort_id"] == cohort["cohort_id"]), None) + daily = {**summary, "assessment_date": now.astimezone(KST).date().isoformat()} + if prior is None or prior["data"] != daily: + _append(directory, events, "assessment", daily, _utc_now()) + results.append(cohort["cohort_id"]) + # Also records successful no-change runs, separately from market observations. + _append(directory, events, "collection_completed", {"active_cohort_ids": results, "expected_through": expected}, _utc_now()) + + +def collect_paper_validation(db_path=None, *, config_path=None, output_dir=None, collector=None): + """Stamp the real server clock; read ledger via SQLite mode=ro; append evidence. + + There is intentionally no --now/as_of/backdate input on the collection path. + Missing database files/tables are reported without initializing any schema. + """ + directory = _directory(output_dir) + if directory in {ROOT, ROOT / "data", ROOT / "config"}: + raise ValueError("use a dedicated paper-validation output directory") + directory.mkdir(parents=True, exist_ok=True) + with _exclusive_lock(directory): + events = _load_events(directory) + now = _utc_now() + if events and now < _dt(events[-1]["observed_at"]): + raise ValueError("paper_validation_clock_rollback") + try: + _collect(directory, events, db_path, _protocol(config_path), collector, now) + except (OSError, ValueError, TypeError, KeyError, sqlite3.Error) as exc: + # Only error type: source paths, configuration values and secrets stay private. + _append(directory, events, "collection_failed", {"error": type(exc).__name__}, _utc_now()) + return read_paper_validation(directory) + + +def read_paper_validation(output_dir=None): + """Read saved files and default-path freshness. No DB/network writes or enrollment. + + The default operational journal is checked against current configuration and + code. An explicit artifact directory retains its own historical policy context. + """ + result = {"schema_version": 1, "research_only": True, "automatic_live_promotion": False, + "status": "not_started", "updated_at": None, "cohorts": [], "issues": [], "limitations": LIMITATIONS} + try: + events = _load_events(_directory(output_dir)) + if not events: + return result + if _dt(events[-1]["observed_at"]) > _utc_now(): + raise ValueError("paper_validation_clock_rollback") + result["updated_at"] = events[-1]["observed_at"] + ended = {event["data"]["cohort_id"]: event for event in events if event["kind"] == "cohort_ended"} + summaries = {} + for event in events: + if event["kind"] == "assessment": + summaries[event["data"]["cohort_id"]] = {**event["data"], "assessed_at": event["observed_at"]} + for key, row in summaries.items(): + row["active"] = key not in ended + if not row["active"]: + row["ended_at"] = ended[key]["observed_at"] + row["end_reason"] = ended[key]["data"]["reason"] + active = [row for row in summaries.values() if row["active"]] + if output_dir is None and active: + try: + protocol = _protocol() + settings = _yaml(ROOT / "config" / "settings.yaml") + risk = _yaml(ROOT / "config" / "risk_params.yaml") + baskets = _yaml(ROOT / "config" / "baskets.yaml").get("baskets", {}) + for row in active: + name = row["basket"] + changed = name not in protocol["baskets"] or baskets.get(name, {}).get("enabled") is not True + if not changed: + config_hash, code_hash, _, _ = _operating_material(baskets[name], settings, risk) + own_protocol = _cohort_protocol(protocol, name) + current = {"config": config_hash, "code": code_hash, "protocol": _hash(own_protocol)} + changed = any(row["fingerprint"].get(key) != value for key, value in current.items()) + if changed: + row["status"] = "stale" + row["issues"] = sorted(set(row["issues"]) | {"policy_changed_pending_collection"}) + result["issues"].append("policy_changed_pending_collection:" + name) + except (OSError, ValueError, TypeError, KeyError): + result["issues"].append("current_policy_unavailable") + for row in active: + row["status"] = "stale" + row["issues"] = sorted(set(row["issues"]) | {"current_policy_unavailable"}) + last_run = next((event for event in reversed(events) if event["kind"] in {"collection_completed", "collection_failed"}), None) + if last_run and last_run["kind"] == "collection_failed": + result["issues"].append("latest_collection_failed:" + last_run["data"]["error"]) + try: + holidays = _calendar() + for row in active: + expected = _expected(_utc_now(), holidays, row.get("market_close", "15:30")) + if row["coverage"]["expected_through"] < expected: + row["status"] = "stale" + row["issues"] = sorted(set(row["issues"]) | {"assessment_stale"}) + result["issues"].append("assessment_stale:" + row["basket"]) + except (OSError, ValueError, TypeError, KeyError): + result["issues"].append("trading_calendar_unavailable") + for row in active: + row["status"] = "stale" + row["issues"] = sorted(set(row["issues"]) | {"trading_calendar_unavailable"}) + if last_run is None: + result["issues"].append("collection_stale") + for row in active: + row["status"] = "stale" + row["issues"] = sorted(set(row["issues"]) | {"collection_stale"}) + result["cohorts"] = sorted(summaries.values(), key=lambda row: (not row["active"], row["basket"], row["started_at"]), reverse=False) + result["status"] = ("unavailable" if result["issues"] else "data_gaps" if any(row["status"] in {"data_gaps", "stale"} for row in active) + else "observing" if active else "inactive") + return result + except (OSError, ValueError, TypeError, KeyError) as exc: + result.update(status="corrupt", issues=["journal_read_error:" + type(exc).__name__]) + return result diff --git a/core/portfolio_manager.py b/core/portfolio_manager.py index 782c69fe..250aec26 100644 --- a/core/portfolio_manager.py +++ b/core/portfolio_manager.py @@ -334,6 +334,7 @@ def get_portfolio_summary( # 이후 MDD가 가짜로 커진다(주문 가드·자본 조회가 가격 없이 이 함수를 부른다). market_priced = bool(state.get("market_priced", True)) or broker_balance_ok is True + return_context = {"method": "initial_capital", "initial_capital": self.initial_capital} if not account_has_flows: # 무입금 계정: 기존 산식 그대로 (하위 호환 — 결과 불변) total_return = ((total_value / self.initial_capital) - 1) * 100 if self.initial_capital > 0 else 0 @@ -358,6 +359,7 @@ def get_portfolio_summary( # 첫 측정: 초기자본이 첫 유입, 그간의 입금 전액이 구간 유입 r = twr_period_return(self.initial_capital, total_value, deposits_total) total_return = r * 100 + return_context.update(method="initial_capital_with_flows", flow_since=deposits_total) else: # 경계는 실제 측정 시각(created_at) — date(자정 귀속)를 쓰면 스냅샷 # 이전의 같은 날 입금이 이중 산입된다. @@ -374,6 +376,11 @@ def get_portfolio_summary( ) r = twr_period_return(prev_total, total_value, flow_since) total_return = ((1 + prev_return / 100) * (1 + r) - 1) * 100 + return_context = { + "method": "linked_measurements", "boundary": str(boundary), + "previous_total_value": prev_total, "previous_cumulative_return": prev_return, + "flow_since": flow_since, + } # MDD도 TWR 지수 기준 — 원화 피크로 재면 입금이 낙폭을 가짜 회복시킨다. index_now = 1 + total_return / 100 @@ -414,6 +421,8 @@ def get_portfolio_summary( "invested": round(invested, 0), "current_value": round(current_value, 0), "total_return": round(total_return, 2), + "total_return_precise": total_return, + "return_context": return_context, "mdd": round(mdd, 2), "position_count": len(positions), "realized_pnl": round(realized_pnl, 0), @@ -426,7 +435,7 @@ def get_portfolio_summary( "broker_balance_error": broker_balance_error, } - def save_daily_snapshot(self, current_prices: dict = None, snapshot_date=None) -> bool: + def save_daily_snapshot(self, current_prices: dict = None, snapshot_date=None, *, valuation_context=None) -> bool: """일일 포트폴리오 스냅샷 저장. 성공 여부를 반환한다. snapshot_date: 귀속 날짜 지정(미지정 시 오늘). 비거래일 보충 실행에서 @@ -454,19 +463,32 @@ def save_daily_snapshot(self, current_prices: dict = None, snapshot_date=None) - daily_return = self._compute_daily_return( summary["total_value"], snapshot_date, ) + cumulative = summary.get("total_return_precise", summary["total_return"]) + # Link the same measured TWR index used by the cumulative return. A day's + # morning/after-deposit boundaries cannot be replaced by a start-day flow. + if has_cash_flows(self.account_key, mode=self._ledger_mode): + from datetime import datetime + prev_day = get_snapshot_before(snapshot_date or datetime.now(), account_key=self.account_key, mode=self._ledger_mode) + if prev_day and 1 + float(prev_day["cumulative_return"]) / 100 > 0: + daily_return = ((1 + cumulative / 100) / (1 + float(prev_day["cumulative_return"]) / 100) - 1) * 100 + from datetime import datetime + context = dict(valuation_context or {}) + context["return_context"] = summary.get("return_context") or {} ok = save_portfolio_snapshot( total_value=summary["total_value"], cash=summary["cash"], invested=summary["invested"], daily_return=daily_return, - cumulative_return=summary["total_return"], + cumulative_return=cumulative, mdd=summary["mdd"], position_count=summary["position_count"], account_key=self.account_key, peak_value=self._peak_value, snapshot_date=snapshot_date, mode=self._ledger_mode, + valuation_context=context, + measured_at=(datetime.fromisoformat(context["measured_at"]) if context.get("measured_at") else None), ) if ok: diff --git a/core/promotion_engine.py b/core/promotion_engine.py index fc5104f1..f8b004e2 100644 --- a/core/promotion_engine.py +++ b/core/promotion_engine.py @@ -55,6 +55,9 @@ class StrategyMetrics: canonical_benchmark_required: bool = False canonical_data_integrity_ok: Optional[bool] = None canonical_data_integrity_issues: Optional[list[str]] = None + research_admission_required: bool = False + research_admission_status: Optional[str] = None + research_admission_issues: Optional[list[str]] = None ev_per_trade: Optional[float] = None # 원/trade cost_adjusted_cagr: Optional[float] = None # % turnover_per_year: Optional[float] = None # %/year @@ -119,6 +122,8 @@ def _invalid_metrics(m: StrategyMetrics, fields: tuple[str, ...]) -> list[str]: def _check_paper_only(m: StrategyMetrics) -> tuple[bool, str]: """paper_only 조건: 절대수익>0, PF≥1.0, WF positive≥50%.""" + if m.research_admission_required and m.research_admission_status != "passed": + return False, "research admission required: " + "; ".join(m.research_admission_issues or ["unavailable"]) bad = _invalid_metrics(m, ("total_return", "profit_factor", "wf_positive_rate")) if bad: return False, "paper_only 미달: 지표 값이 유효하지 않음(" + ", ".join(bad) + ")" @@ -971,6 +976,8 @@ def load_metrics_from_artifact( excess_return = benchmark_raw.get("strategy_excess_return_pct", {}) excess_sharpe = benchmark_raw.get("strategy_excess_sharpe", {}) for name, m in metrics_raw.items(): + from core.research_admission import canonical_research_review + research_review = canonical_research_review(metadata, name) wf = wf_raw.get(name, {}) paper_metrics = paper_evidence_metrics_from_package( load_paper_evidence_package(name, evidence_dir, log_warnings=False), @@ -994,6 +1001,9 @@ def load_metrics_from_artifact( benchmark_excess_return=m.get("benchmark_excess_return", excess_return.get(name)), benchmark_excess_sharpe=m.get("benchmark_excess_sharpe", excess_sharpe.get(name)), canonical_benchmark_required=True, + research_admission_required=research_review["required"], + research_admission_status=research_review["status"], + research_admission_issues=research_review["issues"], ev_per_trade=m.get("ev_per_trade"), cost_adjusted_cagr=m.get("cost_adjusted_cagr"), turnover_per_year=m.get("turnover_per_year"), diff --git a/core/research_admission.py b/core/research_admission.py new file mode 100644 index 00000000..441dc069 --- /dev/null +++ b/core/research_admission.py @@ -0,0 +1,168 @@ +"""Research evidence required for newly generated canonical promotion bundles. + +Legacy bundles remain explicitly unverified; existing runtime authorizations and +basket policies are not retroactively changed by this research policy. +""" +from __future__ import annotations + +import hashlib +import json +import math +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +RESEARCH_ADMISSION_POLICY = "required_v1" + + +def research_code_fingerprint(root: Path | None = None) -> str: + """Bind research to file contents, including uncommitted source changes.""" + root = root or Path(__file__).resolve().parents[1] + paths = [path for directory in ("core", "strategies", "backtest", "config") + for path in (root / directory).rglob("*.py")] + paths.extend(root / name for name in ("main.py", "tools/research_candidate_sweep.py", "tools/evaluate_and_promote.py")) + material = {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(set(paths)) if path.is_file()} + return payload_hash(material) + + +def payload_hash(payload: Any) -> str: + return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=True, + default=str).encode("utf-8")).hexdigest() + + +def proof_from_sweep(bundle: dict) -> dict: + """Retain auditable inputs, not a trusted copy of a report's PASS label.""" + holdout = bundle.get("oos_holdout") or {} + selected = holdout.get("selected_candidate_id") + candidates = [c for c in bundle.get("candidates", []) if c.get("candidate_id") == selected] + proof = {key: bundle.get(key) for key in ( + "artifact_type", "schema_version", "run_id", "generated_at", "commit_hash", + "config_yaml_hash", "config_resolved_hash", "eval_start", "eval_end", "universe", + "universe_selection", "oos_holdout", "candidate_ids_evaluated", "initial_capital", + "research_code_fingerprint", + )} + proof["candidate"] = candidates[0] if len(candidates) == 1 else None + proof["source_payload_hash"] = payload_hash(bundle) + proof["proof_hash"] = payload_hash(proof) + return proof + + +def canonical_research_review(metadata: dict, strategy_name: str) -> dict: + """Validate a candidate's lineage and replay evidence before new paper entry.""" + policy = metadata.get("research_admission_policy") + if policy is None: + return {"required": False, "status": "legacy_unverified", "issues": ["legacy_research_admission_missing"]} + if policy != RESEARCH_ADMISSION_POLICY: + return {"required": True, "status": "unavailable", "issues": ["unknown_research_admission_policy"]} + proofs = metadata.get("research_admission_proofs") or {} + proof = proofs.get(strategy_name) if isinstance(proofs, dict) else None + if not isinstance(proof, dict): + return {"required": True, "status": "unavailable", "issues": ["research_admission_proof_missing"]} + if (any(proof.get(key) is not None and not isinstance(proof[key], dict) + for key in ("universe_selection", "oos_holdout", "candidate")) + or any(not isinstance(proof.get(key, []), list) + or not all(isinstance(x, str) for x in proof.get(key, [])) + for key in ("universe", "candidate_ids_evaluated"))): + return {"required": True, "status": "unavailable", "issues": ["research_proof_structure_invalid"]} + issues = [] + material = {key: value for key, value in proof.items() if key != "proof_hash"} + if proof.get("proof_hash") != payload_hash(material): + issues.append("research_proof_hash_mismatch") + if proof.get("artifact_type") != "research_candidate_sweep_bundle" or proof.get("schema_version") != 1: + issues.append("research_proof_schema_invalid") + for key in ("commit_hash", "config_yaml_hash", "config_resolved_hash", "eval_start", "eval_end"): + if not proof.get(key) or proof.get(key) != metadata.get(key): + issues.append(f"research_{key}_mismatch") + if proof.get("commit_hash") in ("unknown", "dirty"): + issues.append("research_commit_identity_unavailable") + fingerprint = proof.get("research_code_fingerprint") + if (not isinstance(fingerprint, str) or len(fingerprint) != 64 + or fingerprint != metadata.get("research_code_fingerprint")): + issues.append("research_code_fingerprint_mismatch") + capital, canonical_capital = proof.get("initial_capital"), metadata.get("initial_capital") + if (any(not isinstance(x, (int, float)) or isinstance(x, bool) or not math.isfinite(x) or x <= 0 + for x in (capital, canonical_capital)) or capital != canonical_capital): + issues.append("research_initial_capital_mismatch") + if not proof.get("universe") or sorted(proof["universe"]) != sorted(metadata.get("universe") or []): + issues.append("research_universe_mismatch") + if (proof.get("universe_selection") or {}).get("survivorship_controlled") is not True: + issues.append("research_point_in_time_universe_unverified") + holdout = proof.get("oos_holdout") or {} + candidate = proof.get("candidate") or {} + if holdout.get("selected_candidate_id") != strategy_name or candidate.get("candidate_id") != strategy_name: + issues.append("research_train_selected_candidate_mismatch") + specs = [s for s in (metadata.get("strategy_specs") or []) + if isinstance(s, dict) and s.get("candidate_id") == strategy_name] + if len(specs) != 1 or not isinstance(candidate.get("params"), dict): + issues.append("research_candidate_spec_missing") + elif (candidate.get("params") != specs[0].get("params") + or candidate.get("strategy") != specs[0].get("base_strategy")): + issues.append("research_candidate_parameters_mismatch") + try: + train_start, train_end = [date.fromisoformat(x) for x in holdout["selection_window"]] + test_start, test_end = [date.fromisoformat(x) for x in holdout["holdout_window"]] + if not (train_start <= train_end < test_start <= test_end + and str(train_start) == metadata.get("eval_start") + and str(test_end) == metadata.get("eval_end")): + issues.append("research_windows_invalid") + except (KeyError, TypeError, ValueError): + issues.append("research_windows_missing") + train_start = train_end = None + if (holdout.get("status") != "ok" or holdout.get("selected_on") != "train_rank_score_only" + or holdout.get("test_benchmark_coverage_complete") is not True): + issues.append("research_holdout_unverified") + for key in ("test_sharpe", "test_benchmark_excess_return", "test_benchmark_excess_sharpe"): + value = holdout.get(key) + if not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: + issues.append(f"research_{key}_failed_or_missing") + dsr = holdout.get("selected_train_deflated_sharpe") or {} + if not isinstance(dsr, dict): + dsr = {} + value = dsr.get("dsr") + if (dsr.get("status") != "observed" or dsr.get("passes") is not True + or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0.95): + issues.append("research_train_dsr_failed_or_unavailable") + stats = holdout.get("selected_train_return_statistics") or {} + multiple = holdout.get("train_multiple_testing") or {} + if not isinstance(stats, dict): + stats = {} + if not isinstance(multiple, dict): + multiple = {} + try: + from backtest.statistical_validation import deflated_sharpe_ratio + first = date.fromisoformat(stats["first_return_date"]) + last = date.fromisoformat(stats["last_return_date"]) + if (train_start is None or train_end is None or not train_start <= first <= last <= train_end + or stats.get("periods_per_year") != 252 or stats.get("kurtosis_convention") != "pearson" + or stats.get("source") != "equity_curve.pct_change"): + raise ValueError("return observations are not bound to training window") + span = (last - first).days + if span > 20000: + raise ValueError("implausible daily training window") + max_observations = sum((first + timedelta(days=offset)).weekday() < 5 for offset in range(span + 1)) + if int(stats["n_obs"]) > max_observations: + raise ValueError("too many daily observations for training window") + if round(float(stats["sharpe_annual"]), 2) != holdout.get("train_sharpe"): + raise ValueError("training Sharpe disagrees with observed moments") + raw_variance = float(multiple["sharpe_variance_across_trials_raw"]) + if (stats.get("status") != "observed" or int(stats["n_obs"]) < 4 + or int(stats["n_obs"]) != dsr.get("n_obs") or raw_variance < 0 + or not all(math.isfinite(float(x)) for x in ( + stats["sharpe_annual"], stats["skew"], stats["kurtosis"], raw_variance))): + raise ValueError("invalid observed moments") + recomputed = deflated_sharpe_ratio( + float(stats["sharpe_annual"]), int(stats["n_obs"]), int(dsr["n_trials"]), raw_variance, + skew=float(stats["skew"]), kurtosis=float(stats["kurtosis"]), + ) + if not recomputed["passes"] or recomputed["dsr"] != dsr.get("dsr"): + issues.append("research_train_dsr_recalculation_failed") + except (KeyError, TypeError, ValueError, OverflowError): + issues.append("research_observed_return_statistics_missing_or_invalid") + trial_ids = proof.get("candidate_ids_evaluated") or [] + if (strategy_name not in trial_ids or dsr.get("n_trials") != len(trial_ids) + or holdout.get("trials") != len(trial_ids) or multiple.get("n_trials") != len(trial_ids)): + issues.append("research_trial_count_mismatch") + return {"required": True, "status": "passed" if not issues else "failed", "issues": issues, + "source_run_id": proof.get("run_id"), "source_payload_hash": proof.get("source_payload_hash"), + "limits": ["holdout_reuse_not_tracked", "current_sweep_trials_only"]} diff --git a/core/research_evidence.py b/core/research_evidence.py new file mode 100644 index 00000000..dcc1019f --- /dev/null +++ b/core/research_evidence.py @@ -0,0 +1,327 @@ +"""Curator-authored research hypotheses, separate from trading decisions. + +Imports append immutable revisions and stamp observation time on this machine. +Published time is a curator-supplied claim, not a verified news-feed timestamp. +Readers never fetch URLs, create directories, or modify a trading database. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import uuid +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +_ROOT = Path(__file__).resolve().parent.parent +_MAX_BYTES = 2_000_000 +_TYPES = {"industry_thesis", "macro_thesis", "company_thesis", "scheduled_event", "methodology"} +_ENTRY_FIELDS = { + "id", "title", "theme", "thesis", "invalidation_condition", "horizon", + "review_at", "expires_at", "event_at", "evidence_type", "sources", "symbols", + "counterevidence", +} + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _time(value: Any, field: str) -> datetime: + try: + result = value if isinstance(value, datetime) else datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if result.tzinfo is None or result.utcoffset() is None: + raise ValueError("timezone is required") + return result.astimezone(timezone.utc) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field}: use an ISO timestamp with a timezone") from exc + + +def _iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z") + + +def _text(value: Any, field: str, maximum: int = 4000) -> str: + if not isinstance(value, str) or not value.strip() or len(value) > maximum: + raise ValueError(f"{field}: non-empty text up to {maximum} characters is required") + return value.strip() + + +def _url(value: Any) -> str: + result = _text(value, "source URL", 2000) + try: + parsed = urlsplit(result) + valid = parsed.scheme in {"https", "http"} and parsed.hostname and not parsed.username and not parsed.password + valid = valid and not any(character.isspace() or ord(character) < 32 for character in result) + if not valid: + raise ValueError + except ValueError as exc: + raise ValueError("source URL: an http(s) URL without credentials is required") from exc + return result + + +def _unknown_fields(value: dict, allowed: set[str], field: str) -> None: + extra = set(value) - allowed + if extra: + raise ValueError(f"{field}: unsupported fields {sorted(extra)}") + + +def _entry(value: Any) -> dict: + if not isinstance(value, dict): + raise ValueError("entry must be an object") + _unknown_fields(value, _ENTRY_FIELDS, "entry") + result = {key: _text(value.get(key), key) for key in ( + "id", "title", "theme", "thesis", "invalidation_condition", "horizon", "evidence_type", + )} + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,79}", result["id"]): + raise ValueError("id: use 1-80 lowercase letters, digits, hyphens or underscores") + if result["evidence_type"] not in _TYPES: + raise ValueError(f"evidence_type must be one of {sorted(_TYPES)}") + for key in ("review_at", "expires_at", "event_at"): + result[key] = _iso(_time(value[key], key)) if value.get(key) else None + if result["review_at"] is None or result["expires_at"] is None: + raise ValueError("review_at and expires_at are required") + if _time(result["review_at"], "review_at") > _time(result["expires_at"], "expires_at"): + raise ValueError("review_at must not be after expires_at") + if result["evidence_type"] == "scheduled_event" and not result["event_at"]: + raise ValueError("scheduled_event requires event_at") + sources = value.get("sources") + if not isinstance(sources, list) or not 1 <= len(sources) <= 20: + raise ValueError("sources must contain 1-20 source objects") + result["sources"] = [] + for source in sources: + if not isinstance(source, dict): + raise ValueError("source must be an object") + _unknown_fields(source, {"url", "title", "published_at", "published_date", "publication_note"}, "source") + published_date = source.get("published_date") + if published_date is not None: + try: + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", published_date): + raise ValueError + datetime.strptime(published_date, "%Y-%m-%d") + except (TypeError, ValueError) as exc: + raise ValueError("published_date must be a valid YYYY-MM-DD date") from exc + result["sources"].append({ + "url": _url(source.get("url")), "title": _text(source.get("title"), "source title", 500), + "published_at": _iso(_time(source["published_at"], "published_at")) if source.get("published_at") else None, + "published_date": published_date, + "publication_note": _text(source["publication_note"], "publication_note", 500) if source.get("publication_note") else None, + }) + symbols = value.get("symbols", []) + if not isinstance(symbols, list) or len(symbols) > 100: + raise ValueError("symbols must be a list of at most 100 associations") + result["symbols"] = [] + seen_symbols = set() + urls = {source["url"] for source in result["sources"]} + for association in symbols: + if not isinstance(association, dict): + raise ValueError("symbol association must be an object") + _unknown_fields(association, {"symbol", "rationale", "source_url"}, "association") + symbol = _text(association.get("symbol"), "symbol", 20) + if not re.fullmatch(r"[A-Z0-9][A-Z0-9.:-]{0,19}", symbol) or symbol in seen_symbols: + raise ValueError("symbol must be unique and contain only uppercase letters, digits, . : -") + source_url = _url(association.get("source_url")) + if source_url not in urls: + raise ValueError("each symbol association must cite one of this entry's source URLs") + seen_symbols.add(symbol) + result["symbols"].append({"symbol": symbol, "rationale": _text(association.get("rationale"), "rationale"), "source_url": source_url}) + counterevidence = value.get("counterevidence", []) + if not isinstance(counterevidence, list) or len(counterevidence) > 30: + raise ValueError("counterevidence must be a list of at most 30 statements") + result["counterevidence"] = [_text(item, "counterevidence") for item in counterevidence] + return result + + +def validate_research_input(payload: Any) -> dict: + """Validate curator input; observation timestamps/revisions cannot be supplied.""" + if not isinstance(payload, dict): + raise ValueError("research input must be an object") + _unknown_fields(payload, {"schema_version", "curator", "entries"}, "research input") + if type(payload.get("schema_version")) is not int or payload["schema_version"] != 1: + raise ValueError("schema_version must be 1") + curator = _text(payload.get("curator"), "curator", 200) + entries = payload.get("entries") + if not isinstance(entries, list) or not 1 <= len(entries) <= 100: + raise ValueError("entries must contain 1-100 hypotheses") + normalized = [_entry(entry) for entry in entries] + if len({entry["id"] for entry in normalized}) != len(normalized): + raise ValueError("entry IDs must be unique within one import") + return {"schema_version": 1, "curator": curator, "entries": normalized} + + +def evidence_journal_dir(journal_dir: str | os.PathLike | None = None) -> Path: + return Path(journal_dir or os.environ.get("QUANT_RESEARCH_EVIDENCE_DIR") or _ROOT / "data" / "research_evidence").resolve() + + +def _canonical(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +def _read_json(path: Path) -> Any: + if path.stat().st_size > _MAX_BYTES: + raise ValueError("JSON file exceeds 2 MB") + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _history(directory: Path) -> tuple[list[dict], list[str]]: + records, issues = [], [] + if not directory.exists(): + return records, issues + if not directory.is_dir(): + return records, ["journal path is not a directory"] + for path in sorted(directory.glob("*.json")): + try: + if path.is_symlink() or path.resolve().parent != directory: + raise ValueError("journal symlinks are not supported") + record = _read_json(path) + digest = record.pop("sha256") + if hashlib.sha256(_canonical(record)).hexdigest() != digest: + raise ValueError("snapshot checksum mismatch") + _unknown_fields(record, {"schema_version", "observed_at", "curator", "entries"}, "snapshot") + observed_at = _iso(_time(record["observed_at"], "observed_at")) + raw_entries = record["entries"] + versions = [entry["version"] for entry in raw_entries] + if any(type(version) is not int or version < 1 for version in versions): + raise ValueError("invalid revision number") + validated = validate_research_input({ + "schema_version": record["schema_version"], "curator": record["curator"], + "entries": [{key: value for key, value in entry.items() if key != "version"} for entry in raw_entries], + }) + for entry, version in zip(validated["entries"], versions): + records.append({**entry, "version": version, "observed_at": observed_at, "curator": validated["curator"]}) + except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc: + issues.append(f"{path.name}: {exc}") + records.sort(key=lambda record: (record["observed_at"], record["version"], record["id"])) + latest_versions: dict[str, int] = {} + valid_records = [] + for record in records: + expected = latest_versions.get(record["id"], 0) + 1 + if record["version"] != expected: + issues.append(f"{record['id']}: revision sequence is incomplete or duplicated") + continue + latest_versions[record["id"]] = record["version"] + valid_records.append(record) + return valid_records, issues + + +def import_research_evidence(source_path: str | os.PathLike, journal_dir: str | os.PathLike | None = None) -> dict: + """Append one versioned snapshot. Never overwrite input or existing revisions. + + The exclusive import lock prevents duplicate revisions; a leftover lock is an + explicit error, not silently stolen. JSON is read from disk, never fetched. + """ + source = Path(source_path).resolve(strict=True) + directory = evidence_journal_dir(journal_dir) + if source.is_relative_to(directory): + raise ValueError("curated source must be outside the journal directory") + payload = validate_research_input(_read_json(source)) + directory.mkdir(parents=True, exist_ok=True) + lock_path = directory / ".import.lock" + try: + lock = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as exc: + raise ValueError("another import is active or .import.lock needs inspection") from exc + temporary = None + try: + os.close(lock) + history, issues = _history(directory) + if issues: + raise ValueError("journal integrity error: " + "; ".join(issues)) + observed = _utc_now() + if history and observed <= max(_time(entry["observed_at"], "observed_at") for entry in history): + raise ValueError("server clock has not advanced beyond the last observation") + for entry in payload["entries"]: + for source_item in entry["sources"]: + if source_item["published_at"] and _time(source_item["published_at"], "published_at") > observed: + raise ValueError("published_at cannot be after the observation; use event_at for a future scheduled event") + versions = {entry["id"]: entry["version"] for entry in history} + entries = [{**entry, "version": versions.get(entry["id"], 0) + 1} for entry in payload["entries"]] + record = {"schema_version": 1, "observed_at": _iso(observed), "curator": payload["curator"], "entries": entries} + digest = hashlib.sha256(_canonical(record)).hexdigest() + encoded = json.dumps({**record, "sha256": digest}, ensure_ascii=False, indent=2, allow_nan=False).encode("utf-8") + if len(encoded) > _MAX_BYTES: + raise ValueError("normalized snapshot exceeds 2 MB") + token = uuid.uuid4().hex + target = directory / f"{observed.strftime('%Y%m%dT%H%M%S%fZ')}_{token}.json" + temporary = directory / f".pending-{token}" + with temporary.open("xb") as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + # Hard-link publication is atomic and fails if the target already exists. + os.link(temporary, target) + return {"path": str(target), "observed_at": record["observed_at"], "imported": len(entries), "versions": {entry["id"]: entry["version"] for entry in entries}} + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + lock_path.unlink(missing_ok=True) + + +def read_evidence_journal( + journal_dir: str | os.PathLike | None = None, *, as_of: str | datetime | None = None, + symbols: list[str] | None = None, limit: int = 50, +) -> dict: + """Latest observed revision per hypothesis as of an aware timestamp. + + Future-dated publications never become usable evidence early. Unknown source + dates are explicitly unverified, including examples supplied with the app. + CURRENT means review is not overdue, not that a thesis is proven or tradable. + """ + if type(limit) is not int or not 1 <= limit <= 500: + raise ValueError("limit must be an integer from 1 to 500") + point = _time(as_of, "as_of") if as_of is not None else _utc_now() + history, issues = _history(evidence_journal_dir(journal_dir)) + latest = {} + for record in history: + if _time(record["observed_at"], "observed_at") <= point: + latest[record["id"]] = record + entries = [] + for record in latest.values(): + if symbols is not None and not set(symbols).intersection(item["symbol"] for item in record["symbols"]): + continue + notes = [] + sources = [] + for source in record["sources"]: + published = _time(source["published_at"], "published_at") if source["published_at"] else None + # A future publication claimed in an old/tampered record cannot turn + # into observed evidence merely because the wall clock has advanced. + status = "UNKNOWN_PUBLICATION" if published is None else "NOT_YET_PUBLISHED" if published > point else "PUBLICATION_AFTER_OBSERVATION" if published > _time(record["observed_at"], "observed_at") else "AVAILABLE" + if status != "AVAILABLE": + notes.append(status) + sources.append({**source, "availability": status}) + if not record["counterevidence"]: + notes.append("COUNTEREVIDENCE_NOT_RECORDED") + if _time(record["expires_at"], "expires_at") <= point: + notes.append("EXPIRED") + elif _time(record["review_at"], "review_at") <= point: + notes.append("REVIEW_DUE") + if "EXPIRED" in notes or "REVIEW_DUE" in notes: + status = "STALE" + elif any(source["availability"] != "AVAILABLE" for source in sources): + status = "UNVERIFIED" + elif record["event_at"] and _time(record["event_at"], "event_at") > point: + status = "UPCOMING" + else: + status = "CURRENT" + event_status = None + if record["event_at"]: + event_status = "UPCOMING" if _time(record["event_at"], "event_at") > point else "PASSED" + entries.append({**record, "sources": sources, "status": status, "event_status": event_status, "issues": list(dict.fromkeys(notes)), "trade_eligible": False}) + entries.sort(key=lambda entry: (entry["observed_at"], entry["id"]), reverse=True) + counts = dict(Counter(entry["status"] for entry in entries)) + attention = bool(issues or counts.get("STALE") or counts.get("UNVERIFIED")) + return { + "schema_version": 1, "source": "curated_research", "automatic_collection": False, + "publication_verification": "curator_supplied", "as_of": _iso(point), + "status": "ATTENTION" if attention else "OK" if entries else "EMPTY", + "entries": entries[:limit], "total": len(entries), "counts": counts, + "truncated": len(entries) > limit, "issues": issues, + } + + +get_research_evidence = read_evidence_journal diff --git a/core/research_operations.py b/core/research_operations.py new file mode 100644 index 00000000..20a2d6af --- /dev/null +++ b/core/research_operations.py @@ -0,0 +1,245 @@ +"""Durable daily collection, isolated from the order loop and operating DB writes.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import hashlib +import json +import os +import shutil +from pathlib import Path +import subprocess +import sys +import threading +import uuid +from zoneinfo import ZoneInfo + +import yaml +from loguru import logger + +ROOT = Path(__file__).resolve().parents[1] +KST = ZoneInfo("Asia/Seoul") +COMMANDS = { + "market": ("market_research.py",), + "intelligence": ("market_intelligence.py",), + "paper_validation": ("paper_validation.py", "collect"), +} + + +def operations_directory(): + return Path(os.environ.get("QUANT_RESEARCH_OPERATIONS_DIR", str(ROOT / "data" / "research_operations"))) + + +def _config(): + data = yaml.safe_load((ROOT / "config" / "research_operations.yaml").read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("invalid research operations configuration") + return data + + +def _now(): + return datetime.now(timezone.utc) + + +def _collection_period(now): + """Allow an independent settled-close run, preserving T-1 price evidence.""" + from config.config_loader import load_yaml + from core.market_research import _calendar, _sessions, expected_session + + local = now.astimezone(KST) + day = local.date().isoformat() + expected = expected_session(local) # Also validates the calendar year. + is_session = bool(len(_sessions(day, day, _calendar()))) + settings = load_yaml(str(ROOT / "config" / "settings.yaml")) + close = datetime.strptime(settings.get("trading", {}).get("market_close", "15:30"), "%H:%M").time() + settled = datetime.combine(local.date(), close, tzinfo=KST) + timedelta(minutes=30) + phase = "post_market" if is_session and local >= settled else "intraday" if is_session else "closed_day" + return {"key": f"{day}:{phase}", "phase": phase, "is_trading_day": is_session, + "expected_session": expected, "market_data_policy": "previous_session_only"} + + +def _config_hash(config): + return hashlib.sha256(json.dumps(config, sort_keys=True, allow_nan=False).encode()).hexdigest() + + +def _save(report, directory): + directory.mkdir(parents=True, exist_ok=True) + payload = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + temporary = directory / f".{uuid.uuid4().hex}.tmp" + try: + temporary.write_text(payload, encoding="utf-8") + temporary.replace(directory / "latest.json") + finally: + temporary.unlink(missing_ok=True) + + +def read_research_operations(output_dir=None, *, now=None): + directory = Path(output_dir) if output_dir is not None else operations_directory() + try: + report = json.loads((directory / "latest.json").read_text(encoding="utf-8")) + if not isinstance(report, dict): + raise ValueError("invalid research report") + updated = datetime.fromisoformat(report["updated_at"]) + started = datetime.fromisoformat(report["started_at"]) + if (report.get("schema_version") != 1 or updated.tzinfo is None or started.tzinfo is None + or started > updated or report.get("status") not in {"running", "completed", "attention", "disabled"} + or not isinstance(report.get("jobs"), dict) + or any(not isinstance(job, dict) for job in report["jobs"].values())): + raise ValueError("invalid research run") + current = now or _now() + age = current - updated + if age < timedelta(minutes=-5): + return {"status": "invalid", "issues": ["실행 기록 시각이 미래입니다."]} + config = _config() + period = _collection_period(current) + captured = report.get("collection_period") or _collection_period(started) + report["read_collection_period"] = period + report["config_changed"] = report.get("config_hash") is not None and report["config_hash"] != _config_hash(config) + phase_missing = period["phase"] == "post_market" and captured["key"] != period["key"] + session_missing = captured["expected_session"] != period["expected_session"] + # Wall-clock age alone makes valid Friday observations look stale over a + # weekend/holiday. Check required sessions and the settled-close phase. + if report.get("status") == "running" and age > timedelta(minutes=15): + report = {**report, "status": "interrupted", "issues": ["수집 프로세스의 완료 기록이 없습니다."]} + elif report.get("status") != "running" and (phase_missing or session_missing or report["config_changed"]): + issues = [] + if phase_missing: + issues.append("장마감 확정 시간 이후의 수집 실행 기록이 없습니다.") + if session_missing: + issues.append("최신 완료 거래일을 대상으로 한 수집 실행 기록이 없습니다.") + if report["config_changed"]: + issues.append("자동 수집 설정이 마지막 실행 이후 변경되었습니다.") + report = {**report, "status": "stale", "issues": issues} + report["automatic_enabled"] = bool(config.get("enabled")) and os.environ.get("QUANT_RESEARCH_AUTOMATION_DISABLED", "").lower() not in {"1", "true", "yes"} + return report + except FileNotFoundError: + return {"status": "not_started", "issues": ["아직 자동 수집 실행 기록이 없습니다."]} + except (ValueError, TypeError, KeyError, OSError, yaml.YAMLError): + return {"status": "unavailable", "issues": ["자동 수집 기록을 읽을 수 없습니다."]} + + +def _data_status(name): + if name == "market": + from core.market_research import read_market_research + data = read_market_research() + elif name == "intelligence": + from core.market_intelligence import read_market_intelligence + data = read_market_intelligence() + else: + from core.paper_validation import read_paper_validation + data = read_paper_validation() + # Collection completion and investment validation are deliberately different. + return str(data.get("status", "unknown")) + + +def _healthy_data(name, status): + return status in ({"ready"} if name != "paper_validation" else {"observing", "inactive", "collecting"}) + + +def run_research_operations(*, force=False, trigger="manual", output_dir=None): + from core.runtime_lock import process_runtime_lock + + config = _config() + if not config.get("enabled", True) or os.environ.get("QUANT_RESEARCH_AUTOMATION_DISABLED", "").lower() in {"1", "true", "yes"}: + return {"status": "disabled"} + directory = Path(output_dir) if output_dir is not None else operations_directory() + with process_runtime_lock(directory / ".collection.lock", label="연구 자료 수집") as acquired: + if not acquired: + return {"status": "busy"} + now = _now() + period = _collection_period(now) + previous = read_research_operations(directory, now=now) + previous_period = None + if previous.get("started_at"): + started = datetime.fromisoformat(previous["started_at"]) + if started.tzinfo is None: + raise ValueError("invalid collection start time") + previous_period = previous.get("collection_period") or _collection_period(started) + same_period = previous_period["key"] == period["key"] + fresh_completed = previous.get("status") == "completed" and all( + not config.get("jobs", {}).get(name, {}).get("enabled", True) or _healthy_data(name, _data_status(name)) + for name in COMMANDS) + if not force and same_period and fresh_completed: + return {**previous, "skipped": "already_collected_period"} + if not force and same_period and previous.get("status") != "completed" and not previous.get("config_changed") and now - started < timedelta(minutes=float(config.get("retry_minutes", 60))): + return {**previous, "skipped": "retry_backoff"} + report = {"schema_version": 1, "run_id": uuid.uuid4().hex, "trigger": trigger, + "started_at": now.isoformat(), "updated_at": now.isoformat(), + "status": "running", "research_only": True, "jobs": {}, "issues": [], + "collection_period": period, "config_hash": _config_hash(config)} + _save(report, directory) + for name, command in COMMANDS.items(): + setting = config.get("jobs", {}).get(name, {}) + if not setting.get("enabled", True): + report["jobs"][name] = {"status": "disabled"} + continue + job = {"status": "running", "started_at": _now().isoformat()} + report["jobs"][name] = job + report["updated_at"] = _now().isoformat() + _save(report, directory) + try: + old_job = previous.get("jobs", {}).get(name, {}) + # DART/auth failure must not repeatedly download healthy prices or + # suppress paper observation. A new phase still reruns disclosure + # and paper jobs, while unchanged T-1 price evidence is reusable. + reusable_period = previous_period and (previous_period["key"] == period["key"] or name == "market") + if (not force or name == "market") and not previous.get("config_changed") and old_job.get("status") == "completed" and reusable_period: + data_status = _data_status(name) + if _healthy_data(name, data_status): + job.update(status="completed", data_status=data_status, reused=True, + source_completed_at=old_job.get("source_completed_at") or old_job.get("completed_at"), + completed_at=_now().isoformat()) + report["updated_at"] = job["completed_at"] + _save(report, directory) + continue + if shutil.disk_usage(directory).free < float(config.get("minimum_free_mb", 256)) * 1024 * 1024: + job.update(status="disk_pressure", issue="자료 저장 공간이 부족하여 수집을 보류했습니다.") + job["completed_at"] = _now().isoformat() + report["updated_at"] = job["completed_at"] + _save(report, directory) + continue + result = subprocess.run( + [sys.executable, str(ROOT / "tools" / command[0]), *command[1:]], + cwd=str(ROOT), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=max(5, min(300, float(setting.get("timeout_seconds", 150)))), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), check=False, + ) + job["returncode"] = result.returncode + job["status"] = "completed" if result.returncode == 0 else "failed" + job["data_status"] = _data_status(name) + if str(job["data_status"]).lower() in {"partial", "unavailable", "stale", "invalid", "error", "failed", "attention", "data_gaps", "corrupt", "not_started", "unknown"}: + job["status"] = "attention" if result.returncode == 0 else "failed" + except subprocess.TimeoutExpired: + job.update(status="timeout", issue="자료 수집 시간 제한 초과") + except Exception as exc: + # Exception messages can contain authenticated provider URLs. + job.update(status="failed", issue=type(exc).__name__) + job["completed_at"] = _now().isoformat() + report["updated_at"] = job["completed_at"] + _save(report, directory) + report["status"] = "completed" if all(j["status"] in {"completed", "disabled"} for j in report["jobs"].values()) else "attention" + report["updated_at"] = _now().isoformat() + _save(report, directory) + history = directory / "runs" + history.mkdir(exist_ok=True) + with (history / f"{report['run_id']}.json").open("x", encoding="utf-8") as handle: + json.dump(report, handle, ensure_ascii=False, indent=2, allow_nan=False) + return report + + +def launch_research_operations(trigger="runtime"): + """Return immediately; the child owns locking, retry rules and durable status.""" + try: + if os.environ.get("QUANT_RESEARCH_AUTOMATION_DISABLED", "").lower() in {"1", "true", "yes"} or not _config().get("enabled", True): + return {"status": "disabled"} + process = subprocess.Popen( + [sys.executable, str(ROOT / "tools" / "research_operations.py"), "--trigger", trigger], + cwd=str(ROOT), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + start_new_session=os.name != "nt", + ) + # Reap while a long-running scheduler stays alive; a one-shot CLI may exit. + threading.Thread(target=process.wait, daemon=True).start() + return {"status": "launched", "pid": process.pid} + except Exception as exc: + logger.warning("연구 자료 자동 수집 시작 실패: {}", type(exc).__name__) + return {"status": "failed", "issue": type(exc).__name__} diff --git a/core/scheduler.py b/core/scheduler.py index 7f88a31c..d5c0be16 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -437,6 +437,8 @@ def _run_trading_day_phase(self, now: datetime) -> None: # 따라잡는다. _post_market_done은 날짜가 바뀔 때만 초기화된다. self._run_post_market() self._post_market_done = True + from core.research_operations import launch_research_operations + launch_research_operations("post_market") def _run_pre_market(self): """장전 준비: 데이터 수집 + 전략 분석.""" diff --git a/core/strategy_ensemble.py b/core/strategy_ensemble.py index 3d4384ea..effdc419 100644 --- a/core/strategy_ensemble.py +++ b/core/strategy_ensemble.py @@ -112,8 +112,13 @@ def _load_strategies(self): except Exception as e: logger.warning("전략 {} 로드 스킵: {}", name, e) - def analyze(self, df: pd.DataFrame) -> pd.DataFrame: - """하위 전략 신호를 합성한 signal 컬럼을 포함한 DataFrame 반환.""" + def analyze(self, df: pd.DataFrame, *, current_observation: bool = False, + symbol: str = None) -> pd.DataFrame: + """Historical analysis by default; current context is explicitly requested. + + Components with current-only fundamentals may update their final row through + analyze_current(). They must never broadcast today's data into prior rows. + """ if not self._strategies: analyzed = df.copy() analyzed["signal"] = "HOLD" @@ -124,7 +129,10 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: base_df = None for name, strategy, _w in self._strategies: try: - strat_df = strategy.analyze(df.copy()) + if current_observation and callable(getattr(strategy, "analyze_current", None)): + strat_df = strategy.analyze_current(df.copy(), symbol=symbol) + else: + strat_df = strategy.analyze(df.copy()) if strat_df.empty: continue if base_df is None: @@ -269,7 +277,10 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: if not self._strategies: return {"signal": "HOLD", "score": 0, "details": {"ensemble": "전략 없음"}} - analyzed = self.analyze(df) + # generate_signal is the current decision API. An explicit historical + # boundary keeps the safe historical path; no wall-clock guessing occurs. + analyzed = self.analyze(df, current_observation=kwargs.get("as_of_date") is None, + symbol=kwargs.get("symbol")) if analyzed.empty: return {"signal": "HOLD", "score": 0, "details": {"ensemble": "분석 결과 없음"}} diff --git a/core/weekly_report.py b/core/weekly_report.py index a6fce286..ae8ae114 100644 --- a/core/weekly_report.py +++ b/core/weekly_report.py @@ -59,10 +59,16 @@ def build_weekly_summary( # 2) vs KS11 + 귀속 분해(실행/구성) bench = m.get("benchmark_return_pct") + comparison = m.get("close_comparison") + comparison_nav = comparison.get("nav_return_pct") if comparison else nav + if comparison: + bench = comparison.get("benchmark_return_pct") exe = m.get("execution_gap_pct") comp = m.get("composition_gap_pct") - if bench is not None and nav is not None: - line = f"NAV {nav:+.2f}% vs KS11 {bench:+.2f}% (격차 {nav - bench:+.2f}%p)" + if bench is not None and comparison_nav is not None: + line = f"NAV {comparison_nav:+.2f}% vs KS11 {bench:+.2f}% (격차 {comparison_nav - bench:+.2f}%p)" + if comparison: + line += f" · 종가 {comparison['start']} ~ {comparison['end']}" fields.append({"name": "📊 vs KS11", "value": line, "inline": False}) if exe is not None or comp is not None: parts = [] @@ -89,6 +95,8 @@ def build_weekly_summary( "value": value, "inline": False, }) + elif regime_note: + fields.append({"name": "🌗 국면 분해", "value": regime_note, "inline": False}) # 2-c) 리스크 지표 — daily_return이 복구되기 전(~2026-08-10)에는 스냅샷의 이 열이 # 전부 0이라 변동성·샤프를 아예 계산할 수 없었다. 이제 표면화한다. @@ -117,10 +125,15 @@ def build_weekly_summary( ce = int(cycle_errors or 0) rd = int(reconstructed_days or 0) ev_line = f"결측 {md}일 · 사이클 오류 {ce}건" + rejected = m.get("recent_order_rejections") + ev_line += f" · 주문 거부 기록 {rejected if rejected is not None else '미확인'}건" + entry = (eval_result or {}).get("entry_status") or {} + if entry.get("status") == "BLOCKED": + fields.append({"name": "⛔ 최근 평가의 매수 차단", "value": "; ".join(entry.get("reasons") or []) + f" (측정 {entry.get('measured_at')})", "inline": False}) if rd: ev_line += f" · 나중에 채운 기록 {rd}일(그날은 자동매매가 돌지 않았음)" - if md == 0 and ce == 0 and rd == 0: - ev_line += " (무사고)" + if md == 0 and ce == 0 and rd == 0 and rejected == 0 and entry.get("status") != "BLOCKED": + ev_line += " (기록된 운영 이상 없음)" fields.append({"name": "🛠 주간 이벤트", "value": ev_line, "inline": False}) title = f"{icon} 주간 요약 — {basket_name} ({verdict})" diff --git a/database/models.py b/database/models.py index bef9bde9..ad6fb9ce 100644 --- a/database/models.py +++ b/database/models.py @@ -190,6 +190,27 @@ def __repr__(self): ) +class PortfolioValuation(Base): + """Append-only measurements; daily upserts must not erase flow boundaries.""" + __tablename__ = "portfolio_valuations" + + id = Column(Integer, primary_key=True, autoincrement=True) + mode = Column(String(20), nullable=False) + account_key = Column(String(64), nullable=False) + date = Column(DateTime, nullable=False) + measured_at = Column(DateTime, nullable=False) + recorded_at = Column(DateTime, default=datetime.now, nullable=False) + kind = Column(String(24), nullable=False, default="unverified") + total_value = Column(Float, nullable=False) + cash = Column(Float, nullable=False) + cumulative_return = Column(Float, nullable=False) + daily_return = Column(Float) + mdd = Column(Float) + detail = Column(Text, nullable=False, default="{}") + fingerprint = Column(String(64), nullable=False, unique=True) + __table_args__ = (Index("ix_valuation_account_date", "mode", "account_key", "date"),) + + class DailyReport(Base): """ 일일 리포트 테이블 @@ -322,6 +343,21 @@ def __repr__(self): return f"" +class ExecutionCostEvent(Base): + """Append-only observation of new execution attempts; never an order ledger.""" + __tablename__ = "execution_cost_events" + + id = Column(Integer, primary_key=True, autoincrement=True) + event_key = Column(String(64), nullable=False, unique=True) + attempt_id = Column(String(64), nullable=False, index=True) + order_id = Column(String(64), index=True) + account_key = Column(String(64), nullable=False, default="", index=True) + mode = Column(String(20), nullable=False) + event_type = Column(String(32), nullable=False) + payload_json = Column(Text, nullable=False) + recorded_at = Column(DateTime, nullable=False, default=datetime.now) + + class OrderRecord(Base): """ 주문 상태 추적 테이블 — 브로커 기준 source of truth. diff --git a/database/repositories.py b/database/repositories.py index 0d91e58d..fb9768f7 100644 --- a/database/repositories.py +++ b/database/repositories.py @@ -19,7 +19,7 @@ get_session, with_retry, StockPrice, TradeHistory, Position, PortfolioSnapshot, DailyReport, FailedOrder, PendingOrderGuard, OrderRecord as DbOrderRecord, CashFlow, - OperationEvent, + OperationEvent, ExecutionCostEvent, ) @@ -786,6 +786,7 @@ def save_portfolio_snapshot( mode: str = "paper", reconstructed: bool = False, measured_at: datetime = None, + valuation_context: dict = None, ): """일일 포트폴리오 스냅샷 저장 (mode+account_key 장부별 격리). @@ -816,8 +817,8 @@ def save_portfolio_snapshot( position_count=position_count, reconstructed=bool(reconstructed), ) - if measured_at is not None: - snapshot.created_at = measured_at + observed_at = measured_at or datetime.now() + snapshot.created_at = observed_at # merge by (mode, account_key, date) existing = session.query(PortfolioSnapshot).filter( PortfolioSnapshot.mode == md, @@ -825,6 +826,9 @@ def save_portfolio_snapshot( PortfolioSnapshot.date == today, ).first() if existing: + # Preserve the previous observation in the same transaction as the + # replacement, including the first legacy row after this migration. + _append_valuation(session, existing, None, existing.created_at or existing.date) existing.total_value = total_value existing.cash = cash existing.invested = invested @@ -840,9 +844,11 @@ def save_portfolio_snapshot( # created_at은 '이 값이 마지막으로 측정된 시각'이다 — TWR 체인의 유입 경계가 # 이 시각을 쓰므로, 같은 날 재실행(upsert) 때 갱신하지 않으면 재실행 전에 # 반영된 입금이 다음 날 구간에 이중 산입돼 수익률이 영구 왜곡된다(적대적 리뷰 HIGH). - existing.created_at = measured_at or datetime.now() + existing.created_at = observed_at else: session.add(snapshot) + target = existing if existing is not None else snapshot + _append_valuation(session, target, valuation_context, observed_at) session.commit() return True except Exception as e: @@ -884,6 +890,89 @@ def get_latest_peak_value( # 외부 현금 흐름 (입금/출금) — 적립식 지원 (docs/POCKET_TRACK_PLAN.md §4) # ============================================================= +def _append_valuation(session, snapshot, context, measured_at): + import hashlib + import json + from database.models import PortfolioValuation + + # A legacy row may already have a richer record. Do not replace its metadata + # with an unknown-price archive when the next daily upsert happens. + if context is None and session.query(PortfolioValuation.id).filter( + PortfolioValuation.mode == snapshot.mode, + PortfolioValuation.account_key == snapshot.account_key, + PortfolioValuation.date == snapshot.date, + PortfolioValuation.measured_at == measured_at, + PortfolioValuation.total_value == snapshot.total_value, + PortfolioValuation.cash == snapshot.cash, + PortfolioValuation.cumulative_return == snapshot.cumulative_return, + PortfolioValuation.daily_return == snapshot.daily_return, + PortfolioValuation.mdd == snapshot.mdd, + ).first(): + return + context = dict(context or {}) + kind = context.get("valuation_kind") or ("reconstructed" if snapshot.reconstructed else "unverified") + payload = { + "mode": snapshot.mode, "account_key": snapshot.account_key, + "date": snapshot.date.isoformat(), "measured_at": measured_at.isoformat(), + "kind": kind, "total_value": snapshot.total_value, "cash": snapshot.cash, + "cumulative_return": snapshot.cumulative_return, "daily_return": snapshot.daily_return, + "mdd": snapshot.mdd, "context": context, + } + fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, allow_nan=False).encode()).hexdigest() + if session.query(PortfolioValuation.id).filter_by(fingerprint=fingerprint).first(): + return + session.add(PortfolioValuation( + mode=snapshot.mode, account_key=snapshot.account_key, date=snapshot.date, + measured_at=measured_at, kind=kind, total_value=snapshot.total_value, + cash=snapshot.cash, cumulative_return=snapshot.cumulative_return, + daily_return=snapshot.daily_return, mdd=snapshot.mdd, + detail=json.dumps(context, ensure_ascii=False, allow_nan=False), fingerprint=fingerprint, + )) + + +def get_portfolio_valuations(account_key: str, mode: str = "paper", *, kind=None, day=None): + """Read measurements in time order; no external price request.""" + import json + from database.models import PortfolioValuation + + session = get_session() + try: + q = session.query(PortfolioValuation).filter( + PortfolioValuation.account_key == account_key, PortfolioValuation.mode == _ledger_mode(mode), + ) + if kind: + q = q.filter(PortfolioValuation.kind == kind) + if day is not None: + d = datetime.fromisoformat(str(day)[:10]) + q = q.filter(PortfolioValuation.date == d) + return [{"id": r.id, "date": r.date.isoformat(), "measured_at": r.measured_at.isoformat(), + "recorded_at": r.recorded_at.isoformat(), "kind": r.kind, "total_value": r.total_value, + "cash": r.cash, "cumulative_return": r.cumulative_return, "daily_return": r.daily_return, + "mdd": r.mdd, "context": json.loads(r.detail or "{}")} + for r in q.order_by(PortfolioValuation.date, PortfolioValuation.measured_at, PortfolioValuation.id).all()] + finally: + session.close() + + +def append_portfolio_valuation(*, account_key, mode, day, measured_at, total_value, cash, + cumulative_return, daily_return, mdd, context): + """Append a separate valuation without touching the daily operating ledger.""" + from types import SimpleNamespace + row = SimpleNamespace(account_key=account_key, mode=_ledger_mode(mode), + date=datetime.fromisoformat(str(day)[:10]), total_value=total_value, + cash=cash, cumulative_return=cumulative_return, daily_return=daily_return, + mdd=mdd, reconstructed=False) + session = get_session() + try: + _append_valuation(session, row, context, measured_at) + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + @with_retry def record_cash_flow( amount: float, @@ -1688,6 +1777,42 @@ def resolve_failed_order(order_id: int, status: str = "retried"): OPEN_ORDER_RECORD_STATUSES = ("SUBMITTED", "ACKED", "PARTIAL_FILLED") +@with_retry +def append_execution_cost_event(payload: dict, event_type: str) -> bool: + """Idempotent observation, separate from position/cash/order transactions.""" + import hashlib + material = {key: value for key, value in payload.items() if key not in {"observed_at", "updated_at", "reconciled_observed_at"}} + event_key = hashlib.sha256(json.dumps([event_type, material], sort_keys=True, default=str).encode()).hexdigest() + session = get_session() + try: + if session.query(ExecutionCostEvent.id).filter_by(event_key=event_key).first(): + return False + session.add(ExecutionCostEvent( + event_key=event_key, attempt_id=payload["attempt_id"], order_id=payload.get("order_id"), + account_key=payload.get("account_key", ""), mode=payload["mode"], event_type=event_type, + payload_json=json.dumps(payload, ensure_ascii=False, allow_nan=False, default=str), + )) + session.commit() + return True + except IntegrityError: + session.rollback() + return False + except Exception: + session.rollback() + raise + finally: + session.close() + + +def latest_execution_cost_payload(order_id: str) -> dict | None: + session = get_session() + try: + record = session.query(ExecutionCostEvent).filter_by(order_id=order_id).order_by(ExecutionCostEvent.id.desc()).first() + return json.loads(record.payload_json) if record else None + finally: + session.close() + + def _status_value(status) -> str: return getattr(status, "value", status) or "" @@ -1789,6 +1914,7 @@ def reconcile_order_record( filled_price: float | None = None, remaining_qty: int | None = 0, reason: str = "", + execution_price_observed: bool | None = None, ) -> dict | None: """브로커 대조가 끝난 order_records 행을 open 상태에서 제외한다.""" session = get_session() @@ -1812,7 +1938,7 @@ def reconcile_order_record( record.filled_at = datetime.now() record.updated_at = datetime.now() session.commit() - return { + result = { "order_id": record.order_id, "account_key": record.account_key, "symbol": record.symbol, @@ -1825,6 +1951,10 @@ def reconcile_order_record( "remaining_qty": record.remaining_qty or 0, "reason": record.reject_reason or "", } + # Observation is independent of the authoritative reconciliation transaction. + from core.execution_analytics import observe_reconciled_order + observe_reconciled_order(result, execution_price_observed=execution_price_observed) + return result except Exception as e: session.rollback() logger.error("주문 상태 대조 완료 처리 실패: {}", e) diff --git a/docs/BASKET_SNAPSHOT_REVIEW_20261013.md b/docs/BASKET_SNAPSHOT_REVIEW_20261013.md new file mode 100644 index 00000000..e61d9150 --- /dev/null +++ b/docs/BASKET_SNAPSHOT_REVIEW_20261013.md @@ -0,0 +1,162 @@ +# 바스켓 스냅샷 추가 점검 + +2026년 10월 13일 오전 점검. 마지막 운영 스냅샷은 **10월 12일 10시 10분**이다. + +스냅샷 수집은 안정됐지만, **스냅샷이 정상으로 쌓이는 동안 주문이 막히거나 자산 배분이 목표에서 벗어나는 문제**가 남아 있다. 우선 개선할 부분은 현재 매수 차단 상태의 표시, 모의투자의 거래 시간 제한, ETF의 자산별 비중 진단이다. 새 위험 규칙의 성과를 판단할 자료는 아직 짧다. + +아래 수치는 초기 조사와 재현 결과다. 운영 DB는 읽기 전용으로 백업했고 기존 평가·주문 계획은 사본에서 실행했다. 이후 사용자의 수정 요청으로 코드 개선과 사본 실행 검증을 완료했다. 적용 내용은 문서 마지막에 정리했다. 운영 DB·설정·입금 기록에 직접 쓰지는 않았다. + +**확인한 기록** + +DB의 스냅샷은 146건이다. 현재 두 바스켓의 144건을 분석했으며, 폐기된 ETF 계정의 2건은 제외했다. 9월 23일 점검 이후 각 바스켓에 9거래일이 추가됐다. 거래일 수는 저장소의 휴장일 설정을 따르며, 아직 일일 실행 전인 10월 13일은 결측으로 세지 않았다. + +| 항목 | 대형주 분산 투자 | ETF 적립 | +|---|---:|---:| +| 기록 기간 | 6월 10일부터 | 7월 10일부터 | +| 전체 스냅샷 | 83일 | 61일 | +| 제때 기록한 스냅샷 | 81일, 97.59% | 60일, 98.36% | +| 사후 복원 | 2일 | 1일 | +| 10월 12일 기록 평가액 | 8,863,917원 | 381,122원 | +| 저장된 누적 수익률 | −11.36% | −5.98%, 입금 중화 | +| 기록 전체의 최대 낙폭 | 16.26% | 10.71% | +| 9월 23일 이후 스냅샷 수익률 | −3.72% | −2.65% | +| 비현금 자산 비중 | 56.85% | 88.01% | +| 누적 모델 거래비용 | 18,533원 | 483원 | +| 기존 평가의 연환산 비용률 | 0.563% | 0.569% | +| 기존 평가 판정 | PASS_CANDIDATE | WAIT, 모의투자 전용 | + +ETF의 88.01%에는 CD금리 ETF가 포함된다. 이를 주식 위험 노출로 해석하면 안 된다. ETF의 현재 낙폭은 5.98%이며, 표의 10.71%는 전체 기록에서 가장 컸던 낙폭이다. 비용은 실제 증권사 체결비용이 아닌 페이퍼 모형의 기록이다. + +**1. 매수가 차단된 계좌에도 평가서는 통과를 표시한다** + +10월 12일 10:10:26, 대형주 바스켓이 현대차 1주, 기준금액 326,000원을 추가 매수하려다 `MDD 한도 도달: 16.26% >= 15.00%`로 거절됐다. 최신 주식 비중은 목표 60%보다 낮은 56.85%다. 집계 비중 부족을 보충하려는 계획과 낙폭 한도가 충돌한 것이다. + +같은 DB 사본으로 기존 평가 함수를 실행하면 `PASS_CANDIDATE`, `issues=[]`, `paper_order_errors=0`이 나온다. 이유는 `core/basket_evaluation.py`의 집계가 `ORDER_ERROR`, `CYCLE_ERROR`만 세고 실제로 남은 `ORDER_REJECTED`는 제외하기 때문이다. 과거 운영 품질을 평가하는 통과 판정과 현재 주문 가능 상태가 구분되지 않는다. 주간 보고서의 무사고 표시도 결측·사이클 오류·사후 복원만 확인한다. + +개선 우선순위는 **P1**이다. 평가와 대시보드에 현재 진입 가능 여부, 차단 사유·시각, 최근 주문 거절 건수를 별도로 표시해야 한다. 과거 거절 한 건을 영구 실패로 만들지 말고, 현재 유효한 차단과 과거 사건을 분리한다. 전역 15% 한도를 올려 주문을 통과시키는 것은 이 표시 문제의 해결책이 아니다. 이 바스켓은 자체 낙폭 오버레이를 꺼 둔 상태이므로, 전역 한도를 어떤 정책으로 이어갈지는 별도 결정이다. + +확인 위치: `core/basket_evaluation.py:565`, `core/order_executor.py:1230`, `core/weekly_report.py:114`, `logs/quant_trader_2026-10-12.log:269`. + +**2. 장전 모의 체결이 정규 실행을 막았다** + +9월 28일 **08:49:28에 CD금리 ETF 1주가 58,154원에 모의 체결**됐다. 같은 날 10:10 실행은 이 거래를 보고 `REBALANCE_SKIPPED_DAILY_LIMIT`을 남겼다. 새로 쌓인 9거래일 동안 ETF 계좌에서 발생한 유일한 매수다. + +`main.py`는 휴장일 여부만 검사하고, `core/order_executor.py`는 페이퍼 주문에 대해 거래 시간 검사 전에 반환한다. 그래서 평일 장전 실행이 전일 자료로 가상 체결을 남기고 당일 매매 기회를 소진할 수 있다. 8월 26일 17:43에도 두 계좌에서 같은 정규장 밖 모의 매수가 있었다. + +개선 우선순위는 **P1**이다. 정규장용 바스켓 paper 경로도 주문 세션을 검사하고, 장전 실행은 가격 준비·진단으로 끝나야 한다. 장전 준비가 정규 실행의 일일 거래 한도를 소진하지 않는지 사본에서 재현 검증해야 한다. KRX의 ETF 정규장은 09:00~15:30이며 시간외 시장은 별도다. 현재 코드에는 시간외 주문·호가·체결을 별도로 재현하는 모형이 없다. [KRX ETF 거래제도](https://global.krx.co.kr/contents/GLB/06/0605/0605010101/GLB0605010101T1.jsp) + +확인 위치: `main.py:699`, `main.py:899`, `core/order_executor.py:3577`. 날짜가 정규 거래일이라는 것과 해당 시각에 이 주문을 체결할 수 있다는 것은 다른 검사다. + +**3. ETF는 총 투자율을 채워도 주식 비중을 채우지 못한다** + +10월 12일 보유 수량은 KODEX 200 1주, CD금리 ETF 4주, 현금 45,707원이다. 공개 시세에서 다시 받은 같은 날 종가, 각각 101,605원과 58,195원으로 평가하면 다음과 같다. 이 표의 380,092원은 종가 재평가액으로, 10:10에 저장된 381,122원과 다르다. + +| 자산 | 현재 목표 | 종가 재평가 비중 | 목표와 차이 | +|---|---:|---:|---:| +| 주식 ETF | 47.50% | 26.73% | −20.77%p | +| CD금리 ETF | 47.50% | 61.24% | +13.74%p | +| 현금 | 5.00% | 12.03% | +7.03%p | + +기록된 총 투자율 88.01%는 목표 95%와 6.99%p 차이여서, 현재 허용치 10%p를 사용하는 **배치율 진단은 OK**다. 주식 ETF의 큰 미달이 이 합계에서는 보이지 않는다. 다른 축의 적립·자료 경고까지 포함한 전체 헬스가 OK라는 뜻은 아니다. + +기존 `plan_rebalance`를 DB 사본과 위 종가로 실행해도 주문은 0건이다. 주식 ETF의 목표 부족액은 약 78,939원으로 1주 가격보다 작고, CD ETF의 초과액도 약 52,236원으로 1주보다 작다. 양쪽 수량을 각각 내림하면 둘 다 0주가 된다. 최소 주문금액만 낮춰서는 이 문제가 풀리지 않는다. + +또한 월 10만원 적립 설정에 비해 마지막 입금 기록은 **8월 26일 10만원 한 번**이다. 10월 13일 현재 48일이 지났고 9월 적립 기록은 없다. 실제로 입금했는지와 장부 누락인지는 DB만으로 알 수 없다. + +| 동일 종가의 계획 재현 | 현재 잔고 | 10만원 유입을 가정한 잔고 | +|---|---:|---:| +| 기존 코드의 주문 계획 | 없음 | 주식 ETF 1주 매수 | +| 예상 거래비용 | 0원 | 66원 | +| 계획 체결 후 주식 ETF | 26.73% | 42.33% | +| 계획 체결 후 CD ETF | 61.24% | 48.49% | +| 계획 체결 후 현금 | 12.03% | 9.17% | + +이는 **입금 가정에 따른 계획 계산**이며 입금이나 주문을 실행한 결과가 아니다. 실제 체결에는 가격 변동과 실행 시점의 다른 검사가 적용된다. + +개선 우선순위는 **P1**이다. 주식·CD·현금 비중을 따로 표시하고, 주문 0건의 이유를 `1주 단위 제약`, `최소금액`, `가용현금`, `재진입 제한`, `리스크 차단`으로 구분해야 한다. 적립도 마지막 날짜뿐 아니라 약정 월·금액과 실제 기록을 대조한다. 그 다음 단계로 정수 수량을 함께 고르는 계획기를 검토할 수 있지만, 단순 교환 매매는 현금을 더 늘리거나 회전을 크게 만들 수 있어 비용·현금 하한·위험 상한을 함께 비교해야 한다. + +확인 위치: `core/basket_rebalancer.py:833`, `core/basket_rebalancer.py:945`, `core/operator_health.py:206`, `core/operator_health.py:253`. + +**4. 가격 시각과 벤치마크가 맞지 않아 성과 비교의 근거가 약하다** + +9월 17일 이후 운영 스냅샷의 최종 측정 시각은 10:08 또는 10:10이다. 이를 당일 종가 기준 지수와 바로 비교하면 오전 이후 움직임이 실행 격차에 섞인다. 실제로 10월 12일 종가로 원장을 재평가한 금액은 대형주 8,816,167원, ETF 380,092원으로 운영 스냅샷과 다르다. + +이번 공개 시세 재조회에서도 FDR의 `KS11`, `KS200`은 **9월 17일이 마지막 행**이었다. 요청한 10월 12일까지 13거래일의 후속 지수 자료가 없었다. ETF 가격은 10월 12일까지 확보됐다. 위험 판단은 주식 ETF를 대용으로 사용하고 있으며, 최신 상태에는 원지수 날짜와 대용 자료 날짜가 모두 남아 있다. 이 대용 판단을 원지수 복구로 표시해서는 안 된다. + +개선 우선순위는 **P2**다. 스냅샷에 종목별 가격·수량, 가격 기준 시각·날짜, 출처, 장중/종가 구분을 저장하고, 종가 성과는 장마감 평가로 확정해야 한다. 원지수의 마지막 날짜·지연 거래일 수·대체 사용 여부를 함께 보여 준다. 같은 지연 사유의 `OVERLAY_DATA_ISSUE`가 9월 28일부터 10월 12일까지 9번 남았으므로, 상태는 계속 표시하되 알림은 발생·악화·복구에 맞추는 편이 좋다. + +확인 위치: `core/basket_rebalancer.py:1275`, `core/basket_rebalancer.py:249`, `core/portfolio_manager.py:425`, `database/models.py:151`. + +**5. 입금 전후 평가를 덮어쓰면 수익률을 재현하기 어렵다** + +ETF의 8월 26일 오전 로그에는 평가액 284,499원, 누적 수익률 −5.17%가 남아 있다. 그 뒤 10만원 입금과 거래를 반영한 저녁 평가액은 385,460원이다. 오전 평가를 연결하면 누적 수익률은 약 −4.933%로, 저장된 −4.93%와 맞는다. + +반면 일일 테이블에는 저녁 행만 남아 있다. 전일 평가액 280,718원에 입금 10만원이 하루 시작에 들어왔다고 놓고 계산하면 약 −5.265%가 된다. **이 차이를 곧바로 수익률 계산 오류라고 판단하면 안 된다. 입금 경계를 나누는 방법이 다르다.** 현재 남은 일일 NAV만으로 전체 기간을 다시 연결하면 약 −6.33%여서, 표시된 −5.98%와도 차이가 난다. + +개선 우선순위는 **P2**다. 일일 대표 행과 별도로 입금 전후 평가를 덮어쓰지 않는 원장에 남기고, 누적 수익률·일일 수익률·국면 분석이 어떤 현금흐름 경계를 쓰는지 통일해야 한다. 기존 수익률을 일일 근사치로 소급 덮어쓰는 것은 권하지 않는다. + +확인 위치: `core/portfolio_manager.py:347`, `database/repositories.py:818`, `core/performance_lens.py:35`, `logs/quant_trader_2026-08-26.log:93`. + +**새 자료로 판단할 수 있는 성과의 범위** + +시간 기준을 맞추기 위해 9월 23일~10월 12일의 실제 보유 수량·현금·매매 기록을 각 날짜 종가로 다시 평가했다. 아래 수익률은 공식 운영 스냅샷 수익률과 구분되는 사후 재평가다. 입금은 이 구간에 없으며, ETF의 9월 28일 거래비용은 반영했다. 원지수가 없어서 **KODEX 200 가격의 상승·하락으로만 국면을 분류**했다. 이는 KS11 비교나 배당 재투자를 반영한 총수익률 비교가 아니다. + +| 구간 | 대형주 계좌 | ETF 계좌 | KODEX 200 가격 | +|---|---:|---:|---:| +| 전체 9개 일간 구간 | −3.89% | −2.89% | −10.20% | +| 상승한 3일만 연결 | −0.01% | +0.82% | +2.75% | +| 하락한 6일만 연결 | −3.88% | −3.67% | −12.61% | + +하락에서 덜 잃었지만 상승을 따라가는 정도도 낮았다. ETF 계좌의 주식 노출이 약 27%라는 점이 이 결과에 영향을 준다. 이 방어 성과를 새 오버레이의 효과로 귀속할 수는 없다. 대형주 계좌의 이 구간 종가 평가손실은 356,500원이고, 삼성전자 −106,500원, NAVER −65,200원, 신한지주 −60,000원의 영향이 컸다. 이는 손실 기여 설명이며 종목 교체 근거로 충분한 표본은 아니다. + +ETF의 전체 기록은 61일이지만 **9월 17일 변경한 규칙의 실측은 14일**이다. 그 기간 확보한 오버레이 판단 로그 18건은 모두 배수 1.0이다. 이 로그에서는 방어 비중으로 줄였다가 복귀하는 실제 운영 전환을 확인하지 못했다. 상승일도 3일뿐이므로 수익률·손절 기준·현금 비중을 다시 최적화하기보다는 위 실행·기록 문제부터 해결하는 것이 우선이다. + +**재현 자료** + +- [집계와 종가 재평가 원본](../output/basket_snapshot_review_20261013/audit.json): 계좌별 기록·국면 분해·종목 기여도·시세 파일 SHA-256. +- [기존 평가 결과](../output/basket_snapshot_review_20261013/evaluation.json): 10월 13일 기준 평가 함수의 실제 반환값. +- [기존 계획기 재현 결과](../output/basket_snapshot_review_20261013/plan_replay.json): 입금 없음과 10만원 유입 가정. 오버레이는 관측된 배수 1.0으로 고정. +- [오프라인 분석 스크립트](../output/basket_snapshot_review_20261013/audit.py), [사본 계획 재현 스크립트](../output/basket_snapshot_review_20261013/replay.py). + +자료는 로컬 `output/basket_snapshot_review_20261013/`에 있으며 Git 제외 대상이다. 운영 DB 사본의 무결성, 기말 현금·수량 일치, 재현 계획의 현금 하한을 확인했다. 분석 후 운영 DB의 11개 테이블 내용도 최초 사본과 같았다. 평가 함수는 재실행일을 사용하므로 이후 실행하면 평가 분모가 달라질 수 있다. + +```powershell +.venv/Scripts/python.exe output/basket_snapshot_review_20261013/audit.py +.venv/Scripts/python.exe output/basket_snapshot_review_20261013/replay.py +``` + +**10월 13일 수정 결과** + +- 평가에 `entry_status`를 추가했다. 최근 스냅샷이 전역 손실 한도에 걸리면 과거 운영 기준 통과(`operation_verdict`)를 보존하면서 최종 판정은 `WAIT`로 표시한다. 최근 7일 `ORDER_REJECTED` 기록을 오류 건수와 별도로 보여 준다. 과거 거부 기록만으로 영구 차단하지 않으며, 주문 직전의 전체 가드는 계속 재검사한다. +- 바스켓 paper의 매수·매도 모두 정규장으로 제한했다. 리밸런서와 직접 주문 경로에서 확인하며 `--force-rebalance`도 거래 시간을 우회하지 않는다. 장전 진단은 당일 NAV와 당일 체결 기록을 만들지 않는다. +- 주식·CD 방어 자산·현금을 각각 평가해 목표 비중과 비교한다. 1주 단위, 최소 거래금액, 쿨다운, 회전 한도, 현금 유보·비용 때문에 보류된 주문의 사유를 저장한다. 대시보드에는 자산별 시장가격 비중과 보류 사유를, 헬스에는 배분 이탈과 매수 차단을 표시한다. +- 적립은 개시 다음 달부터 월별 계획 금액과 양의 입금 합계를 대조한다. 지난달 부족분은 이번 달의 입금으로 사라지지 않는다. 현재 월은 명시적인 `due_day`가 없으면 진행 중이며, 일부만 입금한 경우 화면에서도 완료로 표시하지 않는다. +- `portfolio_valuations`에 평가 관측을 추가 전용으로 저장한다. 일별 요약의 재실행 시 기존 관측도 같은 트랜잭션에서 보존한다. 측정 시각, 가격 기준일·출처, 수량, 현금흐름 연결 근거를 기록하고 누적 TWR은 내부에서 반올림하지 않는다. 적립 계정의 일간·주간 수익률과 위험 보고는 동일한 누적 TWR 지수를 사용한다. +- 장중 관측에 수량·가격 기준일이 있는 paper 계정은 장 마감 30분 이후 또는 다음 바스켓 사이클에 별도 종가 평가를 추가한다. 측정 이후 체결·입금을 반영하고 정확히 그 날짜의 종가가 있어야 저장한다. 운영용 일별 NAV를 덮어쓰지 않는다. 날짜를 확인할 수 없는 과거 자료는 `unverified`, 사후 복원은 `reconstructed`로 남긴다. +- 벤치마크 비교·국면 분해는 동일 날짜의 별도 종가 평가가 2일 이상 있을 때만 수행한다. 전체 운영 누적 수익률과 종가 비교 구간의 수익률을 분리했다. 정적 설계 귀속에는 양 끝 날짜와 모든 종목의 자료가 필요하며 동적 오버레이를 정적 목표로 비교하지 않는다. KS11 자료가 없으면 비교를 보류하고 ETF를 KS11로 표시하지 않는다. +- 같은 오버레이 자료 문제는 날짜만 바뀐다고 다시 운영 이벤트를 만들지 않는다. 원인 변화와 복구는 기록하며 현재 문제와 출처 날짜는 계속 화면에 남긴다. + +위험 한도, 최소 주문금액, 회전율, 목표 비중과 자동 적립 설정은 변경하지 않았다. 실제 주문 허용 여부는 최신 가격·잔고·세션·전체 가드에 달려 있으며, 화면의 최근 측정 상태가 주문 승인은 아니다. + +**검증과 적용** + +Python 관련 회귀 **443개**, 대시보드 JavaScript **24개** 통과. 입금 전후 같은 날 여러 관측의 수익률 연결, 추가 전용 저장과 실패 시 원자적 롤백, 가격 기준일 불일치, 종가 평가의 체결·입금 반영, 중복 억제 및 복구를 확인했다. + +[운영 데이터 사본 검증 결과](../output/basket_snapshot_review_20261013/fix_verified/result.json): + +| 실제 코드 경로 | 결과 | +|---|---| +| 장전 08:49·장후 17:43 매수/매도 | 4건 차단, 체결 0건 | +| 대형주 현대차 1주 매수 | 재평가 MDD 16.71%로 기존 15% 한도에서 거부, 평가도 WAIT | +| ETF 현재 자금으로 계획 | 주문 0건, 두 종목 모두 `MIN_LOT` 사유 확인 | +| 사본에만 가상 10만 원 적립 후 계획→실행 | 주식 ETF 1주 체결, 주식 42.33%·CD 48.49%·현금 9.17% | +| 동결 시세를 사용한 종가 저장 재현 | 별도 NAV 380,092원 저장, 운영 일별 기록 보존 | +| 원본 사본 파일 SHA-256 | 실행 전후 동일 | + +종가 저장 재현은 동결한 종가 CSV를 통제된 측정 입력으로 사용한 소프트웨어 검증이며, 사라진 과거 장중 시세를 복구한 것은 아니다. 모든 체결·입금·추가 테이블 생성은 검증 DB에만 발생했다. 검증 도구는 네트워크와 알림을 막고 원본을 읽기 전용으로 복사한다. 재실행 때는 새 출력 디렉터리를 지정한다. + +```powershell +.venv/Scripts/python.exe tools/verify_basket_snapshot_fixes.py --source-db output/basket_snapshot_review_20261013/source.db --prices-dir output/basket_snapshot_review_20261013/prices --out-dir output/basket_snapshot_verify_new +``` + +다음 정상 실행의 `init_database()`가 추가 테이블을 생성하고 새 관측부터 기록한다. 기존 실행 중인 프로세스는 코드 재시작 전까지 이전 버전을 유지한다. 과거 장중 가격·수량 메타데이터는 꾸며 채우지 않으므로 새 종가 관측이 쌓일 때까지 일부 비교는 자료 대기로 표시된다. diff --git a/docs/INVESTMENT_RESEARCH_20261013.md b/docs/INVESTMENT_RESEARCH_20261013.md new file mode 100644 index 00000000..e307f6d7 --- /dev/null +++ b/docs/INVESTMENT_RESEARCH_20261013.md @@ -0,0 +1,120 @@ +# 전문 운용 조사와 프로젝트 적용 — 2026-10-13 + +투자자가 시장 변화를 발견하고, 기업·가격·위험을 검증하고, 실제 매매 결과로 판단을 다시 평가하는 과정을 조사했다. 이번 변경은 **시장 조사 → 투자 가설 기록 → 검증된 후보의 다음 단계 진입**을 연결한다. 새로운 연구 지표의 수익성이나 시스템의 완전성을 입증했다는 뜻은 아니다. + +## 1. 조사 범위와 적용 판단 + +모든 링크는 2026-10-13 확인한 원문이다. 운용사 자료는 그 회사의 방법을 보여 주지만 독립적인 수익성 보증은 아니다. 아래 적용 판단은 이 프로젝트의 소액 국내 주식·ETF, 정수 주식 수, 저회전 바스켓, 현재 확보한 자료에 맞춘 설계 판단이다. + +| 관점 | 원문에서 확인한 실무 | 우리 프로젝트 적용 | +|---|---|---| +| 투자 목적·제약 | [CFA — Portfolio Planning, 2026](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/basics-of-portfolio-planning-and-construction): 목적·위험·유동성·기간 등 투자 정책을 먼저 정한다. | 적립 계좌와 대형주 관찰 계좌의 목적을 유지. 연구 순위가 승인된 비중을 자동 변경하지 않는다. | +| 자산 배분 | [CFA — Asset Allocation, 2026](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/overview-asset-allocation): 전략 배분, 전술 판단, 위험 예산과 재조정 비용을 함께 고려한다. | 테마 발견과 편입 결정을 구분. 소액 최소 주문·정수 수량·현금·회전 한도는 기존 실행 경로에서 계속 검사한다. | +| 종목 선택과 포트폴리오 | [CFA — Active Equity Portfolio Construction, 2026](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/active-equity-investing-portfolio-construction): 매력도 순위와 비중 결정은 다르며 집중·상관·유동성·비용을 고려한다. | 19개 관찰 순위를 매수 명령으로 해석하지 않는다. 구성 종목과 업종 ETF를 나눠 보고, 전체 시장을 대표한다고 표시하지 않는다. | +| 사회·산업 흐름 | [BlackRock — Thematic Investing](https://www.blackrock.com/us/individual/insights/thematic-investing): 텍스트에서 테마·기업 연결을 찾고 가격·심리·과밀·가치 평가를 결합한다. | 출처, 연결 근거, 반대 근거, 반증 조건을 갖춘 투자 가설 저널. 인기 키워드만으로 수혜주를 자동 지정하지 않는다. | +| 구조적 변화 | [BlackRock — Mega Forces](https://www.blackrock.com/corporate/insights/blackrock-investment-institute/mega-forces): AI, 인구, 지정학, 금융, 에너지의 장기 변화와 상호작용을 관찰한다. | 단기 가격 순위와 장기 가설의 검토 기간을 구분한다. 모든 테마를 하나의 단기 모멘텀 점수로 합치지 않는다. | +| 기업 분석·매도 검토 | [Smithson — Owner's Manual](https://www.fundsmith.co.uk/media/mklmmv5l/smithson-equity-fund-owners-manual.pdf): 자본수익·현금 창출·경쟁력·가격과 보유 논거의 변화를 점검한다. | 실적 발표 후 현금흐름·투자 지출·지속성을 재검토하도록 가설과 검토일을 보존한다. 특정 운용 철학을 모든 업종에 동일 적용하지 않는다. | +| 계량적 추세 | [MSCI Momentum Methodology, July 2025](https://www.msci.com/indexes/documents/methodology/2_MSCI_Momentum_Indexes_Methodology_20250725.pdf): 다기간 모멘텀, 위험 조정, 버퍼, 정기 검토와 회전 관리. | 20/60/120거래일 상대 강도와 변동성을 함께 관찰. MSCI 지수나 모멘텀 방법론을 복제했다고 주장하지 않는다. | +| 국면 검증 | [AQR — Century of Trend Following](https://www.aqr.com/insights/research/journal-article/a-century-of-evidence-on-trend-following-investing): 여러 자산과 긴 기간을 사용한다. | 국내 롱온리 결과를 해외 선물·롱숏 성과와 동일시하지 않는다. 기존 상승/하락 구간 분석과 다른 기간·유니버스 검증을 유지한다. | +| 매매 집행 | [CFA — Trading Costs, 2026](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/trading-costs-and-electronic-markets): 수수료뿐 아니라 지연·충격·미체결 기회비용을 고려한다. | 기존 수수료·슬리피지·회전 한도를 유지. 판단 가격부터 체결까지 연결하는 실측 비용 분석은 후속 구현 대상으로 명시한다. | +| 거래비용 실증 | [AQR — Trading Costs](https://www.aqr.com/insights/research/working-paper/trading-costs): 실제 기관 체결 자료의 비용은 종목·규모·거래 방식에 따라 달라진다. | 기관 대규모 주문 계수를 소액 ETF에 그대로 이식하지 않는다. 모의 체결 비용과 실측 비용을 구분해야 한다. | +| 시점·표본 검증 | [CFA — Backtesting and Simulation, 2026](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/backtesting-and-simulation): 투자 시점에 가능했던 자료, 생존자 편향, 구간·시나리오 검증이 중요하다. | 과거 재무 분석에 현재 Yahoo 자료와 마지막 날짜의 점수가 섞이던 경로를 수정했다. | +| 반복 탐색의 과적합 | [Bailey·López de Prado — Deflated Sharpe Ratio](https://www.davidhbailey.com/dhbpapers/deflated-sharpe.pdf), [Bailey 외 — Probability of Backtest Overfitting](https://www.davidhbailey.com/dhbpapers/backtest-prob.pdf) | 실제 관측수·왜도·첨도를 사용하고 훈련 구간 선택 후보만 추천한다. 단일 실행의 DSR은 프로젝트 전체 반복 탐색을 완전히 보정하지 않는다. | +| 모델 재검증 | [CFA Research Foundation — Investment Model Validation](https://rpc.cfainstitute.org/-/media/documents/article/rf-brief/investment-model-validation.pdf) | 경고만 남기던 필수 근거를 새 후보 승격 판정에 연결하고, 기존 결과를 무조건 현재 검증 완료로 보지 않는다. | +| 전문가 보유 추종 | [SEC — Form 13F FAQ](https://www.sec.gov/rules-regulations/staff-guidance/frequently-asked-questions-about-form-13f): 분기 자료에 보고 지연이 있고 숏 포지션이 포함되지 않는다. | 기관 보유를 현재 매수 신호로 복사하지 않는다. 보유 기준일과 공개·관측 시점을 분리해야 한다. | +| 국내 공시 | [OpenDART 공시검색 명세](https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019001): 접수일·접수번호·정정 및 보고서 구분을 제공한다. | 접수일을 임의의 미래 실적 발표일로 바꾸지 않는다. 실제 공시 시점이 없는 과거 재무 자료는 없는 것으로 처리한다. | +| 국내 수급·거시 | [KRX Data Marketplace](https://data.krx.co.kr/contents/MDC/MAIN/main/index.cmd), [한국은행 ECOS](https://ecos.bok.or.kr/) | 투자자별 수급·금리·경기 자료의 후속 공급원. 이번에 버전이 있는 자동 수급·거시 수집까지 구현했다고 주장하지 않는다. 수정 통계와 원래 공개 자료를 구분할 수 있어야 한다. | + +## 2. 구현한 운용 흐름 + +### 시장 관찰 + +`config/market_research.yaml`의 9개 바스켓 구성 종목과 10개 업종 ETF를 KODEX 200과 비교한다. 반도체·은행·자동차·2차전지·헬스케어·건설·에너지화학·IT·조선·방산 ETF의 운용사 참고 링크를 설정에 남겼다. ETF 편입 종목과 산업 노출은 변할 수 있으며, 현재 관찰 목록은 과거 투자 가능 유니버스가 아니다. + +- 동일 거래일 구간의 20/60/120일 수정가격 수익률과 비교지수 대비 차이. +- 직전 거래일 거래량 / 그 이전 20일 평균 거래량, 최근 20일 연율화 변동성, 120일 구간 최대낙폭. +- 각 관찰 집단에서 60일 평균선 위인 종목 수와 확인 가능한 분모. 결측을 0으로 채우지 않는다. +- 공급자, 가격 기준, 자료 기준일, 수집 시각, 설정 해시, 순위 변화. 지연·비교 불가·설정 변경을 숨기지 않는다. +- 당일 봉은 장 마감 뒤에도 제외하는 보수적인 T-1 기준. 가격 공급자의 배당 조정은 다를 수 있어 공식 총수익 지수와 같다고 보지 않는다. + +화면의 **시장 자료 갱신**은 별도 프로세스에서 연구 자료만 수집한다. 120초 제한, 동시 요청 공유, 실패 표시를 제공한다. 화면의 주기 조회는 저장 자료만 읽는다. 계좌 주문 루프와 결합하지 않아 자료 수집 장애가 주문을 지연시키지 않는다. + +### 투자 가설과 이벤트 + +`core/research_evidence.py`는 작성자가 원문을 확인한 기록을 보관한다. 자동 뉴스 피드나 LLM의 사실 판정기가 아니다. + +- 가설, 기간, 반증 조건, 반대 근거, 원문 URL, 공개 시각, 실제 관측 시각, 다음 검토일, 유효 기한. +- 종목을 연결하려면 별도의 사업·이벤트 연결 근거와 출처가 필요하다. +- 저장 시 서버의 관측 시각을 부여한다. 새 버전은 추가하며 이전 버전을 덮어쓰지 않는다. 과거 조회에서 당시 없던 버전이 보이지 않는다. +- 게시 날짜만 아는 자료는 정확한 게시 시각을 만들어 넣지 않는다. `UNVERIFIED`는 정보의 시점 등이 부족하다는 뜻이다. `CURRENT`도 수익성이나 가설의 진실을 검증했다는 뜻이 아니다. +- 미래 실적 일정은 `event_at`으로 저장한다. 아직 발표하지 않은 실적을 현재의 증거로 등록하지 않는다. 이벤트가 지나면 결과 검토 대상으로 표시한다. + +실제 원문을 확인한 초기 기록 두 건을 등록했다. + +1. [IEA — Key Questions on Energy and AI](https://www.iea.org/reports/key-questions-on-energy-and-ai), 2026-04-16 발표. AI 확대와 전력·공급망 제약의 상호작용을 검토한다. 국내 특정 종목의 수혜를 입증한 자료는 아니다. +2. [삼성전자 3분기 가이던스](https://news.samsung.com/be/samsung-electronics-announces-earnings-guidance-for-third-quarter-2026), 2026-10-08 발표와 [공식 IR](https://www.samsung.com/global/ir/)의 10월 29일 10시 설명회 안내. 잠정 가이던스를 확정 실적으로 취급하지 않고 현금흐름·투자 지출·지속성을 후속 검토한다. + +### 연구 후보 검증과 승격 + +기존 도구는 OOS 결과 계산 전에 추천을 만들고, 전체 기간 우수 후보와 훈련 구간 선택 후보를 섞을 수 있었다. 절대 샤프가 양수인 것만으로 비교지수 열세가 가려질 수도 있었다. 다음을 수정했다. + +- 훈련 구간에서 선택한 후보의 ID를 유지하고 OOS 구간 비교지수를 다시 계산한다. +- 실제 수익률 관측수와 왜도·첨도를 DSR 계산에 사용한다. 평일 달력 일수를 관측수로 대체하지 않는다. +- OOS·다중검정·시점 유니버스 근거가 없거나 실패하면 다음 단계 추천을 보류한다. +- 새 canonical 평가와 승격에는 검증 근거와 후보·자본금·미커밋 변경을 포함한 코드 지문·설정·기간·유니버스 연결 확인을 요구한다. DSR 관측일과 학습 기간, 가능한 관측수, 252일 연율화·Pearson 첨도 규약도 확인한다. 근거가 없는 새 결과는 `research_only`이다. +- 기존 승인과 바스켓을 소급 정지하지 않는다. 과거 결과는 `legacy_unverified`로 구분하며 새 검증이 완료됐다고 표현하지 않는다. + +### 재무 자료 정합성 + +Yahoo의 `debtToEquity`를 다시 100배 하던 오류를 수정하고, 일부 필드가 없는 다른 공급자 응답을 전체 성공으로 처리하던 경로를 보완했다. 공급자마다 부채 정의가 다르므로 필드별 출처·기준을 보존한다. + +평균회귀 전략의 재무 필터도 총부채/자본 기준을 기본으로 명시한다. Yahoo의 차입금/자본을 총부채/자본으로 대체해 통과시키지 않는다. 차입금 기준이 의도라면 `debt_ratio_basis: interest_bearing_debt_to_equity_pct`를 별도로 지정해야 한다. 기준이나 수치가 없으면 매수 필터를 통과하지 못한다. + +과거 날짜 조회에 현재 Yahoo 재무 값이 합쳐지거나, 마지막 날짜의 재무 점수가 과거 전 구간에 퍼지던 경로를 차단했다. 공시 시점이 없는 ROE·부채·영업이익 자료에 임의의 60일 지연을 붙여 과거에 알 수 있었다고 간주하지 않는다. 과거 재무 팩터가 자료 부족으로 비활성 상태가 되는 것은 검증 가능한 데이터의 한계를 드러내는 결과다. + +현재 확보한 역사 자료만으로 재무 팩터의 최소 요건을 채울 수 없어, 역사 분석은 외부 API를 날짜마다 반복 호출하지 않고 자료 부족으로 보류한다. 실시간 분석에는 별도의 현재 관측 경로를 두어 최신 한 행에만 점수를 적용한다. 실시간 앙상블도 이 경로를 사용하며, 역사 분석과 섞지 않는다. + +## 3. 실행과 재현 + +프로젝트 루트의 PowerShell에서: + +```powershell +# 공개 가격으로 시장 관찰 자료 갱신 (주문·DB 초기화 없음) +.venv/Scripts/python.exe tools/market_research.py + +# 직접 확인한 원문과 가설을 추가 (매번 새 관측 시각·버전) +.venv/Scripts/python.exe tools/research_evidence.py import config/research_theses.json +.venv/Scripts/python.exe tools/research_evidence.py list --json + +# 운영 DB 사본의 실제 주문 경로 재현 (고정된 이전 검증 자료 사용) +.venv/Scripts/python.exe tools/verify_basket_snapshot_fixes.py --source-db output/basket_snapshot_review_20261013/source.db --prices-dir output/basket_snapshot_review_20261013/prices --out-dir output/research_review_20261013/new_basket_replay + +# 기존 승인 평가 자료를 사본으로 읽어 새 근거 요구를 확인 +.venv/Scripts/python.exe tools/verify_research_admission.py --out-dir output/research_review_20261013/new_admission_replay +``` + +연구 가격 결과는 `output/market_research/latest.json`과 `snapshots/`, 가설은 `data/research_evidence/`에 저장된다. `QUANT_MARKET_RESEARCH_DIR`, `QUANT_RESEARCH_EVIDENCE_DIR`로 검증 환경을 격리할 수 있다. 각 도구의 `--help`에 오프라인 재현 옵션이 있다. 연구 파일 삭제는 관측 근거를 잃으므로 장기 관찰에 사용할 경우 함께 백업해야 한다. + +화면 서버가 이미 실행 중이면 코드 변경을 반영하도록 대시보드 프로세스를 다시 시작해야 한다. 연구 결과 갱신에는 자동매매 프로세스 재시작이 필요 없다. + +## 4. 검증 결과 + +- 공개 데이터 실제 수집: 2026-10-12 종가 기준 19/19개 관찰 종목 비교 가능, 누락 0. 가격 공급자의 원천 데이터 정확성 자체를 보증하는 수치는 아니다. +- 운영 DB 사본 `plan → execute`: 장외 모의 주문 4건 거부·체결 0, MDD 초과 매수 거부, 소액 최소 수량 보류, 사본에만 추가한 적립금의 ETF 1주 매수와 분리 종가 평가 재현. 원본 사본 소스 해시 불변. +- 실제 저장된 후보 25개에 대해 검증 근거 없음/잘못된 근거 두 경우 모두 `research_only`, 새 paper/live 승인 0. 원본 평가 파일 5개의 해시 불변. +- 변경 관련 Python 30개 테스트 파일: **665개 통과**. 기존 의존성의 폐기 예정 API 경고 2건이 있으며 실패는 없다. 화면 JavaScript: **32개 통과**. +- 실제 브라우저에서 시장 자료 갱신 요청, 19개 자료 표시, 업종 필터, 기간 전환, 데스크톱 1440px·모바일 390px 화면을 확인했다. 모바일 표는 가로로 스크롤되고 페이지 전체는 넘치지 않는다. +- 검증 기록: `output/research_review_20261013/python-final.txt`, `js-final.txt`, `admission-final/result.json`, `basket_regression/result.json`. 화면 기록: `output/playwright/research-desktop.png`, `research-mobile.png`. + +## 5. 남은 완성도 기준 + +후속 구현: [공시·수급 자동 수집, 체결비용, 장기 모의 검증](RESEARCH_OPERATIONS_20261013.md). 아래 목록은 최초 조사 시점의 과제이며 후속 문서에서 구현 범위와 남은 제약을 확인한다. + +이번 구현으로 시장 관찰과 연구 규율은 개선됐지만 **실제 초과수익은 아직 입증되지 않았다.** 다음은 구현됐다고 표현하면 안 되는 부분이다. + +1. **공시·거시·수급의 시점 보존 자동 수집**: DART 정정 전 원문, ECOS 최초 발표와 수정치, KRX 투자자 수급을 날짜별로 보존하는 데이터 계약과 운영 점검이 필요하다. 현재 가설 기록은 수동 조사 기반이다. +2. **주문 의사결정부터 체결까지의 비용 분석**: 의사결정 가격·시각, 주문·체결·거부·미체결을 연결하고 BUY/SELL 방향별 불리한 가격 이동을 측정해야 한다. 현재 모의 슬리피지는 실측 시장 충격이 아니다. +3. **누적 연구 시행 원장과 새로운 미열람 검증 구간**: 한 실행의 DSR과 반복해서 확인한 OOS만으로 탐색 편향 전체를 제거하지 못한다. 다시 본 구간을 계속 untouched holdout이라 부르지 않는다. +4. **장기 모의 검증과 실행 가능성**: 새 관찰 지표를 운용 신호로 바꾸려면 비용·정수 수량·상승/하락 국면·회전·집중·유동성 검증과 별도의 paper 누적 관측이 필요하다. 관찰 순위가 좋아 보인다는 이유로 활성화하지 않는다. + +기존 `PROFITABILITY_FINDINGS.md`의 일부 “가장 좋은 실행”, “회전이 낮을수록 수익이 높다”, “DSR 통과로 과적합이 아니다”라는 표현도 해당 표본과 가정에 한정되도록 수정했다. diff --git a/docs/PROFITABILITY_FINDINGS.md b/docs/PROFITABILITY_FINDINGS.md index 060ff5b2..52e4a154 100644 --- a/docs/PROFITABILITY_FINDINGS.md +++ b/docs/PROFITABILITY_FINDINGS.md @@ -2,14 +2,17 @@ > 아래 수치는 당시 데이터와 실험 조건에서 나온 과거 결과입니다. "안정적 고수익이 가능하다", "알파는 없다"는 기존의 일반화는 철회합니다. 일부 전략이 비교군에 뒤졌다는 사실만으로 미래 수익성이나 모든 능동 전략의 한계를 단정할 수 없습니다. 2026년 9월 변경과 새 검증은 [위험 관리 검증 보고서](RISK_REVIEW_20260917.md)를 보세요. +> **2026-10-13 검증 정정:** 아래 과거 도구의 PASS/DSR 수치를 현재의 승인 근거로 사용하지 않습니다. 실제 관측수·비정규 분포, 훈련 구간 후보 선택, OOS 비교지수, 시점 유니버스와 새 승격 근거 연결을 보강했습니다. 재사용한 검증 구간은 untouched라고 볼 수 없습니다. [변경 사항과 1차 참고자료](INVESTMENT_RESEARCH_20261013.md). + ## 이 실험에서 확인한 것 2023~2025년 비교에서 대형주 30종목 동일비중 보유는 +123.8%를 기록했습니다. 여기서 시험한 모멘텀·저변동성·위험 균형·이동평균 규칙은 해당 비교군을 넘지 못했습니다. 아래의 비용·기간·유니버스 조건을 함께 읽어야 하는 결과입니다. > 중요(실행 가능성): 위 +123.8%는 **무비용·매수후보유(frictionless buy-once-hold)** 벤치마크다. > 같은 동일비중을 월 1회 리밸런싱 + 익일 시가 체결 + 수수료/세금/슬리피지로 **실제 실행**하면 -> +83.4%(Sharpe 0.97, MDD −20%)로 떨어진다. 즉 능동 리밸런싱은 그 자체로 비용이라, **가장 좋은 -> 실행은 "사서 들고 있기"** 다. SMA risk-off 오버레이는 +58.6%로 더 나빠졌다(휘프소 + 회전비용). +> +83.4%(Sharpe 0.97, MDD −20%)로 떨어진다. **이 기간·유니버스·비용 가정에서는 초기 매수 후 보유가 +> 월별 재조정보다 성과가 높았다.** 다른 기간이나 정책에 대한 우월성을 뜻하지 않는다. +> SMA risk-off 오버레이는 같은 실험에서 +58.6%였다(휘프소 + 회전비용). > 정직성 메모: 이건 검증 도구가 제대로 작동한 사례다. 좁은 유니버스 결과(+12~27%p)에 흥분하지 > 않고 넓은 유니버스 재현 테스트를 돌렸더니 음(-)으로 뒤집혔다. 좁은 유니버스에서 EW 벤치마크 @@ -27,11 +30,11 @@ | **초기매수 후 보유(회전 0)** | **+132.9%** | **+32.6%** | 1.29 | −23.2% | 진입 수수료만, 가장 좋은 실행 | | 월 리밸런싱(비용 반영) | +83.4% | ~22% | 0.97 | ~−20% | 능동 리밸런싱은 비용이라 수익↓ | -위 두 행이 결론을 그대로 보여준다: **회전을 줄일수록 수익이 좋다.** buy-once-hold가 +132.9%인데 -월 리밸런싱은 +83.4%로 떨어진다. 그래서 권장 운용은 "사서 들고 있기 + 큰 이탈만 교정". +위 두 행에서는 buy-once-hold가 +132.9%, 월 리밸런싱이 +83.4%였다. 이 표본에서 저회전 정책을 +유지할 근거는 있지만, 회전 감소가 항상 수익을 높인다는 결론은 낼 수 없다. 배포 체크리스트: -- [x] 전략 식별 (분산 보유 = 검증된 고수익 베타) +- [x] 비교할 기본 정책 식별 (분산 보유; 향후 고수익이 검증됐다는 의미는 아님) - [x] 실행 경로 동작 (`main.py --mode rebalance --basket kr_diversified_hold` → 실제 주문 생성) - [x] 배포 config (`kr_diversified_hold`, `enabled:false`) - [x] 배포 경로 구조 규명(실측) — 바스켓은 능동 알파 전략과 다른 경로다. paper는 `--dry-run`으로 @@ -83,9 +86,9 @@ buy&hold 바스켓은 능동 알파 전략의 60일 evidence 승격 절차를 ** | 벤치마크 초과수익 | **+12.7%p** | EW B&H를 실제로 이김(베타 아님) | | 초과 Sharpe | **+0.23** | risk-adjusted로도 우위 | | 전체 수익/Sharpe/MDD | +124.7% / 1.30 / −18.2% | 벤치마크 Sharpe 1.07보다 높음 | - | Walk-forward | **6/6 window 양수** (positive·sharpe+ 모두 1.0) | 구간 안정성 | - | OOS holdout (untouched 2025) | **통과**, test Sharpe 2.05 | 선택 과적합 아님 | - | Deflated Sharpe | **DSR 0.985 ≥ 0.95** | 다중검정 운 아님 | + | Walk-forward | **6/6 window 양수** (positive·sharpe+ 모두 1.0) | 당시 조건의 구간 결과 | + | OOS holdout (2025) | 당시 도구 판정 통과, test Sharpe 2.05 | 반복 조회·변형 선택의 영향을 별도 확인해야 함 | + | Deflated Sharpe | 당시 계산 DSR 0.985 ≥ 0.95 | 시행 수·실제 관측수·분포 가정에 의존하며 확증이 아님 | | 회전율 | 194%/년 | 모멘텀(~800~1000%)보다 훨씬 낮음(비용 유리) | | validation_warnings | **없음** | 모든 정직 게이트 통과 | @@ -161,14 +164,14 @@ buy&hold 바스켓은 능동 알파 전략의 60일 evidence 승격 절차를 ** 이 표본에서는 저회전 분산 보유의 성과가 더 좋았습니다. 다른 기간과 자산에서도 같은 결과가 나오는지는 별도의 검증이 필요합니다. **권장(배포 가능한 경로):** -1. **분산 대형주 buy&hold를 기본 운용으로**: 30종목 동일비중 보유 자체가 ~22% CAGR(실행 기준), - Sharpe ~1.0, MDD ~−20%. 능동 리밸런싱·오버레이는 전부 이걸 깎으니, **회전을 최소화**한다 - (연 1회 구성종목 점검 + 대규모 이탈만 교체). 잦은 월 리밸런싱은 비용만 키운다. -2. **낙폭 한도는 포지션·현금 관리로**: SMA risk-off는 휘프소로 더 나빴다. 낙폭 통제가 필요하면 - 전략 신호가 아니라 전체 노출(현금 비중) 상한 같은 단순 규칙이 낫다. +1. **저회전 분산 보유를 비교할 기본 정책으로**: 이 실험의 30종목 조건에서는 약22% CAGR, + Sharpe 약1.0, MDD 약−20%였고 시험한 월별 재조정·오버레이보다 성과가 높았다. + 기본 정책은 유지하되 기업·시장 변화와 큰 비중 이탈을 검토한다. 향후 기대수익의 추정치는 아니다. +2. **낙폭 통제는 노출 정책과 함께 비교**: 이 표본의 SMA risk-off는 휘프소 영향을 받았다. + 현금 비중과 위험 오버레이의 효용은 다른 기간·국면·비용·적립 조건을 포함해 따로 평가한다. **계속 연구할 가치가 있는 것(쉽지 않음):** -3. **다요인 결합**: 단일 팩터는 EW B&H를 못 이긴다. 저변동 선택 + 모멘텀 필터 + quality 결합을 +3. **다요인 결합**: 시험한 단일 팩터는 이 표본의 EW B&H를 못 이겼다. 저변동 선택 + 모멘텀 필터 + quality 결합을 같은 OOS 방식으로 시험(재무 데이터 복구 필요). 4. **생존자 통제 재측정**: pykrx 과거 상장목록 동작 환경에서 `--canonical`. 단 보통 수치를 낮추는 방향이라 alpha를 만들지는 못한다. diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index 3dc3e175..c255852b 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -5,6 +5,8 @@ > **최종 수정**: 2026-07-06 (weekly_report 모드, 사이클 관측성·집계 배치율 감시·성과 귀속 분해 반영 — 한 달 운영 리뷰 후속. 이전: 2026-06-10 guide/health/deploy_check) > **참고**: 전체 아키텍처·지표 공식·전략 상세·시스템 진단은 루트의 `quant_trader_design.md` 참고. +> **2026-10-13 추가**: [시장 관찰·투자 가설·연구 후보 검증](INVESTMENT_RESEARCH_20261013.md). `tools/market_research.py`와 `tools/research_evidence.py`는 주문 경로 밖에서 연구 자료를 관리한다. `/api/research`는 저장 자료만 조회하며 `/api/research/refresh`는 시간 제한이 있는 별도 수집 프로세스를 실행한다. 새 canonical 승격에는 `--research-admission-artifact`로 후보·자본·코드·설정·기간·유니버스가 일치하는 연구 근거가 필요하다. + --- ## 목차 diff --git a/docs/RESEARCH_OPERATIONS_20261013.md b/docs/RESEARCH_OPERATIONS_20261013.md new file mode 100644 index 00000000..a6d4343c --- /dev/null +++ b/docs/RESEARCH_OPERATIONS_20261013.md @@ -0,0 +1,115 @@ +# 공시·수급 자동 수집과 모의운용 검증 — 2026-10-13 + +공시와 수급 자료를 정기적으로 수집하고, 주문 계획부터 체결까지 가격 차이와 비용을 기록하도록 했다. 모의운용 성과는 검증 기준을 미리 정한 뒤 새로 쌓이는 종가로 평가한다. 필요한 관측 기간이 지나기 전에는 수익성이 검증됐다고 판단하지 않는다. + +## 자동 실행과 저장 + +- 기존 일일 `main.py --mode rebalance`의 주문·종가 처리 이후, 상시 스케줄러의 장마감 처리 이후에 별도 연구 프로세스를 시작한다. `--dry-run`에서는 시작하지 않는다. +- `core/research_operations.py`가 가격, 공시·수급, 모의 검증 도구를 각각 제한 시간 안에서 실행한다. 중복 실행을 막고, 장중과 장마감 수집을 구분해 오전 수집 때문에 오후 작업이 빠지지 않게 했다. 같은 구간에서 실패한 작업을 자동으로 재시도할 때는 60분 간격을 둔다. +- 일부 수집 실패·갱신 지연은 상태 JSON뿐 아니라 CLI 종료 코드 1로도 알려 외부 실행기가 성공으로 오인하지 않게 한다. +- 공급자 지연이 주문 처리를 기다리게 하지 않는다. 각 작업의 시작·종료·실패·자료 상태를 `data/research_operations/`에 기록한다. 자식 프로세스가 중단되거나 기록이 오래되면 화면에서 확인할 수 있다. +- `config/research_operations.yaml`에서 활성화·제한 시간·재시도·여유 공간 기준을 관리한다. 저장 위치의 여유가 256MiB 미만이면 수집을 보류한다. `QUANT_RESEARCH_AUTOMATION_DISABLED=true`는 자동 수집을 끈다. +- 기존 운영 프로세스의 연결 외에, 아래 일일 종가·수집 점검을 별도로 사용한다. PC가 꺼져 있거나 실행 앱이 꺼져 있으면 로컬 자료는 쌓이지 않는다. 별도 OS 예약 작업을 설치하거나 새 전략의 매매를 켜지 않는다. + +## 공시와 수급 + +`core/market_intelligence.py`와 `tools/market_intelligence.py`는 현재 관찰 대상으로 설정한 국내 주식 9종목을 수집한다. + +**공시:** OpenDART 기업 고유번호와 공시 목록을 조회한다. 페이지 수·행 수·대상 회사를 검증하며, 정상적인 공시 없음과 인증 실패·시간 초과·일부 페이지만 수집된 상태를 구분한다. 접수번호, 보고서명, 접수일, 정정·철회 표시와 원문 링크를 저장한다. 접수일만으로 정확한 발표 시각이나 정정 전 본문을 만들지 않는다. 현재 구현의 원문 보존 범위는 API 응답 필드이며 공시 본문 ZIP 전체 보관은 포함하지 않는다. [OpenDART 공시검색](https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019001) + +**수급:** NAVER 증권 공개 웹 응답의 개인·기관·외국인 순매수 **수량(주)** 을 보관한다. 당일 값을 제외한 최근 완료 거래일을 대상으로 한다. 거래대금, KRX 계약 원본, 시장 전체 수급, 거래소별 집계 범위로 확대 해석하지 않는다. 공개 웹 인터페이스 변경 시 정상 자료로 대체하지 않고 수집 실패로 표시한다. + +각 기록에 공급자의 기준일과 시스템이 실제로 본 시각을 별도로 둔다. 같은 자료의 내용 변경은 새 버전으로 보존한다. `output/market_intelligence/latest.json`은 최신 상태, `snapshots/`와 `versions/`는 관측 근거다. 이 두 원장은 장기 검증에 필요한 자료이므로 임시 캐시로 삭제하지 않는다. + +실수집에서 수급 **9/9종목 × 4거래일 = 36행**을 확인했다. 현재 DART 설정 키에는 서버가 **010(미등록 키)** 을 반환했다. 사용자 결정에 따라 키는 나중에 갱신하며, 인증 실패가 있는 현재 전체 상태는 `partial`이다. `.env`의 `DART_API_KEY`를 유효한 키로 바꾼 뒤 수집 도구를 다시 실행하면 된다. 키 원문은 보고서·로그·명령 인자로 출력하지 않는다. + +## 체결비용 관측 + +`core/execution_analytics.py`를 바스켓 주문 계획, 실제 주문 실행, 주문 상태 저장과 체결 재조회에 연결했다. `execution_cost_events`는 관측 이벤트를 추가하는 테이블이다. 보고 도구는 SQLite를 읽기 전용으로 열며, 누락된 테이블을 조회 과정에서 만들지 않는다. 새 테이블은 기존 운영 초기화·마이그레이션 경로에서 생성된다. + +- 바스켓 계획 가격·시각이 있으면 그 기준을 전달한다. 다른 호출은 실행부에 입력된 가격이라는 사실을 명시한다. 옛 주문의 의사결정 가격을 추정해 채우지 않는다. +- 매수 비용은 `(체결가 − 기준가) × 체결수량`, 매도 비용은 부호를 반대로 계산한다. 가격 비용과 수수료·세금을 구분하고, 체결가에 이미 반영된 슬리피지를 다시 더하지 않는다. +- 부분 체결과 재조회는 주문의 최신 누적 상태로 계산하므로 같은 체결을 여러 번 합산하지 않는다. 거부·미체결 시도도 관측한다. +- 통화, 모의 장부·증권사 환경, 기준가격 종류, 체결가격 근거, 비용 근거를 나눠 집계한다. 증권사 체결가격을 확인했더라도 수수료·세금이 추정치라면 전체 비용을 실측이라고 표시하지 않는다. +- 관측 시각은 로컬 시스템의 시각이다. 독립적인 주문 도착가격, 거래소의 정확한 체결 시각, 잔량 평가 종료가격을 모두 확보한 완전한 비용 분해는 아니다. 미체결 잔량의 기회비용은 `null`로 남긴다. + +이 구분은 결정가격 대비 체결·지연·기회비용을 구별하는 [CFA의 거래 집행 기준](https://www.cfainstitute.org/insights/professional-learning/refresher-readings/2026/trade-strategy-execution)을 따른 설계다. 현재 확보하지 못한 항목까지 측정했다고 주장하지 않는다. + +## 장기 모의 검증 + +`config/paper_validation.yaml`에 검증 기준을 먼저 고정한다. 기본 요건은 **동일 거래일 비교가 가능한 일간 수익률 120개, 관측 기간 180일, 비교지수 상승일·하락일 각각 20개, 결측 비율 5% 이하**다. 연구 표본의 최소 요건이며 수익성 합격 기준이나 실계좌 승인 조건이 아니다. + +`core/paper_validation.py`는 활성 바스켓별 운용 코드·설정·노출 정책·비교지수 지문과 등록 시각을 추가형 원장에 기록한다. 기존 스냅샷·종가를 새 전진 검증 일수로 바꾸지 않는다. 운용 정책이나 코드가 바뀌면 과거 기록을 보존하고 새 검증 묶음을 시작한다. 화면·문서만 변경한 경우에는 검증 기간을 다시 시작하지 않는다. + +- 등록 후 실제 측정된 종가만 인정하며, 뒤늦은 대량 소급 입력으로 누락을 지우지 못하도록 수집 지연을 제한한다. +- 같은 날짜의 비교지수와 입출금 조정 수익률, 관측 최대낙폭, 상승·하락일 성과, 비용, 회전, 결측을 함께 기록한다. +- 저장된 TWR은 입출금을 구간 시작에 반영하는 장부 근사치다. 정확한 현금흐름 시점별 평가를 요구하는 [GIPS 수익률 원칙](https://www.gipsstandards.org/standards/gips-standards-for-firms/gips-standards-handbook-for-firms/)과 차이가 있으며 GIPS 준수·인증 성과로 부르지 않는다. +- 비교지수는 배당 제외 가격지수다. 현금·방어 자산 비중이 다른 계좌와의 수익률 차이를 위험조정 알파로 부르지 않는다. 비용은 NAV에 이미 반영돼 재차 차감하지 않는다. +- 읽기 전용 장부의 과거 행이 수정·누락되거나 자료가 늦어지면 무결성 문제로 표시한다. 자료 부족이나 표본 요건 충족이 자동 실계좌 승격으로 이어지지 않는다. + +## 실행 명령 + +프로젝트 루트 PowerShell에서 실행한다. 수집 도구는 운영 장부에 주문이나 입출금을 기록하지 않는다. + +```powershell +# 전체 관측 작업: 같은 날 재시도까지 직접 요청하려면 --force +.venv/Scripts/python.exe tools/research_operations.py --force +.venv/Scripts/python.exe tools/research_operations.py --status + +# 개별 수집·조회 +.venv/Scripts/python.exe tools/market_intelligence.py +.venv/Scripts/python.exe tools/paper_validation.py collect +.venv/Scripts/python.exe tools/paper_validation.py status --json +.venv/Scripts/python.exe tools/execution_cost_report.py --mode paper --limit 200 +``` + +`QUANT_MARKET_INTELLIGENCE_DIR`, `QUANT_PAPER_VALIDATION_DIR`, `QUANT_RESEARCH_OPERATIONS_DIR`로 관측 저장 위치를 격리할 수 있다. 체결비용은 `--db`, 모의 검증은 `--db-path`로 **기존 DB 사본**을 지정할 수 있다. 대시보드의 시장 관찰 아래에서 자동 수집·공시·수급·체결비용·장기 검증 상태를 읽는다. + +## 검증 기록 + +- Python 통합 회귀 **919개 통과**. 기존 `pandas_ta`의 pandas 설정 폐기 예고 1건만 있었으며 실패는 없었다. 마지막 YAML 오류 처리 수정은 모의 검증·연구 원장 **47개 재검사**로 확인했다. JavaScript **37개 통과**. 통합 로그는 `output/research_operations_review_20261013/`에 보존한다. +- 실제 자동 수집 경로에서 가격 관찰, 공시·수급, 모의 검증을 순차 실행했다. 수급 36행을 저장했고 DART 인증 실패는 전체 `attention` 및 공시 `unavailable`로 남았다. 수집 전후 운영 DB SHA-256은 동일했다(`source-integrity.json`). +- 두 바스켓의 전진 검증을 2026-10-13 12:20 KST에 등록했다. 기존 스냅샷 84개·62개는 새 성과에서 제외하며 현재 새 수익률 관측은 각각 **0/120**이다. 아직 장기 성과 검증이 완료된 상태가 아니다. +- 고정된 운영 DB 사본에서 실제 계획→실행 경로를 확인했다. 모의 매수 1주의 계획가 101,605원, 체결가 101,656원, 가격 비용 51원, 수수료 15원으로 총 66원(약 6.50bp)이 기록됐다. 낙폭 제한 주문 1건과 장외 주문 4건은 차단됐다. 모의 체결이므로 실제 증권사 체결 품질의 증거로 사용하지 않는다. 근거는 `output/execution_cost_review_20261013/`에 있다. +- 데스크톱 1440px·모바일 390px에서 실제 저장 자료 표시와 가로 넘침 없음을 확인했다. 최종 화면 두 장은 `output/playwright/operations-desktop.png`, `operations-mobile.png`에 보존했다. 검증 서버와 브라우저는 종료했다. +- 마지막 CLI 실패 종료 처리 **10개 검사 통과**, 테스트 임시 폴더 격리·종료 정리 **실제 자식 프로세스 검사 2개 통과**. 사용자가 지정한 경로는 삭제 대상이 아니며, 해당 실행에서 만든 경로만 정리한다. +- 사용이 끝난 중간 DB·임시 검증 자료·브라우저 중간 기록과 미사용이 확인된 3월 생성 로그 4개를 합계 **101,521,799바이트** 정리했다. 공용 캐시와 불명확한 로그의 일괄 삭제는 자동 승인 검토에서 차단되어 실행하지 않았으며, 이후 확인된 개별 경로만 삭제했다. 실제 잔여 공간은 별도 측정값으로 기록한다. + +디스크 부족으로 쓰기가 실패했던 주문 파일은 기존 소스와 장외 바스켓 주문 가드를 복구한 뒤 회귀 검증했다. 새 수집 저장은 원자적 게시와 변경 전 기록 보존을 사용한다. 임시 검증 자료는 최종 근거만 남기며, 다른 작업의 여유 공간 변동은 이 작업의 정리 실적으로 계산하지 않는다. + +## 실제 일일 운영 연결 후속 점검 + +2026-10-13의 실제 운영 원장을 읽기 전용으로 확인했다. 오전 10:07 리밸런싱과 11:00·14:00 결측 재시도를 위한 기존 Claude 예약 지침은 있지만, 종가 확정 후의 전용 기록 예약은 없었다. 당일 장중 스냅샷은 두 바스켓에 84개·62개 있으나 `portfolio_valuations`는 0행이었다. 이전 수집 연결만으로 종가 성과가 누적된다고 볼 수 없었다. + +### 수정한 누락 경로 + +- 오전 수집 성공이 같은 날의 장마감 수집을 막지 않도록 장중/마감 확정 후/휴장일 실행을 구분했다. DART 실패 때 이미 준비된 가격 자료를 반복 다운로드하지 않는다. +- 저장 자료가 주말·연휴 동안 36시간을 넘었다는 이유만으로 늦었다고 판단하지 않는다. 실제 필요한 거래일과 마감 후 실행 여부를 확인한다. 레이더·수급의 당일 잠정 값 제외 정책은 유지하므로 다음 날 아침에도 수집한다. +- `tools/collect_basket_closes.py`는 매매 없이 paper 계좌의 실제 원장과 정확한 종가로 별도 평가를 추가한다. 최초 종가를 새 수익률의 기준으로 삼으며 과거 장중 스냅샷을 종가로 꾸미지 않는다. 장전·장중·휴장일에는 DB를 읽기 전용으로 확인하고 직전 완료 거래일의 종가 누락을 별도로 보고한다. 현재 보유 수량만으로 놓친 과거 종가를 자동 재구성하지 않는다. +- 실제 조회에서 FDR/Yahoo의 KOSPI·KOSPI 200 최신 기준일이 실패했다. 검증 전용 비교지수를 NAVER 공개 지수 자료로 고정하고, 공급자와 코드 지문을 검증 기준에 포함했다. 공급자 응답에 오늘 잠정 값이 있어도 날짜·마감 확정 시각 검사를 통과한 값만 관측한다. NAVER 웹 자료이며 KRX 계약 원본으로 표시하지 않는다. +- `tools/daily_observation.py`가 종가 기록 → 연구 자료 수집 → 모의 검증 순서로 실행한다. 한 단계 실패가 다른 단계의 확인을 막지 않으며, 중복 실행 잠금과 전체 15분 제한을 둔다. 시간 초과 시 이번 실행이 만든 자식 프로세스 트리까지 종료한다. 저장 공간이 256MiB 미만이면 수집을 보류한다. + +### 실행과 점검 + +```powershell +# 매매 없이 종가 기록과 자료 수집을 함께 실행 +.venv/Scripts/python.exe tools/daily_observation.py +# 저장된 상태만 확인: DB 변경·네트워크 수집 없음 +.venv/Scripts/python.exe tools/daily_observation.py --status +# 장마감 기록만 실행하거나 상태 확인 +.venv/Scripts/python.exe tools/collect_basket_closes.py --json +.venv/Scripts/python.exe tools/collect_basket_closes.py --status --json +``` + +통합 실행 기록은 `data/daily_observation/latest.json`과 `runs/`에 남긴다. 대시보드는 종가 기록 성공, 마감 대기, 직전 종가 누락, 전체 수집 상태를 분리해 표시한다. `QUANT_DAILY_OBSERVATION_DIR`로 검증 실행을 격리할 수 있다. 기존 매매·입출금·위험 한도는 이 도구에서 변경하지 않는다. + +로컬 일정은 컴퓨터와 앱이 실행 중이어야 작동한다. [OpenAI 공식 예약 작업 안내](https://learn.chatgpt.com/docs/automations?surface=app) + +### 적용 및 검증 결과 + +- 운영 PC의 Codex에 **바스켓 종가·자동 수집 일일 점검**을 등록했다. 한국 시간 매일 **08:30, 16:30, 18:30**에 실행한다. 아침에는 전일 자료, 장마감에는 당일 종가, 저녁에는 누락 여부를 확인한다. 첫 종가 기록, 새 오류, 오류 복구, 직접 조치가 필요한 경우에만 작업 채팅으로 알린다. 이 일정은 해당 PC의 로컬 설정이며, 저장소를 내려받는 것만으로 등록되지는 않는다. 기존 Claude 매매 예약은 수정하지 않았다. +- 13:22~13:23의 실제 운영 실행에서 종가 기록은 `previous_close_missing`으로 직전 거래일의 누락 2건을 표시했다. 장중에 가짜 종가를 만들지 않았고 운영 DB SHA-256도 동일했다. 가격 레이더는 재다운로드 없이 재사용, 수급은 갱신, DART 010은 실패, 모의 검증은 정상 실행으로 각각 구분됐다. +- 운용 코드와 비교지수 공급자가 바뀌어 기존 검증 묶음을 보존하고 **13:23 KST에 새 묶음 두 개를 사전등록**했다. 등록 시 종가가 0건이므로 가격 조회 없이 기준만 고정했다. 아직 실제 종가 관측은 0개이며 오늘 첫 기록의 운영 확인은 16:30 실행 이후다. +- 현재 운영 원장의 읽기 전용 사본에서 두 바스켓의 종가 2행 생성, 반복 실행 0행, 실제 주문·입출금·포지션·장중 스냅샷 무변경을 확인했다. 저장된 종가를 원장 대조 검증기까지 통과시켰다. 종가·시각은 사본 검증용 고정값이므로 실제 시장 성과로 사용하지 않는다. 근거: `output/basket_close_review_20261013/result.json`. +- 관련 Python 회귀 **296개**, JavaScript **38개** 통과. 실제 NAVER 비교지수 경로도 두 지수의 10월 12일 정확한 날짜 조회에 성공했다. 소유한 손자 프로세스까지 시간 초과 시 종료되는 검증도 포함했다. 근거: `output/daily_observation_review_20261013/`. +- 이번 후속 점검에서 사용이 끝난 중간 검증 자료·임시 DB·테스트 파일 **4,930,759바이트**를 삭제했다. 마지막 측정 여유 공간은 **3,406,086,144바이트(약 3.41GB)**이며, 작업 중 발생한 전체 디스크 공간 변동은 정리 실적에 포함하지 않았다. 추가 `pytest-200` 임시 폴더 삭제는 자동 승인 검토에서 정책상 차단되어 실행하지 않았다. 삭제 내역과 실제 남은 공간은 `cleanup.json`, 전체 검증 요약은 `verification.json`에 기록했다. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4e44e364..cac6cc25 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -36,10 +36,31 @@ 종목마다 보유 수량, 평균 매수가, 매수한 금액, 비중이 나옵니다. 지금 비중과 목표 비중을 나란히 비교할 수 있습니다. -이 표의 비중은 **매수한 금액 기준**입니다. 현재 가격으로 계산한 보유 주식의 금액과 손익은 표 아래에 따로 표시합니다. +이 표의 비중은 저장된 **평가 시점의 시장가격 기준**입니다. 가격이나 보유 수량이 맞지 않으면 미확인으로 표시합니다. 목표와의 차이, 최소 1주 수량 등 주문을 보류한 이유도 확인할 수 있습니다. [보유 종목 화면 크게 보기](images/readme-holdings-20260922.png) +## 시장 관찰 + +상단 **시장 관찰**에서 바스켓 구성 종목과 업종 ETF를 비교합니다. **시장 자료 갱신**을 누르면 공개 가격을 새로 수집합니다. 최대 2분 정도 걸릴 수 있으며 화면을 열어 두기만 하면 저장 자료를 읽습니다. + +- **관찰 대상**에서 업종 ETF 또는 바스켓 구성 종목을 고릅니다. 바스켓 설정에 있다는 표시는 실제 보유를 뜻하지 않습니다. +- **상대 강도 정렬**은 20·60·120거래일 중 고른 기간의 KODEX 200 대비 수익률 차이입니다. +5는 비교지수보다 5%p 높았다는 뜻입니다. +- **60일 평균선 위**는 확인 가능한 관찰 종목 중의 비율이며 전체 시장의 상승 종목 비율이 아닙니다. +- **거래량 배수**는 직전 거래일 거래량을 그 이전 20일 평균과 비교합니다. 변동성은 최근 20일 수익률의 연율화 표준편차입니다. + +당일 봉은 사용하지 않습니다. 수집 시각과 종가 기준일을 따로 확인하세요. 지연·누락·가격 기준 미확인 자료는 순위에서 제외합니다. 공급자의 배당 조정 방식은 다를 수 있으며 수수료·세금·실제 체결 비용을 반영한 수익률이 아닙니다. + +아래 **검토 중인 투자 가설**에는 사람이 원문을 확인해 등록한 가설과 반증 조건, 출처, 검토일이 나옵니다. `확인 부족`은 공개 시각 등 근거가 불충분하다는 뜻이며, `검토 중`도 가설이 입증됐다는 뜻은 아닙니다. 지나간 기업 이벤트는 결과 검토 대상으로 바뀝니다. + +관찰 순위와 투자 가설은 자동 주문을 만들지 않습니다. 등록·재현 방법과 조사 출처는 [전문 운용 조사 보고서](INVESTMENT_RESEARCH_20261013.md)를 참고하세요. + +아래 **공시·수급 자동 수집**은 일일 운영 뒤의 수집 상태와 최근 원문을 보여줍니다. 수급은 NAVER 제공 종목별 순매수 수량(주)입니다. DART 인증 오류가 있으면 `.env`의 유효한 키 설정이 필요하며, 공시가 없는 날과 수집 실패를 구분해서 표시합니다. + +**장기 모의운용 검증**의 종가 기록 상태도 확인하세요. 오전 자산 스냅샷만으로는 종가 성과가 누적되지 않습니다. `tools/daily_observation.py`는 장마감 이후 종가 평가를 기록한 다음 자료 수집·성과 검증을 실행합니다. 장전·장중에는 직전 완료 거래일의 종가 유무만 확인하며, 빠진 과거 종가를 현재 보유량으로 만들어 채우지 않습니다. 수급·레이더는 당일 잠정 값을 제외하므로 다음 날 아침 수집으로 직전 거래일까지 확인합니다. [실제 일일 운영 연결과 점검 명령](RESEARCH_OPERATIONS_20261013.md#실제-일일-운영-연결-후속-점검) + +**체결비용 관측**의 양수는 기준가격보다 불리한 체결입니다. 모의 체결·증권사 환경·수수료 추정 여부를 함께 확인하세요. **장기 모의운용 검증**은 등록 이후 자료만 누적하며 기본 표본 요건은 120거래일 수익률, 180일 기간, 상승/하락 각각 20일입니다. 표본 요건 충족은 수익성 합격 판정이 아닙니다. [실행과 검증 기준](RESEARCH_OPERATIONS_20261013.md) + ## 적립금 기록 모의투자에 쓸 가상 자금을 추가하는 기능입니다. diff --git a/main.py b/main.py index 7637daa2..b46b5b99 100644 --- a/main.py +++ b/main.py @@ -707,6 +707,11 @@ def _market_closed_today(config, now) -> bool: return False +def _basket_session_open(config, now): + from core.trading_hours import TradingHours + return TradingHours(config).can_place_order(now)["allowed"] + + def _run_rebalance_impl(args): """바스켓 포트폴리오 리밸런싱 모드.""" from datetime import datetime @@ -787,22 +792,21 @@ def _run_rebalance_impl(args): ) cycle_snapshots_saved = 0 - # 휴장일 실행은 스냅샷(직전 거래일 날짜로 저장)과 빠진 날 채우기만 하고 매매는 하지 않는다. - # 휴장일의 '현재가'는 직전 거래일 종가라, 그 가격으로 체결을 남기면 장이 닫힌 - # 날의 가짜 체결이 트랙레코드와 비용 통계에 섞인다. 평일 휴장(추석 등)에도 - # 일일 태스크(월~금 10시)는 돈다. 판정 실패 시 평소대로 진행한다(1일 1매매 - # 가드와 같은 원칙 — 방어선이지 게이트가 아니다). - kst_now = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None) + # 장외 진단·종가 수집은 가능하지만 paper/live 체결은 정규장에만 한다. + # force는 당일 중복 체결 가드만 우회하며 거래 시간을 우회하지 않는다. + from core.trading_hours import _now_kst + kst_now = _now_kst().replace(tzinfo=None) market_closed_today = _market_closed_today(config, kst_now) - holiday_skip = market_closed_today and not dry_run and not force_rebalance + holiday_skip = not dry_run and (market_closed_today or not _basket_session_open(config, kst_now)) + from core.trading_hours import TradingHours + pre_open = not market_closed_today and kst_now.time() < TradingHours(config).market_open if holiday_skip: logger.info( - "오늘({})은 휴장일 — 리스크 청산·리밸런싱 매매를 건너뛰고 스냅샷만 남긴다 " - "(우회: --force-rebalance)", kst_now.date(), + "현재({})는 정규장 밖 — 리스크 청산·리밸런싱 체결 보류", kst_now, ) record_cycle_event( "REBALANCE_SKIPPED_MARKET_CLOSED", - f"휴장일({kst_now.date()}) 실행 — 매매 생략, 스냅샷만 기록", + f"정규장 밖({kst_now}) 실행 — 매매 생략", mode=mode, ) @@ -817,6 +821,15 @@ def _run_rebalance_impl(args): account_key=live_strategy_name, execution_strategy=live_strategy_name, ) + if not dry_run and mode == "paper": + try: + from core.basket_close import finalize_basket_closes + finalized = finalize_basket_closes(rebalancer, now=kst_now) + if finalized: + logger.info("바스켓 '{}' 종가 평가 추가: {}", name, finalized) + except Exception as close_exc: + record_event_once_per_day("CLOSE_VALUATION_FAILED", f"바스켓 '{name}' 종가 평가 실패: {close_exc}", + strategy=live_strategy_name, mode=mode) if mode == "live" and not dry_run: sync_result = rebalancer.portfolio_mgr.sync_with_broker() @@ -949,8 +962,7 @@ def _run_rebalance_impl(args): executed = False if holiday_skip: logger.info( - "바스켓 '{}' 휴장일 — 리밸런싱 매매 건너뜀 " - "(스냅샷은 직전 거래일 날짜로 저장, 우회: --force-rebalance)", name, + "바스켓 '{}' 정규장 밖 — 리밸런싱 체결 보류", name, ) elif already_traded_today: logger.info( @@ -1008,7 +1020,7 @@ def _run_rebalance_impl(args): # 트랙레코드: 거래 여부와 무관하게 바스켓 계정의 일일 NAV 스냅샷을 남긴다. # 보유 종목 가격이 전부 확보된 경우에만 저장(가짜 NAV 방지), 멱등 upsert. - if not dry_run: + if not dry_run and not pre_open: snapshot_saved = rebalancer.save_daily_nav_snapshot() # 사이클 관측·경보: 스냅샷 저장/스킵을 기록하고, 최근 영업일 결측을 # 당일 경보로 노출한다(6/26류 조용한 누락 재발 방지). best-effort. @@ -1155,11 +1167,8 @@ def _run_rebalance_impl(args): finally: _sess.close() if len(_snaps) >= 3: - _flows = account_flows_by_snapshot( - live_strategy_name, _snaps, mode=ledger_mode, - ) _daily = daily_returns_from_nav( - [(s.date, s.total_value) for s in _snaps], flows=_flows, + account_performance_points(live_strategy_name, _snaps, mode=ledger_mode), ) report_card["risk"] = format_risk_line( risk_metrics([r for _, r in _daily]) @@ -1199,6 +1208,11 @@ def _run_rebalance_impl(args): except Exception as e: logger.warning("리밸런싱 후 DB 백업 실패: {}", e) + # 주문과 종가 기록을 마친 뒤 별도 프로세스에서 관측 자료를 수집한다. + # 공급자 지연은 주문 루프를 막지 않으며 결과·중단은 대시보드에 남긴다. + from core.research_operations import launch_research_operations + launch_research_operations("rebalance") + def run_paper_trading(args): """페이퍼 트레이딩 모드 실행""" @@ -2111,7 +2125,6 @@ def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: # 설계가 굴러가지 않는다(잔고가 1주 단위를 못 넘겨 배치율이 수렴 불가). contribution_notes: list[str] = [] try: - from core.operator_health import summarize_contribution_plan from database.repositories import get_cash_flows for name in enabled_baskets: @@ -2123,9 +2136,6 @@ def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: # get_cash_flows는 (occurred_at, amount) 튜플 목록을 준다 — 객체가 # 아니다. getattr로 읽으면 항상 None이 잡혀 '입금 0건'으로 오판한다. flows = get_cash_flows(account_key=key, mode="paper") - last_flow = max( - (f[0] for f in flows if f and f[0] is not None), default=None, - ) # 트랙 개시일 = 이 계정의 첫 스냅샷. 개시 직후에는 아직 적립 시점이 # 오지 않았을 수 있으므로 판정에 필요하다. sess = get_session() @@ -2142,16 +2152,22 @@ def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: finally: sess.close() first_snap = row[0] if row else None - plan_state = summarize_contribution_plan( - name, plan, last_flow, first_snap, date.today(), - ) - if plan_state["note"]: - contribution_notes.append(plan_state["note"]) + from core.basket_observability import monthly_contribution_status + plan_state = monthly_contribution_status(plan, flows, first_snap, date.today()) + contribution_notes.extend(f"바스켓 '{name}' {note}" for note in plan_state["issues"]) except Exception as plan_exc: logger.warning("적립 계획 점검 실패: {}", plan_exc) # 위험 관리(오버레이) 입력 문제: 마지막 사이클이 남긴 상태 파일의 data_issues. data_notes: list[str] = [] + from core.basket_observability import read_basket_diagnostics + for name in enabled_baskets: + try: + diag = read_basket_diagnostics(baskets_cfg.get(name) or {}, rebalance_live_strategy_id(name), config.risk_params) + data_notes.extend(f"바스켓 '{name}' {note}" for note in diag["entry_status"]["reasons"]) + data_notes.extend(f"바스켓 '{name}' {note}" for note in diag["allocation"]["issues"]) + except Exception as diag_exc: + data_notes.append(f"바스켓 '{name}' 자산별 배분·진입 상태 확인 실패: {diag_exc}") try: from core.risk_overlays import load_overlay_state, parse_overlay_config @@ -2276,6 +2292,13 @@ def account_flows_by_snapshot(account_key: str, snapshots, mode: str = "paper") return out +def account_performance_points(account_key, snapshots, mode="paper"): + """Deposited accounts use the persisted linked index, not day-bucketed flows.""" + from database.repositories import has_cash_flows + linked = has_cash_flows(account_key, mode=mode) + return [(s.date, 1 + float(s.cumulative_return) / 100 if linked else float(s.total_value)) for s in snapshots] + + def _fetch_benchmark_closes(start, end) -> dict: """벤치마크(KS11) 종가를 {date: close}로. 실패하면 빈 dict. @@ -2283,9 +2306,7 @@ def _fetch_benchmark_closes(start, end) -> dict: 여러 날 구간이 되므로, 벤치마크도 같은 구간으로 다시 계산해야 비교가 성립한다 (core.performance_lens.aligned_returns 참고). - NAV는 10:07 장중 마크라 지수 종가(15:30)와 비교하면 하루의 절반가량이 어긋나 - 포착률이 양쪽 다 0 쪽으로 쏠린다. 시가(09:00)가 기록 시각에 훨씬 가깝다 — - 완전히 맞출 수는 없으니 결과는 근사로 표기한다. 시가가 없으면 종가를 쓴다. + 별도 종가 평가 장부와만 비교한다. 장중 NAV를 시가·종가에 억지로 맞추지 않는다. """ from datetime import timedelta @@ -2297,7 +2318,7 @@ def _fetch_benchmark_closes(start, end) -> dict: df = fdr.DataReader("KS11", s.isoformat(), e.isoformat()) if df is None or df.empty: return {} - col = "Open" if "Open" in df.columns else ("Close" if "Close" in df.columns else None) + col = "Close" if "Close" in df.columns else None if col is None: return {} return { @@ -2391,23 +2412,8 @@ def _d(v): ) ref_val = float(ref.total_value) if ref_val > 0: - from core.portfolio_manager import twr_period_return - from database.repositories import get_cash_flow_total_between - flow = 0.0 - try: - ref_boundary = ref.created_at or ref.date - last_boundary = snaps[-1].created_at or now_kst - flow = get_cash_flow_total_between( - key, - ref_boundary, - last_boundary, - mode="paper", - ) - except Exception as flow_exc: - logger.warning("주간 변화 계산 중 입금 조회 실패 — 주간 항목 생략: {}", flow_exc) - ref_val = 0.0 - if ref_val > 0: - week_change = twr_period_return(ref_val, last_val, flow) * 100 + points = account_performance_points(key, [ref, snaps[-1]]) + week_change = (points[-1][1] / points[0][1] - 1) * 100 if points[0][1] > 0 else None # 결측은 '고유 일수'로 집계(SNAPSHOT_GAP 이벤트는 미복구 결측을 매 사이클 # 재경보해 부풀려짐 → 최근 7일 실제 빠진 영업일 수를 직접 센다). @@ -2438,32 +2444,35 @@ def _d(v): # 스냅샷의 daily_return 열이 아니라 NAV 시계열에서 직접 뽑는다 — # 그 열은 2026-08-10 이전 전 구간이 0.0이라(값을 안 넘기던 버그) # 그대로 쓰면 변동성이 0으로 깔려 없는 안정성을 주장하게 된다. - nav_points = [(s.date, s.total_value) for s in snaps] - # 입금은 수익이 아니다 — 날짜별 유입을 넘겨 분모에서 중화한다. - # 안 넘기면 적립 하루가 통째로 수익률로 잡힌다: 2026-08-26 kr_pocket에 - # 10만원을 넣자 NAV가 284,499 → 385,460이 되면서 그날이 +35% 수익으로 - # 계산돼 연환산 변동성 109%, 샤프 +2.28이라는 허구가 나왔다. - # 적립식 트랙은 이 경로가 상시라 한 번 새면 계속 샌다. - flows = account_flows_by_snapshot(key, snaps, mode="paper") - - daily = daily_returns_from_nav(nav_points, flows=flows) + nav_points = account_performance_points(key, snaps) + # 적립 계정은 같은 날 여러 측정의 경계를 보존한 누적 TWR 지수를 쓴다. + daily = daily_returns_from_nav(nav_points) risk = risk_metrics([r for _, r in daily]) - if len(nav_points) >= 2: + from database.repositories import get_portfolio_valuations + close_rows = get_portfolio_valuations(key, "paper", kind="close") + by_day = {v["date"][:10]: v for v in close_rows} + close_points = [(datetime.fromisoformat(day), 1 + v["cumulative_return"] / 100) for day, v in sorted(by_day.items())] + regime_note = "비교 가능한 종가 평가 2일 이상 필요 — 장중 기록은 종가와 비교하지 않음" + if len(close_points) >= 2: closes = _fetch_benchmark_closes( - nav_points[0][0], nav_points[-1][0], + close_points[0][0], close_points[-1][0], ) if closes: pairs = [ (m, b) - for _d, m, b in aligned_returns(nav_points, closes, flows=flows) + for _day, m, b in aligned_returns(close_points, closes) ] if pairs: regime = split_by_regime(pairs) - regime_note = "근사치 — 지수 시가 기준" + regime_note = "별도 종가 평가 · KS11 종가 기준" last_bench = max(closes) - last_nav = _d(nav_points[-1][0]) + last_nav = _d(close_points[-1][0]) if last_bench < last_nav: regime_note += f", 지수 자료 {last_bench}까지만 반영" + else: + regime_note = "종가 평가와 같은 날짜의 KS11 자료 부족" + else: + regime_note = "KS11 종가 자료 없음 — 비교 보류" except Exception as lens_exc: logger.warning("국면/리스크 지표 생략: {}", lens_exc) diff --git a/monitoring/operations_dashboard.py b/monitoring/operations_dashboard.py new file mode 100644 index 00000000..635e08cf --- /dev/null +++ b/monitoring/operations_dashboard.py @@ -0,0 +1,30 @@ +"""Read-only evidence desk for collection, execution costs and forward paper runs.""" +import asyncio +from aiohttp import web + + +def get_operations_json(): + from core.research_operations import read_research_operations + result = {"research_only": True, "operations": read_research_operations()} + readers = { + "daily_observation": ("core.daily_observation", "read_daily_observation"), + "intelligence": ("core.market_intelligence", "read_market_intelligence"), + "validation": ("core.paper_validation", "read_paper_validation"), + "execution": ("core.execution_analytics", "execution_cost_report"), + } + import importlib + for key, (module, function) in readers.items(): + try: + reader = getattr(importlib.import_module(module), function) + result[key] = reader() + except Exception: + result[key] = {"status": "unavailable", "issues": ["저장 자료를 읽을 수 없습니다."]} + return result + + +async def handle_operations(_request): + return web.json_response(await asyncio.to_thread(get_operations_json)) + + +def register_operations_routes(app): + app.router.add_get("/api/research/operations", handle_operations) diff --git a/monitoring/research_dashboard.py b/monitoring/research_dashboard.py new file mode 100644 index 00000000..90aed8c0 --- /dev/null +++ b/monitoring/research_dashboard.py @@ -0,0 +1,117 @@ +"""Read-only research view; explicit refresh collects prices in a bounded child. + +This module does not import the trading executor or initialize a database. Opening +the dashboard reads saved research; only a refresh request contacts data providers. +""" +from __future__ import annotations + +import asyncio +from pathlib import Path +import sys +import time + +from aiohttp import web +from loguru import logger + +ROOT = Path(__file__).resolve().parent.parent + + +def get_research_desk_json() -> dict: + from core.market_research import read_market_research + from core.research_evidence import read_evidence_journal + + # A broken research file must not hide the other half of the desk. + try: + market = read_market_research() + except Exception: + logger.warning("시장 관찰 자료 읽기 실패") + market = {"status": "unavailable", "issues": ["저장 자료를 읽을 수 없습니다."]} + try: + evidence = read_evidence_journal() + except Exception: + logger.warning("투자 가설 자료 읽기 실패") + evidence = {"status": "ATTENTION", "entries": [], "issues": ["투자 가설 기록을 읽을 수 없습니다."]} + return {"research_only": True, "market": market, "evidence": evidence} + + +async def run_research_refresh() -> None: + """Kill a stuck vendor fetch without blocking the web server or trading loop.""" + process = await asyncio.create_subprocess_exec( + sys.executable, str(ROOT / "tools" / "market_research.py"), + cwd=str(ROOT), stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, + ) + try: + code = await asyncio.wait_for(process.wait(), timeout=120) + if code != 0: + raise RuntimeError("market research collection failed") + finally: + if process.returncode is None: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + + +class ResearchRefresh: + def __init__(self): + self.task = None + self.completed = None + + async def _collect(self): + await run_research_refresh() + self.completed = time.monotonic() + return await asyncio.to_thread(get_research_desk_json) + + def _finished(self, task): + # Retrieve exceptions even if every browser disconnected in the meantime. + if not task.cancelled(): + task.exception() + if self.task is task: + self.task = None + + async def refresh(self): + if self.task is None: + if self.completed is not None and time.monotonic() - self.completed < 30: + return await asyncio.to_thread(get_research_desk_json) + self.task = asyncio.create_task(self._collect()) + self.task.add_done_callback(self._finished) + # Every concurrent tab receives the same success OR failure. Disconnecting + # one client cannot kill the shared collection or queue repeated timeouts. + return await asyncio.shield(self.task) + + async def close(self): + task = self.task + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +REFRESH_KEY = web.AppKey("research_refresh", ResearchRefresh) + + +async def handle_research(_request): + return web.json_response(await asyncio.to_thread(get_research_desk_json)) + + +async def handle_refresh(request): + from monitoring.web_dashboard import _is_local_origin + + if not _is_local_origin(request) or request.headers.get("X-Requested-With") != "quant-dashboard": + return web.json_response({"error": "이 컴퓨터의 대시보드에서 갱신해 주세요."}, status=403) + try: + return web.json_response(await request.app[REFRESH_KEY].refresh()) + except asyncio.TimeoutError: + return web.json_response({"error": "자료 수집 시간이 초과되었습니다. 이전 자료의 기준일을 확인해 주세요."}, status=504) + except Exception: + logger.warning("시장 관찰 자료 갱신 실패") + return web.json_response({"error": "자료를 갱신하지 못했습니다. 잠시 후 다시 시도해 주세요."}, status=503) + + +def register_research_routes(app): + app[REFRESH_KEY] = ResearchRefresh() + async def close_research(application): + await application[REFRESH_KEY].close() + app.on_cleanup.append(close_research) + app.router.add_get("/api/research", handle_research) + app.router.add_post("/api/research/refresh", handle_refresh) diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css index 43ab9e46..821fc7ac 100644 --- a/monitoring/static/dashboard.css +++ b/monitoring/static/dashboard.css @@ -26,6 +26,43 @@ --signal: #82cfff; --cursor: #ffffff; } + +/* 시장 조사: 계좌 성과와 다른 기준일·표본을 명시한다. */ +.research-status { margin: 18px 0 12px; color: var(--ink-soft); } +.research-status[data-state="error"], .research-status[data-state="stale"] { color: var(--warn); } +.research-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--rule); margin-bottom: 16px; } +.research-metric { padding: 14px; border-right: 1px solid var(--rule); } +.research-metric:last-child { border-right: 0; } +.research-metric span, .research-metric small { display: block; color: var(--ink-faint); font-size: 12px; } +.research-metric strong { display: block; font-size: 22px; font-variant-numeric: tabular-nums; margin: 3px 0; } +.research-controls { display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap; padding: 0 0 12px; } +.research-controls label { font-size: 12px; color: var(--ink-soft); } +.research-controls select { background: var(--paper); border: 1px solid var(--rule-strong); padding: 7px 24px 7px 9px; max-width: 100%; } +.research-table td:first-child { min-width: 155px; } +.research-table caption { position: static; width: auto; height: auto; margin: 0; padding: 0 0 10px; clip-path: none; overflow: visible; white-space: normal; text-align: left; font-size: 12px; color: var(--ink-soft); } +.research-table small { display: block; color: var(--ink-faint); font-weight: 400; font-size: 11px; white-space: normal; } +.research-table .research-bar { display: block; height: 3px; width: var(--bar-width); background: var(--accent); margin: 5px 0 0 auto; opacity: .55; } +.research-theses-head { margin: 24px 0 12px; } +.research-theses-head h3 { font-size: 15px; } +.research-thesis { border-top: 1px solid var(--rule); padding: 16px 0; } +.research-thesis header { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-bottom: 8px; } +.research-thesis h4 { font-size: 15px; } +.research-thesis p { margin: 6px 0; } +.research-thesis .research-evidence-status { color: var(--warn); font-size: 12px; flex-shrink: 0; } +.research-thesis a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } +.research-thesis ul, #researchLimitations ul { padding-left: 20px; margin: 8px 0; } +.research-thesis details { margin-top: 8px; } +.research-thesis summary { cursor: pointer; color: var(--ink-soft); } +.operations-disclosures { padding-left: 20px; } +.operations-disclosures li { margin: 8px 0; overflow-wrap: anywhere; } +.operations-disclosures a { color: var(--accent); text-decoration: underline; } +.operations-wrap { overflow-wrap: anywhere; } +@media (max-width: 600px) { + .research-metric { padding: 10px 8px; } + .research-metric strong { font-size: 18px; } + .research-controls { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 8px; } + .research-thesis header { align-items: flex-start; } +} * { box-sizing: border-box; } diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js index bcdfe3eb..28885a0d 100644 --- a/monitoring/static/dashboard.js +++ b/monitoring/static/dashboard.js @@ -612,10 +612,10 @@ if (!basket || !basket.contribution_plan?.enabled) return "not-planned"; if (state.flowStatus.get(basket.basket) !== "ready") return "unknown"; const month = koreaDate(new Date()).slice(0, 7); - const recorded = (state.flows.get(basket.basket) || []).some((flow) => { - return koreaDate(flow.occurred_at).slice(0, 7) === month; - }); - return recorded ? "recorded" : "empty"; + const recorded = (state.flows.get(basket.basket) || []).reduce((sum, flow) => + sum + (koreaDate(flow.occurred_at).slice(0, 7) === month ? Math.max(0, Number(flow.amount) || 0) : 0), 0); + if (!recorded) return "empty"; + return recorded >= Number(basket.contribution_plan?.amount || 0) ? "recorded" : "partial"; } function runtimeAgeMinutes() { const parsed = parseDate( @@ -654,6 +654,8 @@ // 이번 달 적립금 미기록 같은 실제 할 일이 가려진다. const reviewNote = evaluation?.review_note || null; const issues = (evaluation?.issues || []).filter((item) => item !== reviewNote); + const contributionIssues = primary?.contribution_status?.issues || []; + const allocationIssues = primary?.allocation?.issues || []; const contribution = currentMonthContributionState(primary); el.decisionMeta.textContent = `${modeLabel()}${latest ? ` · 마지막 자산 기록 ${fmtLong(latest)}` : " · 자산 기록 없음"}`; @@ -751,6 +753,16 @@ }); return; } + if (contributionIssues.length) { + setDecision({ title: "지난 적립 계획의 금액을 확인해 주세요", description: contributionIssues.join(" · "), action: "portfolio", actionLabel: "적립 기록 보기" }); + return; + } + if (allocationIssues.length) { + setDecision({ title: primary?.allocation?.status === "UNKNOWN" ? "자산별 평가 자료를 기다리고 있습니다" : "자산별 비중이 목표에서 벗어났습니다", + description: [...allocationIssues, ...(primary?.plan_diagnostics || []).map(d => `${d.symbol}: ${d.reason}`)].join(" · "), + action: "portfolio", actionLabel: "포트폴리오 보기" }); + return; + } if (contribution === "unknown") { setDecision({ title: "적립금 기록을 확인할 수 없습니다", @@ -761,10 +773,10 @@ }); return; } - if (contribution === "empty") { + if (contribution === "empty" || contribution === "partial") { const planned = Number(primary.contribution_plan?.amount || 0); setDecision({ - title: "이번 달 적립금 기록이 없습니다", + title: contribution === "partial" ? "이번 달 적립 계획의 일부만 기록됐습니다" : "이번 달 적립금 기록이 없습니다", description: `${planned > 0 ? `매월 ${compactWon(planned)}을 적립하는 계좌입니다. ` : ""}${state.mode === "live" ? "입금을 마쳤다면 같은 금액을 기록해 주세요." : "추가할 모의투자금을 기록해 주세요."}`, action: "deposit", actionLabel: "적립금 기록", @@ -836,9 +848,11 @@ return '

아직 보유 종목이 없습니다.

'; const total = basket.snapshot ? Number(basket.snapshot.total_value) : null; const targets = basket.target_weights || {}; + const measured = new Map((basket.measurement?.positions || []).map(p => [p.symbol, p])); const rows = positions.map((p) => ({ ...p, weight: total ? Number(p.invested || 0) / total : null, + marketWeight: total && measured.get(p.symbol)?.quantity === p.quantity && measured.get(p.symbol)?.price != null ? Number(p.quantity) * Number(measured.get(p.symbol).price) / total : null, target: targets[p.symbol] == null ? null : Number(targets[p.symbol]), })); const maxW = Math.max( @@ -856,7 +870,7 @@ : holdingsValue - holdingsCost; return `
- + ${rows .map( (r) => ` @@ -865,13 +879,14 @@ + `, ) .join("")} - +
${escapeHtml(basket.display_name)} 보유 종목과 비중
종목수량평단가매입금액매입 비중목표 비중
종목수량평단가매입금액매입 비중시장가격 비중목표 비중
${won(r.avg_price)} ${won(r.invested)} ${r.weight == null ? "—" : pct(r.weight * 100, { sign: false, digits: 1 })}${r.marketWeight == null ? "—" : pct(r.marketWeight * 100, { sign: false, digits: 1 })} ${r.target == null ? "—" : pct(r.target * 100, { sign: false, digits: 1 })}
보유분 평가금액 ${won(holdingsValue)} · 매입금액 ${won(holdingsCost)} · 평가손익 ${signedWon(holdingsPnl)}${holdingsCost && holdingsPnl != null ? ` (${pct((holdingsPnl / holdingsCost) * 100)})` : ""}
보유분 평가금액 ${won(holdingsValue)} · 매입금액 ${won(holdingsCost)} · 평가손익 ${signedWon(holdingsPnl)}${holdingsCost && holdingsPnl != null ? ` (${pct((holdingsPnl / holdingsCost) * 100)})` : ""}
-

현재가는 계좌 기록에 저장하지 않으므로 종목별 비중은 매입금액 기준이고, 보유분 평가금액은 총자산에서 현금을 뺀 값입니다.

`; +

시장가격 비중은 마지막 평가의 가격·수량 기준입니다. 가격 기록이 없거나 이후 수량이 바뀌면 —로 표시합니다.

`; } function renderBasketTracks(data) { @@ -951,6 +966,12 @@
${target == null ? "" : ``}

${escapeHtml(note)}

${overlayLine} +

평가 ${escapeHtml(fmtDT(b.measurement?.measured_at))} · ${{ intraday: "장중", close: "종가", unverified: "가격 시점 미확인", reconstructed: "복원 추정" }[b.measurement?.kind] || "가격 시점 미확인"}${b.measurement?.quote_dates?.length ? ` · 가격 기준일 ${escapeHtml(b.measurement.quote_dates.join(", "))}` : ""}

+ ${(b.allocation?.assets || []).map(a => `

${escapeHtml(a.label)} ${pct(Number(a.actual) * 100)} · 목표 ${pct(Number(a.target) * 100)} · 차이 ${pct(Number(a.gap) * 100)}p

`).join("")} + ${(b.allocation?.issues || []).map(s => `

${escapeHtml(s)}

`).join("")} + ${(b.plan_diagnostics || []).map(d => `

주문 보류 · ${escapeHtml(d.symbol)}: ${escapeHtml(d.reason)}

`).join("")} + ${(b.contribution_status?.issues || []).map(s => `

${escapeHtml(s)}

`).join("")} +

${b.latest_close ? `별도 종가 평가 ${escapeHtml(b.latest_close.date.slice(0, 10))} · ${won(b.latest_close.total_value)} · 누적 ${pct(b.latest_close.cumulative_return)}` : "별도 종가 평가 기록 대기"}

${holdingsTable(b)} ${latestFlow ? `

최근 적립 ${escapeHtml(fmtMD(latestFlow.occurred_at))} · ${escapeHtml(signedWon(latestFlow.amount))}${latestFlow.note ? ` · ${escapeHtml(latestFlow.note)}` : ""}

` : ""} @@ -1719,6 +1740,9 @@

${item.paper_only ? `전체 운영 ${days}거래일` : `${days} / ${minimum} 거래일`}${coverageText}

${copy}

+ ${item.entry_status ? `

${item.entry_status.status === "BLOCKED" ? "최근 평가 기준 신규 매수 차단" : "주문 직전 진입 조건 재검사"} · ${escapeHtml(fmtDT(item.entry_status.measured_at))}

` : ""} +

최근 7일 주문 거부 기록 ${item.recent_order_rejections ?? "미확인"}건 · 동일 일자·원인은 중복 제외

+ ${item.rules_window ? `

현재 규칙: ${escapeHtml(item.rules_window.since)}부터 ${Number(item.rules_window.trading_days)} / ${Number(item.rules_window.min_trading_days)}거래일

` : ""} ${issues.length ? `
    ${issues.map((i) => `
  • ${escapeHtml(i)}
  • `).join("")}
` : ""} `; }) diff --git a/monitoring/static/operations.js b/monitoring/static/operations.js new file mode 100644 index 00000000..ce75c4ed --- /dev/null +++ b/monitoring/static/operations.js @@ -0,0 +1,81 @@ +/* Saved observations only: this panel never starts orders or initializes a ledger. */ +(() => { + "use strict"; + const $ = (id) => document.getElementById(id); + const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); + const finite = (v) => typeof v === "number" && Number.isFinite(v); + const num = (v, digits = 1) => finite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: digits }) : "—"; + const status = (v) => ({ ready: "자료 준비됨", completed: "수집 완료", running: "수집 중", partial: "일부 자료 부족", attention: "확인 필요", unavailable: "자료 없음", stale: "갱신 지연", not_started: "시작 기록 없음", interrupted: "수집 중단", disabled: "자동 수집 꺼짐", collecting: "관측 누적 중", insufficient_sample: "관측 부족", observing: "관측 누적 중", awaiting_forward: "새 종가 관측 대기", ready_for_review: "표본 요건 충족 · 성과 검토 필요", data_gaps: "관측 결측", corrupt: "기록 확인 필요", disk_pressure: "저장 공간 부족", failed: "수집 실패", timeout: "수집 시간 초과", inactive: "활성 검증 없음" })[String(v).toLowerCase()] || v || "미확인"; + const when = (v) => { + if (!v) return "미확인"; + const d = new Date(v); + return Number.isFinite(d.valueOf()) ? new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(d) : "미확인"; + }; + const url = (value) => { + try { const u = new URL(value); return ["https:", "http:"].includes(u.protocol) && !u.username && !u.password ? u.href : null; } catch { return null; } + }; + const metric = (label, value, note = "") => `
${esc(label)}${esc(value)}${esc(note)}
`; + const issueLabel = (value) => ({ dart_status_010: "DART 인증키가 등록되지 않았습니다. 키를 갱신해야 공시를 수집할 수 있습니다.", dart_api_key_missing: "DART 인증키 설정이 필요합니다.", close_sessions_missing: "예상 거래일의 종가 기록이 누락됐습니다.", benchmark_missing: "동일 날짜의 비교지수 자료가 없습니다.", benchmark_source_changed: "비교지수 공급자가 변경되어 비교를 보류합니다.", assessment_stale: "최신 거래일까지 평가가 갱신되지 않았습니다.", collection_stale: "자동 수집 완료 기록이 오래됐습니다.", execution_observation_schema_missing: "새 체결 기록 구조가 아직 적용되지 않았습니다. 다음 운영 시작 시 적용됩니다.", no_new_execution_observations: "아직 새 체결 관측 기록이 없습니다." })[value] || value; + const issues = (values) => (values || []).map((value) => `

${esc(issueLabel(typeof value === "string" ? value : value.message || value.code || "자료 확인 필요"))}

`).join(""); + + function render(data) { + const ops = data.operations || {}; + const daily = data.daily_observation || {}; + const closeJob = daily.jobs?.close || {}; + $("dailyObservationStatus").dataset.state = daily.status === "completed" ? "ready" : "stale"; + const closeStatus = (value) => ({ awaiting_close: "장마감 종가 대기", previous_close_missing: "직전 거래일 종가 누락" })[value] || status(value); + $("dailyObservationStatus").textContent = `종가 기록 ${closeStatus(closeJob.data_status || closeJob.status || daily.status)} · 종가·수집 통합 ${closeStatus(daily.status)} · 최근 실행 ${when(daily.updated_at)}${daily.expected_close_session ? ` · 확인 대상 ${daily.expected_close_session}` : ""}`; + $("operationsStatus").dataset.state = ["completed", "running"].includes(ops.status) ? "ready" : "stale"; + const jobs = Object.entries(ops.jobs || {}).map(([key, value]) => `${({ market: "가격", intelligence: "공시·수급", paper_validation: "모의 검증" })[key] || key}: ${status(value.status)}`).join(" · "); + $("operationsStatus").textContent = `${status(ops.status)} · 최근 실행 ${when(ops.updated_at)}${jobs ? ` · ${jobs}` : ""}${ops.automatic_enabled === false ? " · 자동 실행 꺼짐" : ""}`; + const intel = data.intelligence || {}; + const disclosures = intel.disclosures || {}; + const flows = intel.investor_flows || {}; + $("intelligenceSummary").innerHTML = metric("공시 원문", status(disclosures.status), `OpenDART · ${num(disclosures.coverage?.ready, 0)}/${num(disclosures.coverage?.requested, 0)}종목 조회 완료`) + metric("투자자별 순매수", status(flows.status), `NAVER · ${num(flows.coverage?.ready, 0)}/${num(flows.coverage?.requested, 0)}종목 · 단위 주`) + metric("수급 기준일", intel.expected_session || intel.as_of || "—", `관측 ${when(intel.generated_at || intel.observed_at)}`); + const disclosureRows = [...(disclosures.records || [])].sort((a, b) => String(b.published_date).localeCompare(String(a.published_date))).slice(0, 20).map((row) => { + const link = url(row.url || row.source_url); + const title = row.report_nm || row.title || row.report_name || "공시"; + return `
  • ${esc(row.published_date || row.rcept_dt || "공개일 미확인")} · ${esc(row.corp_name || row.name || row.symbol || "")} · ${link ? `${esc(title)}` : esc(title)}${row.is_correction ? " · 정정" : ""}
  • `; + }).join(""); + const recentFlows = [...(flows.records || [])].sort((a, b) => String(b.trade_date).localeCompare(String(a.trade_date)) || String(a.symbol).localeCompare(String(b.symbol))); + const seen = new Set(); + const flowRows = recentFlows.filter((row) => !seen.has(row.symbol) && seen.add(row.symbol)).map((row) => `${esc(row.name || row.symbol)}${esc(row.trade_date)}${num(row.foreign_net_buy_shares, 0)}${num(row.institution_net_buy_shares, 0)}${num(row.individual_net_buy_shares, 0)}`).join(""); + const providerIssues = [...new Set([...(disclosures.symbols || []), ...(flows.symbols || [])].flatMap((row) => row.issues || []))]; + $("intelligenceRecords").innerHTML = issues(intel.issues) + issues(providerIssues) + `

    공시 ${num((disclosures.records || []).length, 0)}건 · 조회 ${esc(intel.disclosure_window?.start || "—")} ~ ${esc(intel.disclosure_window?.end || "—")} · 정확한 공개 시각이 없는 공시는 일중 분석에 사용할 수 없습니다.

    ${disclosureRows ? `
      ${disclosureRows}
    ` : '

    표시할 공시가 없습니다. 수집 상태와 조회 범위를 확인하세요.

    '}
    ${flowRows || ''}
    NAVER 제공 종목별 최근 순매수 수량(주) · 거래소별 집계 범위 미확인 · 거래대금 또는 시장 전체 수급이 아닙니다.
    종목기준일외국인기관개인
    확인 가능한 수급 자료가 없습니다.
    `; + renderExecution(data.execution || {}); + renderValidation(data.validation || {}); + } + + function renderExecution(execution) { + const coverage = execution.coverage || {}; + const groups = Array.isArray(execution.summary_by_basis) ? execution.summary_by_basis : []; + const brokerLabel = (group) => ({ paper_simulation: "장부 모의", vts: "증권사 모의 서버", production: "증권사 실거래 서버", unknown: "증권사 환경 미확인" })[group.broker_environment] || "환경 미확인"; + $("executionSummary").innerHTML = `

    ${esc(status(execution.status))} · 조회 ${esc(when(execution.as_of))} · 이전 주문 중 측정 근거 없음 ${num(coverage.legacy_unmeasured_orders, 0)}건${coverage.truncated ? " · 최근 관측 일부만 표시" : ""}

    ` + (groups.length ? groups.map((group) => `

    ${esc(({ modeled: "모의 체결", broker_observed: "증권사 확인 체결" })[group.fill_price_basis] || "체결 기준 미확인")} · ${esc(brokerLabel(group))} · ${esc(group.currency)}

    주문 시도 ${num(group.attempts, 0)}건 · 체결 ${num(group.filled_orders, 0)}건 · 가격 차이 ${num(group.price_shortfall_bps)} bp · 비용 포함 ${num(group.price_plus_fees_bps)} bp

    기준가격: ${esc(({ basket_plan: "바스켓 계획 시점", decision: "의사결정 시점", executor_input: "주문 실행부에 전달된 가격" })[group.reference_kind] || "미확인")} · 수수료·세금: ${esc(({ modeled: "모의 계산", estimated: "추정값", broker_observed: "증권사 확인", unavailable: "미확인" })[group.fees_basis] || "미확인")} · 1 bp = 0.01%p

    `).join("") : '

    새 주문의 기준가격·체결 기록이 쌓이면 비용을 계산합니다. 과거 주문의 기준가격을 추정하지 않습니다.

    ') + issues(execution.reason ? [execution.reason] : []) + issues(execution.issues) + `
    측정 범위${issues(execution.limitations)}
    `; + } + + function renderValidation(validation) { + const cohorts = (validation.cohorts || []).filter((row) => row.active !== false); + $("paperValidation").innerHTML = issues(validation.issues) + (cohorts.length ? cohorts.map((row) => { + const performance = row.performance || {}; + const regime = (key, label) => { const r = row.regimes?.[key] || {}; return `

    ${label} ${num(r.observations, 0)} / ${num(row.minimum_regime_observations, 0)}일 · 계좌 ${num(r.portfolio_return_pct)}% · 비교지수 ${num(r.benchmark_return_pct)}%

    `; }; + return `

    ${esc(row.basket || row.account_key)}

    ${esc(status(row.status))}

    새 관측 ${num(row.return_observations ?? row.observations, 0)} / ${num(row.minimum_observations, 0)}거래일 · 기간 ${num(row.calendar_days, 0)} / ${num(row.minimum_calendar_days, 0)}일 · 시작 ${esc(when(row.started_at))}

    입출금 조정 수익률 ${num(performance.twr_pct)}% · ${esc(row.benchmark?.name || "비교지수")} ${num(performance.benchmark_return_pct)}% · 차이 ${num(performance.excess_return_pct)}%p

    ${regime("up", "비교지수 상승일")}${regime("down", "비교지수 하락일")}

    결측 ${num(row.coverage?.missing_sessions?.length, 0)}일 · 비용 ${num(row.costs?.explicit_cost, 0)}원 · 총 매매금액 / 평균 자산 ${num(row.costs?.gross_turnover_ratio, 2)}배

    ${issues(row.issues)}
    검증 식별자와 범위

    ${esc(row.cohort_id)} · ${esc(row.account_key)}

    입출금을 구간 시작으로 간주한 장부 TWR 근사치입니다. 비용은 자산 평가에 이미 반영되어 다시 차감하지 않습니다. 기존 과거 기록은 새 전진 검증 일수에 포함하지 않습니다. 관측 기간 충족이 실계좌 승인이나 미래 수익 보증을 뜻하지 않습니다.

    `; + }).join("") : '

    검증 기준 등록 또는 첫 종가 기록을 기다리고 있습니다. 자동 수집 실행 상태를 확인하세요.

    '); + } + let reading = false; + async function load() { + if (reading) return; + reading = true; + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch("/api/research/operations", { signal: controller.signal }); + if (!response.ok) throw new Error("read failed"); + render(await response.json()); + } catch { + $("operationsStatus").dataset.state = "error"; + $("operationsStatus").textContent = "운용 관측 자료를 읽지 못했습니다. 표시 중인 자료는 이전 조회 결과입니다."; + } finally { window.clearTimeout(timer); reading = false; } + } + function main() { load(); window.setInterval(() => { if (document.visibilityState !== "hidden") load(); }, 60000); } + main(); +})(); diff --git a/monitoring/static/research.js b/monitoring/static/research.js new file mode 100644 index 00000000..426138cf --- /dev/null +++ b/monitoring/static/research.js @@ -0,0 +1,121 @@ +/* Research has its own freshness and errors; it never grants trading permission. */ +(() => { + "use strict"; + const $ = (id) => document.getElementById(id); + const escape = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); + const numeric = (value) => typeof value === "number" && Number.isFinite(value); + const number = (value, digits = 1) => numeric(value) ? value.toFixed(digits) : "—"; + const signed = (value) => numeric(value) ? `${value > 0 ? "+" : ""}${value.toFixed(1)}` : "—"; + const cohortName = (name) => ({ basket_equities: "바스켓 구성 종목", sector_etfs: "업종 ETF" })[name] || name || "미분류"; + const issueName = (name) => ({ stale_price: "가격 지연", adjustment_unverified: "수정주가 미확인", volume_unavailable: "거래량 없음" })[name] || (/^incomplete_\d+d_window$/.test(name) ? `${name.match(/\d+/)[0]}일 자료 부족` : name); + const evidenceName = (status) => ({ CURRENT: "검토 중", STALE: "재검토 필요", UPCOMING: "발표 대기", UNVERIFIED: "확인 부족" })[status] || "상태 미확인"; + const evidenceIssue = (issue) => ({ UNKNOWN_PUBLICATION: "출처의 정확한 공개 시각을 확인하지 못했습니다.", REVIEW_DUE: "예정한 검토 시각이 지났습니다.", EXPIRED: "연구 기록의 유효 기한이 지났습니다.", FUTURE_PUBLICATION: "아직 공개되지 않은 자료입니다." })[issue] || issue; + const eventLabel = (entry) => entry.event_status === "PASSED" || new Date(entry.event_at).valueOf() <= Date.now() ? "지난 이벤트 · 결과 검토" : "예정 이벤트"; + const safeUrl = (value) => { + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password ? url.href : null; + } catch { return null; } + }; + const date = (value) => { + if (!value) return "미확인"; + const parsed = new Date(value); + return Number.isFinite(parsed.valueOf()) ? new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(parsed) : "미확인"; + }; + let desk = null; + let refreshing = false; + let reading = false; + let refreshError = null; + let errorOrigin = null; + + function renderMarket() { + const market = desk?.market || {}; + const status = $("researchStatus"); + const old = market.is_stale || market.status === "stale"; + const labels = { ready: "관찰 자료 준비됨", partial: "일부 자료 부족", stale: "이전 거래일 자료 — 갱신 필요", unavailable: "관찰 자료 없음" }; + status.dataset.state = refreshError ? "error" : old ? "stale" : market.status; + status.textContent = refreshing ? "가격 자료를 수집하고 있습니다. 최대 2분 정도 걸릴 수 있습니다." : refreshError || `${market.replay ? "오프라인 재현 자료 · " : ""}${labels[market.status] || "확인 중"}${market.config_changed ? " · 관찰 설정 변경됨" : ""} · 종가 기준 ${market.as_of || market.expected_session || "미확인"} · 수집 ${date(market.generated_at)}`; + const rows = Array.isArray(market.observations) ? market.observations : []; + const cohortSelect = $("researchCohort"); + const selected = cohortSelect.value || "all"; + const cohorts = [...new Set(rows.map((row) => row.cohort))]; + cohortSelect.innerHTML = '' + cohorts.map((name) => ``).join(""); + cohortSelect.value = cohorts.includes(selected) ? selected : "all"; + const filtered = rows.filter((row) => cohortSelect.value === "all" || row.cohort === cohortSelect.value); + const horizon = $("researchHorizon").value || "60"; + const benchmark = market.benchmark || {}; + const comparable = (row) => Boolean(market.expected_session) && row.as_of === market.expected_session && benchmark.as_of === market.expected_session && !(row.issues || []).includes("adjustment_unverified") && !(benchmark.issues || []).includes("adjustment_unverified") && numeric(row.horizons?.[horizon]?.excess_pct); + filtered.sort((a, b) => Number(comparable(b)) - Number(comparable(a)) || (comparable(a) ? b.horizons[horizon].excess_pct - a.horizons[horizon].excess_pct : String(a.symbol).localeCompare(String(b.symbol)))); + const breadth = (market.breadth || []).filter((row) => cohortSelect.value === "all" || row.cohort === cohortSelect.value); + const eligible = breadth.reduce((sum, row) => sum + (row.sma60_denominator || 0), 0); + const above = breadth.reduce((sum, row) => sum + (row.above_sma60_count || 0), 0); + const metric = (label, value, note) => `
    ${escape(label)}${escape(value)}${escape(note)}
    `; + $("researchMetrics").innerHTML = metric("비교 가능한 관찰 종목", `${filtered.filter(comparable).length} / ${filtered.length}`, `${horizon}거래일 · 현재 설정 표본`) + metric("60일 평균선 위", eligible ? `${above} / ${eligible}` : "—", "전체 시장의 상승 종목 비율 아님") + metric(`${benchmark.name || "비교지수"} ${horizon}일`, numeric(benchmark.horizons?.[horizon]?.return_pct) ? `${signed(benchmark.horizons[horizon].return_pct)}%` : "—", "공급자 수정가격 · 배당 처리 상이"); + $("researchRows").innerHTML = filtered.length ? filtered.map((row) => { + const values = [20, 60, 120].map((period) => { + const value = row.horizons?.[period]?.excess_pct; + const width = numeric(value) ? Math.min(100, Math.abs(value) * 2) : 0; + return `${signed(value)}${String(period) === horizon && numeric(value) ? `` : ""}`; + }).join(""); + const issues = (row.issues || []).map(issueName).join(" · "); + return `${escape(row.name || row.symbol)}${escape(row.symbol)} · ${escape(row.group || cohortName(row.cohort))} · ${row.role === "basket_target" ? "바스켓 설정" : "관찰용"}${escape(row.as_of || "기준일 없음")} · ${escape(row.source?.name || "출처 미확인")}${issues ? ` · ${escape(issues)}` : ""}${values}${number(row.volume_ratio_20d)}${numeric(row.volume_ratio_20d) ? "배" : ""}${number(row.volatility_20d_pct)}${numeric(row.volatility_20d_pct) ? "%" : ""}`; + }).join("") : '시장 자료 갱신으로 관찰을 시작하세요. 기존 계좌의 매매 설정은 바뀌지 않습니다.'; + $("researchTableCaption").textContent = `${benchmark.name || "비교지수"} 대비 가격 수익률 차이(%p). 변동성은 최근 20거래일의 연율화 표준편차.`; + const limitations = [...(market.limitations || []), "거래량 배수는 마지막 거래일 거래량 ÷ 그 이전 20거래일 평균입니다.", "바스켓 설정 표시는 구성 목록 기준이며 실제 보유 수량이나 편입 추천을 뜻하지 않습니다."]; + const changes = (market.lead_changes || []).map((row) => `${row.name || row.symbol}: ${row.previous_rank}위 → ${row.rank}위 (${row.previous_as_of} 비교)`); + $("researchLimitations").innerHTML = `
      ${limitations.map((text) => `
    • ${escape(text)}
    • `).join("")}
    ${changes.length ? `

    이전 관찰 이후 순위 변화: ${escape(changes.join(" · "))}

    ` : ""}`; + } + + function renderEvidence() { + const evidence = desk?.evidence || {}; + const entries = Array.isArray(evidence.entries) ? evidence.entries : []; + const issues = (evidence.issues || []).map((issue) => `

    ${escape(issue)}

    `).join(""); + $("researchTheses").innerHTML = issues + (entries.length ? entries.map((entry) => { + const links = (entry.sources || []).map((source) => { + const url = safeUrl(source.url); + return `
  • ${url ? `${escape(source.title || source.url)}, 새 탭` : escape(source.title || "출처 확인 필요")} 발표 ${source.published_at ? escape(String(source.published_at).slice(0, 10)) : source.published_date ? `${escape(source.published_date)} · 시각 미확인` : "일시 미확인"}${source.publication_note ? `

    ${escape(source.publication_note)}

    ` : ""}
  • `; + }).join(""); + const symbols = (entry.symbols || []).map((row) => `${row.symbol}: ${row.rationale}`).join(" · "); + return `

    ${escape(entry.title || entry.theme)}

    ${escape(evidenceName(entry.status))}

    ${escape(entry.thesis)}

    반증·재검토 조건 ${escape(entry.invalidation_condition)}

    ${symbols ? `

    연결 근거: ${escape(symbols)}

    ` : '

    개별 종목의 수혜 여부는 아직 연결하지 않았습니다.

    '}${entry.event_at ? `

    ${escape(eventLabel(entry))} ${escape(date(entry.event_at))} · 원문 일정 재확인 필요

    ` : ""}

    검토 ${escape(date(entry.review_at))} · 유효 기한 ${escape(date(entry.expires_at))} · ${escape(entry.horizon)}

    출처와 반대 근거 · 기록 ${escape(date(entry.observed_at))}
      ${links}
    ${(entry.counterevidence || []).map((text) => `

    반대 근거·위험: ${escape(text)}

    `).join("")}${(entry.issues || []).map((text) => `

    ${escape(evidenceIssue(text))}

    `).join("")}
    `; + }).join("") : '

    등록된 투자 가설이 없습니다. 원문과 반증 조건을 확인한 연구 기록을 등록하면 여기에 표시됩니다.

    '); + } + + function render(data) { desk = data; renderMarket(); renderEvidence(); } + + async function load(refresh = false) { + if (refreshing || reading) return; + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), refresh ? 130000 : 15000); + reading = !refresh; + refreshing = refresh; + $("researchRefresh").disabled = true; + if (refresh) { refreshError = null; renderMarket(); } + try { + const response = await fetch(refresh ? "/api/research/refresh" : "/api/research", { method: refresh ? "POST" : "GET", headers: refresh ? { "X-Requested-With": "quant-dashboard" } : {}, signal: controller.signal }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "자료를 확인하지 못했습니다."); + if (refresh || errorOrigin === "read") { refreshError = null; errorOrigin = null; } + refreshing = false; + render(data); + } catch (error) { + errorOrigin = refresh ? "collection" : "read"; + refreshError = error.name === "AbortError" ? "조회 시간이 초과되었습니다. 표시 중인 자료의 기준일을 확인해 주세요." : "자료를 확인하지 못했습니다. 표시 중인 자료는 이전 수집 결과입니다."; + refreshing = false; + renderMarket(); + } finally { + window.clearTimeout(timer); + reading = false; + refreshing = false; + $("researchRefresh").disabled = false; + } + } + + function main() { + $("researchRefresh").addEventListener("click", () => load(true)); + $("researchCohort").addEventListener("change", renderMarket); + $("researchHorizon").addEventListener("change", renderMarket); + load(); + window.setInterval(() => { if (document.visibilityState !== "hidden") load(); }, 60000); + } + main(); +})(); diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html index 2512867e..57929af5 100644 --- a/monitoring/templates/dashboard.html +++ b/monitoring/templates/dashboard.html @@ -33,7 +33,7 @@ >
    @@ -319,6 +319,42 @@

    수익의 흐름

    +
    +
    +

    시장 관찰과 투자 가설

    + +
    +

    업종의 상대 강도와 산업 변화의 근거를 살펴봅니다. 관찰 순위는 매수 추천이 아니며 자동 주문에 연결되지 않습니다.

    +

    시장 자료를 확인하고 있습니다.

    +
    +
    + + + + +
    +
    + + + + + + +
    비교지수 대비 수정가격 수익률 차이(%p). 공급자별 배당 처리 상이·매매 비용 미반영.
    관찰 종목20일60일120일거래량 배수변동성
    +
    +
    관찰 범위와 데이터 한계
    +

    검토 중인 투자 가설

    원문을 확인해 등록한 연구 기록입니다. 자동 뉴스 수집이나 사실 판정 결과가 아닙니다.

    +

    등록된 근거를 확인하고 있습니다.

    +

    공시·수급 자동 수집

    공개된 원문과 제공처의 관측 자료를 보관합니다. 새 공시나 순매수만으로 매수 판단을 만들지는 않습니다.

    +

    자동 수집 상태를 확인하고 있습니다.

    +
    +
    최근 공시와 종목별 수급
    +

    체결비용 관측

    양수 비용은 기준가격보다 불리한 체결입니다. 모의 체결과 증권사 확인 체결을 구분합니다.

    +
    +

    장기 모의운용 검증

    검증 기준을 등록한 이후의 종가로 성과를 누적합니다. 자료 부족은 통과로 간주하지 않습니다.

    +

    종가 기록 실행 상태를 확인하고 있습니다.

    +
    +

    보유 종목

    @@ -680,5 +716,7 @@

    적립금 기록

    aria-atomic="true" >
    + + diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index 7072a082..38fcaf58 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -3,7 +3,7 @@ 대시보드는 장부와 런타임 상태를 읽어 사용자가 오늘 해야 할 일, 장기 성과, 실전 전환 준비도를 한 화면에서 이해하도록 돕는다. 웹에서 가능한 쓰기는 -적립금 기록뿐이며 매매와 설정 변경은 의도적으로 제공하지 않는다. +적립금 기록과 연구 자료 갱신이며, 매매와 설정 변경은 제공하지 않는다. """ from __future__ import annotations @@ -199,6 +199,7 @@ def get_snapshots_json(days: int = 30, account_key: Optional[str] = None) -> dic def get_baskets_json() -> dict: """활성 바스켓별 원금·평가금·배치율·보유 현황을 DB에서만 읽는다.""" from core.basket_deploy import effective_stock_fraction + from core.basket_observability import read_basket_diagnostics from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id from core.risk_overlays import ( overlay_target_weights, @@ -370,6 +371,9 @@ def get_baskets_json() -> dict: "holdings_cost": holdings_cost, "holdings_value": holdings_value, "positions": positions, + **read_basket_diagnostics( + basket_config, account_key, config.risk_params, ledger_mode, + ), } ) @@ -748,6 +752,10 @@ def _collect_all() -> dict: { "basket": basket_name, "verdict": result.get("verdict"), + "entry_status": result.get("entry_status"), + "operation_verdict": result.get("operation_verdict"), + "recent_order_rejections": (result.get("metrics") or {}).get("recent_order_rejections"), + "rules_window": result.get("rules_window"), "paper_only": bool(result.get("paper_only", False)), "review_note": result.get("review_note"), "progress_days": result.get("progress_days"), @@ -774,6 +782,8 @@ def _collect_all() -> dict: def create_app() -> web.Application: + from monitoring.research_dashboard import register_research_routes + web_mod = _require_aiohttp_web() app = web_mod.Application(middlewares=[web_mod.middleware(_security_headers)]) app[_RUNTIME_CACHE_KEY] = _ReadCache() @@ -786,6 +796,9 @@ def create_app() -> web.Application: app.router.add_post("/api/deposit", handle_api_deposit) app.router.add_get("/api/cash_flows", handle_api_cash_flows) app.router.add_get("/api/basket_evaluation", handle_api_basket_evaluation) + register_research_routes(app) + from monitoring.operations_dashboard import register_operations_routes + register_operations_routes(app) app.router.add_static( "/static/", path=str(_STATIC_PATH), diff --git a/strategies/fundamental_factor.py b/strategies/fundamental_factor.py index d17d8a87..f32ecdcc 100644 --- a/strategies/fundamental_factor.py +++ b/strategies/fundamental_factor.py @@ -2,8 +2,9 @@ 펀더멘털 팩터 전략 - 가격(OHLC)이 아닌 재무 지표만으로 신호 산출 (pykrx 우선 → yfinance 폴백). - 백테스트 시 DataFrame에 종목코드를 넣으려면 df.attrs['symbol'] = '005930' 설정. -- Point-in-time 안전장치: as_of_date가 주어지면 해당 시점 기준 공시 가용 데이터만 사용. - 한국 상장사는 분기 종료 후 통상 45일 내 실적 공시. 안전 마진으로 60일 적용. +- 과거 시점에는 당시 일자에 조회 가능한 시장 배수만 사용한다. + Yahoo의 현재 재무 수치를 과거에 붙이지 않는다. 공시 시각을 가진 재무 이력이 + 없으면 해당 항목은 결측이며 임의의 60일 지연으로 가용성을 만들어내지 않는다. """ from __future__ import annotations @@ -17,6 +18,7 @@ from strategies.base_strategy import BaseStrategy from config.config_loader import Config +from core.fundamental_loader import yahoo_debt_to_equity_pct try: import yfinance as yf @@ -230,20 +232,7 @@ def _yf_roe_debt_op_yoy(symbol: str) -> tuple[Optional[float], Optional[float], except (TypeError, ValueError): roe = None - debt_ratio = None - de = info.get("debtToEquity") - if de is not None: - try: - debt_ratio = float(de) * 100.0 - except (TypeError, ValueError): - pass - if debt_ratio is None: - td, te = info.get("totalDebt"), info.get("totalStockholderEquity") - if td is not None and te is not None and float(te) != 0: - try: - debt_ratio = float(td) / float(te) * 100.0 - except (TypeError, ValueError, ZeroDivisionError): - pass + debt_ratio = yahoo_debt_to_equity_pct(info) yoy = _yf_operating_income_yoy(t) return roe, debt_ratio, yoy @@ -307,6 +296,9 @@ def _fetch_fundamental_bundle(symbol: str, as_of_date: datetime = None) -> dict[ "debt_ratio": None, "op_income_yoy": None, "source": "", + "field_sources": {}, + "as_of": as_of_date.isoformat() if as_of_date is not None else None, + "financial_history_available": as_of_date is None, } per_pykrx = _stock_per_pykrx(symbol, as_of_date=as_of_date) mdf, dstr, mkt = _market_fundamental_for_symbol(symbol, as_of_date=as_of_date) @@ -319,14 +311,23 @@ def _fetch_fundamental_bundle(symbol: str, as_of_date: datetime = None) -> dict[ else: bench = _sector_median_per_pykrx(mdf) per = per_pykrx - if per is None: + if per is None and as_of_date is None: per = _yf_per(symbol) - roe, debt, yoy = _yf_roe_debt_op_yoy(symbol) + # Yahoo .info and quarterly_financials contain current/revised information. + # Merely changing pykrx's date (or subtracting 60 days) does not date Yahoo's + # values. Exclude them for every historical call until filing-time data exists. + roe, debt, yoy = _yf_roe_debt_op_yoy(symbol) if as_of_date is None else (None, None, None) out["per"] = per out["sector_benchmark_per"] = bench out["roe"] = roe out["debt_ratio"] = debt out["op_income_yoy"] = yoy + out["field_sources"] = {key: source for key, value, source in ( + ("per", per, "pykrx" if per_pykrx is not None else "yfinance"), + ("sector_benchmark_per", bench, "pykrx"), ("roe", roe, "yfinance"), + ("debt_ratio", debt, "yfinance"), ("op_income_yoy", yoy, "yfinance"), + ) if value is not None} + out["debt_ratio_basis"] = "interest_bearing_debt_to_equity_pct" if debt is not None else None parts: list[str] = [] if per_pykrx is not None or mdf is not None: parts.append("pykrx") @@ -349,10 +350,6 @@ class FundamentalFactorStrategy(BaseStrategy): SELL = "SELL" HOLD = "HOLD" - # Point-in-time 안전 마진: 분기 종료 후 실적 공시까지 최소 일수. - # 한국 상장사 통상 45일 이내 공시, 안전 마진 60일. - PIT_SAFE_MARGIN_DAYS = 60 - def __init__(self, config: Config = None, as_of_date: datetime = None): super().__init__( name="fundamental_factor", @@ -370,10 +367,9 @@ def _cached_bundle(self, symbol: str, as_of_date: datetime = None) -> dict[str, return {} ref_date = as_of_date or self._as_of_date - # Point-in-time: 백테스트 시 공시 가용 시점으로 조회 시점을 후퇴 - pit_date = None - if ref_date is not None: - pit_date = ref_date - timedelta(days=self.PIT_SAFE_MARGIN_DAYS) + # A market PER is already a dated market observation. Statement availability + # must come from real filing timestamps, not an invented fixed delay. + pit_date = ref_date cache_key = f"{sym}_{pit_date.strftime('%Y%m%d') if pit_date else 'live'}" ent = _CACHE.get(cache_key) @@ -421,8 +417,9 @@ def _score_bundle(self, bundle: dict[str, Any]) -> tuple[int, dict[str, Any]]: # 부채비율: provider 신뢰도 검증. yfinance debtToEquity는 한국 회계 기준과 # 다르므로(차입금/자본 vs 총부채/자본) 신뢰 소스가 아니면 점수 계산에서 제외. dr = bundle.get("debt_ratio") - source = bundle.get("source", "") - debt_source_trusted = any(ts in source for ts in self._TRUSTED_DEBT_SOURCES) + field_sources = bundle.get("field_sources") or {} + source = field_sources.get("debt_ratio", "unknown") + debt_source_trusted = source in self._TRUSTED_DEBT_SOURCES and bundle.get("debt_ratio_basis") == "total_liabilities_to_equity_pct" if dr is not None and debt_source_trusted: max_score += 1 if dr <= debt_max: @@ -460,57 +457,46 @@ def _signal_from_score(self, score: int, max_score: int = 4) -> str: return self._signal_from_score_adaptive(score, max_score) def analyze(self, df: pd.DataFrame) -> pd.DataFrame: + """Historical frame analysis is unavailable until filing-time data exists. + + Historical pykrx PER alone cannot supply the two independent factors that + this strategy requires. Fetching it once per row would issue hundreds of + calls for a guaranteed HOLD result. Report that limitation immediately. + Current observations use analyze_current()/generate_signal() explicitly. + """ result = df.copy() - if result.empty: - result["signal"] = self.HOLD - result["strategy_score"] = 0.0 - result["ensemble_skip"] = True - return result + result["signal"] = self.HOLD + result["strategy_score"] = 0.0 + result["fundamental_score"] = 0 + result["ensemble_skip"] = True + result["fundamental_data_status"] = "point_in_time_financial_history_unavailable" + return result - sym = "" - if hasattr(result, "attrs") and isinstance(result.attrs, dict): - sym = str(result.attrs.get("symbol") or "").strip() + def analyze_current(self, df: pd.DataFrame, *, symbol: str = None) -> pd.DataFrame: + """Explicit current observation, assigned only to the final decision row. - if not sym: - result["signal"] = self.HOLD - result["strategy_score"] = 0.0 - result["fundamental_score"] = 0 - result["ensemble_skip"] = True + The caller chooses this context; a row whose date happens to equal today's + date never implicitly authorizes current financial data in a backtest. + """ + result = self.analyze(df) + if result.empty or self._as_of_date is not None: return result - - # 백테스트: DataFrame의 마지막 날짜를 as_of_date로 사용 - row_date = None - if self._as_of_date: - row_date = self._as_of_date - elif hasattr(result.index, 'max') and len(result) > 0: - try: - last_idx = result.index[-1] - if isinstance(last_idx, (datetime, pd.Timestamp)): - row_date = last_idx.to_pydatetime() if hasattr(last_idx, 'to_pydatetime') else last_idx - except Exception: - pass - - bundle = self._cached_bundle(sym, as_of_date=row_date) - if not any( - bundle.get(k) is not None - for k in ("per", "sector_benchmark_per", "roe", "debt_ratio", "op_income_yoy") - ): - result["signal"] = self.HOLD - result["strategy_score"] = 0.0 - result["fundamental_score"] = 0 - result["ensemble_skip"] = True - return result - - sc, det = self._score_bundle(bundle) - max_sc = det.get("_max_score", 4) - sig = self._signal_from_score(sc, max_sc) - result["signal"] = sig - result["strategy_score"] = float(sc) - result["fundamental_score"] = sc - result["ensemble_skip"] = False + current = self.generate_signal(df, symbol=symbol) + skipped = current.get("ensemble_skip", True) + for column, value in (("signal", current.get("signal", self.HOLD)), + ("strategy_score", float(current.get("score", 0))), + ("fundamental_score", current.get("score", 0)), + ("ensemble_skip", skipped), + ("fundamental_data_status", "current_unavailable" if skipped else "current_observation")): + result.iat[-1, result.columns.get_loc(column)] = value return result def generate_signal(self, df: pd.DataFrame, symbol: str = None, **kwargs) -> dict: + """Current observation by default; historical callers must pass as_of_date. + + Backtests use analyze(), which returns explicit unavailability until dated + financial snapshots exist. Dated lookups stay separate from current cache. + """ sym = symbol or kwargs.get("symbol") or ( df.attrs.get("symbol") if hasattr(df, "attrs") and isinstance(df.attrs, dict) else None ) @@ -518,10 +504,11 @@ def generate_signal(self, df: pd.DataFrame, symbol: str = None, **kwargs) -> dic return { "signal": self.HOLD, "score": 0, + "ensemble_skip": True, "details": {"사유": "종목코드 없음 — df.attrs['symbol'] 또는 symbol 인자 필요"}, } - bundle = self._cached_bundle(sym) + bundle = self._cached_bundle(sym, as_of_date=kwargs.get("as_of_date")) if not any( bundle.get(k) is not None for k in ("per", "roe", "debt_ratio", "op_income_yoy") @@ -529,6 +516,7 @@ def generate_signal(self, df: pd.DataFrame, symbol: str = None, **kwargs) -> dic return { "signal": self.HOLD, "score": 0, + "ensemble_skip": True, "details": {"사유": "펀더멘털 데이터 조회 실패"}, "source": bundle.get("source", "none"), } @@ -540,6 +528,7 @@ def generate_signal(self, df: pd.DataFrame, symbol: str = None, **kwargs) -> dic return { "signal": sig, "score": sc, + "ensemble_skip": max_sc <= 1, "details": det, "close": float(df["close"].iloc[-1]) if not df.empty and "close" in df.columns else 0, "date": df.index[-1] if not df.empty else None, diff --git a/strategies/mean_reversion.py b/strategies/mean_reversion.py index 6479e93b..f27c8ee3 100644 --- a/strategies/mean_reversion.py +++ b/strategies/mean_reversion.py @@ -226,6 +226,7 @@ def generate_signal(self, df: pd.DataFrame, symbol: str = None, **kwargs) -> dic per_min=per_min, per_max=per_max, debt_ratio_max=debt_ratio_max, + debt_ratio_basis=fund_cfg.get("debt_ratio_basis", "total_liabilities_to_equity_pct"), ) details["펀더멘털필터"] = reason if not passed: diff --git a/tests/conftest.py b/tests/conftest.py index 6aee600f..04213b34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,20 +20,30 @@ from pathlib import Path +def _isolate_session_path(name, *, prefix, filename=None): + """Only create/clean paths owned by this pytest process, never caller paths.""" + if os.environ.get(name): + return + directory = Path(tempfile.mkdtemp(prefix=prefix)).resolve() + os.environ[name] = str(directory / filename if filename else directory) + # Capture the exact newly-created directory now. Tests may later change env; + # looking the path up during shutdown could delete a caller-owned directory. + atexit.register(shutil.rmtree, directory, ignore_errors=True) + + def pytest_configure(config): + # Automatic research hooks must never launch real provider collection in tests. + os.environ.setdefault("QUANT_RESEARCH_AUTOMATION_DISABLED", "true") + for name in ("QUANT_RESEARCH_OPERATIONS_DIR", "QUANT_PAPER_VALIDATION_DIR", "QUANT_MARKET_INTELLIGENCE_DIR", "QUANT_DAILY_OBSERVATION_DIR"): + _isolate_session_path(name, prefix="quant_test_research_") # 리스크 오버레이 상태 파일(data/overlay_state/*.json)도 운영 파일이다. 테스트가 # 운영 바스켓의 '추세 아래/낙폭 발동' 상태를 덮어쓰지 않도록 임시 디렉터리로 격리한다. - if not os.environ.get("QUANT_OVERLAY_STATE_DIR"): - os.environ["QUANT_OVERLAY_STATE_DIR"] = tempfile.mkdtemp(prefix="quant_test_overlay_") + _isolate_session_path("QUANT_OVERLAY_STATE_DIR", prefix="quant_test_overlay_") # 대시보드 런타임 상태·live 런타임 락도 운영 파일이다(2026-09-23 확인: 테스트 실행이 # data/dashboard_runtime_state.json에 가짜 신호를 남기고 data/.live_runtime.lock을 # 잡았다 — 같은 시각의 실제 live 실행은 '다른 런타임 실행 중'으로 중단된다). - if not os.environ.get("QUANT_DASHBOARD_STATE_PATH"): - os.environ["QUANT_DASHBOARD_STATE_PATH"] = str( - Path(tempfile.mkdtemp(prefix="quant_test_dash_")) / "dashboard_runtime_state.json" - ) - if not os.environ.get("QUANT_RUNTIME_LOCK_DIR"): - os.environ["QUANT_RUNTIME_LOCK_DIR"] = tempfile.mkdtemp(prefix="quant_test_lock_") + _isolate_session_path("QUANT_DASHBOARD_STATE_PATH", prefix="quant_test_dash_", filename="dashboard_runtime_state.json") + _isolate_session_path("QUANT_RUNTIME_LOCK_DIR", prefix="quant_test_lock_") # 사용자가 명시적으로 DB 경로를 지정했다면 존중한다. if os.environ.get("QUANT_DB_PATH"): diff --git a/tests/js/dashboard.test.cjs b/tests/js/dashboard.test.cjs index 1907b3f9..d1575af2 100644 --- a/tests/js/dashboard.test.cjs +++ b/tests/js/dashboard.test.cjs @@ -82,7 +82,7 @@ function dashboard({ now = "2026-10-01T00:15:00+09:00", fetch } = {}) { history: { replaceState() {} }, }); const exports = - "parseDate, fmtLong, fmtDT, calendarAgeDays, rowInstant, buildSeries, monthlyReturns, currentMonthContributionState, renderSignals, refreshCore, refreshSlow, refreshChart, updateChart, renderChartTable, moveHistoryTable, renderDecision, renderEvaluations, state, chart"; + "parseDate, fmtLong, fmtDT, calendarAgeDays, rowInstant, buildSeries, monthlyReturns, currentMonthContributionState, renderSignals, refreshCore, refreshSlow, refreshChart, updateChart, renderChartTable, moveHistoryTable, renderDecision, renderEvaluations, holdingsTable, renderBasketTracks, state, chart"; assert.match(source, /\n main\(\);\s*\}\)\(\);\s*$/); vm.runInContext( source.replace(/\n main\(\);/, `\n globalThis.dashboard = {${exports}};`), @@ -366,3 +366,39 @@ test("검증 결과를 처음 불러오는 동안에는 실패 안내를 띄우 d.renderDecision(); assert.equal(title(), "검증 결과를 불러오지 못했습니다"); }); + +test("시장 비중은 측정한 가격·수량으로만 표시", () => { + const d = dashboard(); + const basket = {display_name: "테스트", snapshot: {total_value: 1000}, + positions: [{symbol: "A", quantity: 2, invested: 400, avg_price: 200}], + target_weights: {A: 0.5}, measurement: {positions: [{symbol: "A", quantity: 2, price: 100}]}}; + assert.match(d.holdingsTable(basket), /20\.0%/); + basket.measurement.positions[0].quantity = 1; + assert.doesNotMatch(d.holdingsTable(basket), /20\.0%/); +}); + +test("매수 차단과 거부 기록을 검증 카드에 표시", () => { + const d = dashboard(); + d.renderEvaluations([{basket: "a", verdict: "WAIT", recent_order_rejections: 3, + entry_status: {status: "BLOCKED", measured_at: "2026-10-12T10:10:00"}}]); + const html = d.element("basketEval").innerHTML; + assert.match(html, /신규 매수 차단/); + assert.match(html, /주문 거부 기록 3건/); +}); + +test("이번 달 입금이 있어도 지난달 부족 금액을 숨기지 않음", () => { + const d = dashboard(); + d.state.runtime = {trading_halt: null, scheduler_stale: false}; + d.state.runtimeStatus = "ready"; + d.state.baskets = [{basket: "kr_pocket", account_key: "basket_rebalance:kr_pocket", snapshot: {date: "2026-09-30"}, + contribution_status: {issues: ["2026-09 적립 90,000원 확인 필요"]}}]; + d.renderEvaluations([{basket: "kr_pocket", issues: []}]); + assert.equal(d.element("decisionTitle").textContent, "지난 적립 계획의 금액을 확인해 주세요"); +}); + +test("소액 입금 한 건으로 월 적립이 완료되지 않음", () => { + const d = dashboard(); + d.state.flowStatus.set("test", "ready"); + d.state.flows.set("test", [{occurred_at: "2026-10-01T00:01:00", amount: 10000}]); + assert.equal(d.currentMonthContributionState({basket: "test", contribution_plan: {enabled: true, amount: 100000}}), "partial"); +}); diff --git a/tests/js/operations.test.cjs b/tests/js/operations.test.cjs new file mode 100644 index 00000000..6c27df17 --- /dev/null +++ b/tests/js/operations.test.cjs @@ -0,0 +1,55 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const path = require("node:path"); +const source = fs.readFileSync(path.join(__dirname, "../../monitoring/static/operations.js"), "utf8"); +function panel(fetch) { + const elements = new Map(); + const element = (id) => { + if (!elements.has(id)) elements.set(id, { innerHTML: "", textContent: "", dataset: {} }); + return elements.get(id); + }; + const context = vm.createContext({ URL, Intl, Date, AbortController, fetch, document: { getElementById: element }, window: { setTimeout, clearTimeout } }); + vm.runInContext(source.replace(/\n main\(\);/, "\n globalThis.panel = {render, load};"), context); + return { ...context.panel, element }; +} +test("missing data and interrupted collection never look healthy", () => { + const p = panel(); p.render({ operations: { status: "interrupted" }, intelligence: { disclosures: { status: "unavailable" } } }); + assert.match(p.element("operationsStatus").textContent, /수집 중단/); + assert.match(p.element("intelligenceSummary").innerHTML, /자료 없음/); +}); +test("external disclosures are escaped and unsafe links have no anchor", () => { + const p = panel(); p.render({ intelligence: { disclosures: { records: [{ title: "", url: "javascript:alert(1)" }] } } }); + assert.doesNotMatch(p.element("intelligenceRecords").innerHTML, /', thesis: 'thesis', status: "UNVERIFIED", sources: [{ url: "javascript:alert(1)", title: "bad" }, { url: "https://example.com/source", title: "good" }] }] } }); + assert.doesNotMatch(r.element("researchRows").innerHTML, /|href="javascript:/); + assert.match(html, /href="https:\/\/example.com\/source"/); + assert.match(html, /확인 부족/); +}); + +test("refresh explicitly posts once and a failed refresh never shows a success state", async () => { + let calls = 0; + const r = research(async (url, options) => { calls++; assert.equal(url, "/api/research/refresh"); assert.equal(options.method, "POST"); return { ok: false, json: async () => ({ error: "failed" }) }; }); + await r.load(true); + assert.equal(calls, 1); + assert.equal(r.element("researchStatus").dataset.state, "error"); + assert.equal(r.element("researchRefresh").disabled, false); +}); + +test("short history can be compared over20days while60day breadth remains unknown", () => { + const r = research(); + r.element("researchHorizon").value = "20"; + const short = row("new-etf", null); + short.horizons["20"] = { excess_pct: 2 }; + r.render({ market: { expected_session: "2026-10-12", benchmark: { as_of: "2026-10-12" }, observations: [short], breadth: [{ cohort: "sector_etfs", eligible: 1, sma60_denominator: 0, above_sma60_count: 0 }] } }); + const html = r.element("researchMetrics").innerHTML; + assert.match(html, /1 \/ 1/); + assert.match(html, /60일 평균선 위<\/span>—/); + assert.doesNotMatch(html, /0 \/ 1/); +}); + +test("past corporate events are shown as results to review, not upcoming events", () => { + const r = research(); + r.render({ evidence: { entries: [{ title: "earnings", event_at: "2020-10-01T01:00:00Z", event_status: "PASSED" }] } }); + assert.match(r.element("researchTheses").innerHTML, /지난 이벤트 · 결과 검토/); + assert.doesNotMatch(r.element("researchTheses").innerHTML, /예정 이벤트/); +}); + +test("a recovered read clears its error but an old report cannot clear a failed collection", async () => { + const responses = [false, true, false, true]; + const r = research(async () => ({ ok: responses.shift(), json: async () => ({ market: { status: "ready", as_of: "2026-10-12" } }) })); + await r.load(); + assert.equal(r.element("researchStatus").dataset.state, "error"); + await r.load(); + assert.equal(r.element("researchStatus").dataset.state, "ready"); + await r.load(true); + await r.load(); + assert.equal(r.element("researchStatus").dataset.state, "error"); +}); + +test("offline replay and configuration changes remain visible", () => { + const r = research(); + r.render({ market: { status: "stale", replay: true, config_changed: true } }); + assert.match(r.element("researchStatus").textContent, /오프라인 재현 자료/); + assert.match(r.element("researchStatus").textContent, /관찰 설정 변경됨/); +}); diff --git a/tests/test_audit_cycle.py b/tests/test_audit_cycle.py index fc06b4e4..7a9ea02f 100644 --- a/tests/test_audit_cycle.py +++ b/tests/test_audit_cycle.py @@ -27,6 +27,7 @@ def cycle(monkeypatch): from config.config_loader import Config Config._instance = None + monkeypatch.setattr("core.trading_hours._now_kst", lambda: datetime(2026, 10, 13, 10, 10)) order = [] fake_rb = MagicMock() fake_rb.portfolio_mgr.initial_capital = 300_000 @@ -70,7 +71,7 @@ def test_holiday_run_skips_trading_but_saves_snapshot(cycle, monkeypatch): fake_rb.save_daily_nav_snapshot.assert_called_once() -def test_holiday_run_can_be_forced(cycle, monkeypatch): +def test_force_does_not_bypass_market_session(cycle, monkeypatch): import main fake_rb, _, _ = cycle @@ -78,8 +79,9 @@ def test_holiday_run_can_be_forced(cycle, monkeypatch): main.run_rebalance(_args(force_rebalance=True)) - fake_rb.plan_risk_exits.assert_called_once() - fake_rb.plan_rebalance.assert_called_once() + fake_rb.plan_risk_exits.assert_not_called() + fake_rb.plan_rebalance.assert_not_called() + fake_rb.execute.assert_not_called() def test_trading_day_runs_normal_path(cycle, monkeypatch): diff --git a/tests/test_audit_data_freshness.py b/tests/test_audit_data_freshness.py index 9789c351..61f14afa 100644 --- a/tests/test_audit_data_freshness.py +++ b/tests/test_audit_data_freshness.py @@ -79,6 +79,15 @@ def test_benchmark_return_refuses_stale_last_bar(monkeypatch, fixed_target): assert DataCollector.fetch_benchmark_return("2026-06-10", "2026-09-22", "KS11") is None +def test_close_comparison_requires_both_requested_dates(monkeypatch): + import FinanceDataReader as fdr + shifted = _frame(["2026-10-07", "2026-10-12"]).rename(columns={"close": "Close"}) + monkeypatch.setattr(fdr, "DataReader", lambda *a, **k: shifted) + monkeypatch.setattr(dc_mod, "HAS_YF", False) + assert DataCollector.fetch_benchmark_return("2026-10-06", "2026-10-12", exact_boundaries=True) is None + assert DataCollector.fetch_benchmark_return("2026-10-07", "2026-10-12", exact_boundaries=True) == pytest.approx(1.0) + + def test_benchmark_return_uses_fresh_fallback(monkeypatch, fixed_target): import FinanceDataReader as fdr diff --git a/tests/test_audit_strategies_mean_reversion.py b/tests/test_audit_strategies_mean_reversion.py index da3b3b67..2cf01062 100644 --- a/tests/test_audit_strategies_mean_reversion.py +++ b/tests/test_audit_strategies_mean_reversion.py @@ -138,6 +138,20 @@ def _boom(*args, **kwargs): ] +@pytest.mark.parametrize("basis", [None, "interest_bearing_debt_to_equity_pct"]) +def test_live_fundamental_filter_passes_explicit_debt_definition(monkeypatch, basis): + import strategies.mean_reversion as mr + + captured = [] + monkeypatch.setattr(mr, "check_fundamental_filter", lambda symbol, **kwargs: captured.append(kwargs) or (True, "OK")) + filter_config = {"enabled": True, "debt_ratio_max": 200} + if basis is not None: + filter_config["debt_ratio_basis"] = basis + strategy = _strategy(fundamental_filter=filter_config) + assert strategy.generate_signal(_clean_case(), symbol="005930")["signal"] == "BUY" + assert captured[0]["debt_ratio_basis"] == (basis or "total_liabilities_to_equity_pct") + + def test_unmodelled_filters_warn_once_per_instance(): from loguru import logger diff --git a/tests/test_basket_close_collection.py b/tests/test_basket_close_collection.py new file mode 100644 index 00000000..efb150da --- /dev/null +++ b/tests/test_basket_close_collection.py @@ -0,0 +1,238 @@ +"""Forward close observations without orders or operating NAV rewrites.""" +from datetime import datetime, timedelta +import hashlib +import json +from pathlib import Path +import sqlite3 +from types import SimpleNamespace + +import pandas as pd +import pytest + +from core.basket_close import collect_basket_closes, finalize_basket_closes, DIRECT_CLOSE_METHOD + + +@pytest.fixture +def ledger(tmp_path, monkeypatch): + import database.models as models + from config.config_loader import Config + monkeypatch.setenv("QUANT_DB_PATH", str(tmp_path / "close.db")) + monkeypatch.setenv("QUANT_RUNTIME_LOCK_DIR", str(tmp_path / "locks")) + monkeypatch.setattr(Config, "_instance", None) + for name in ("_engine", "_ScopedSession", "_SessionFactory"): + monkeypatch.setattr(models, name, None) + models.init_database() + monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer._load_baskets_config", lambda: { + "test": {"enabled": True, "initial_capital": 1000}, "off": {"enabled": False, "initial_capital": 1000}, + }) + monkeypatch.setattr("socket.socket.connect", lambda *a, **k: pytest.fail("no network in close tests")) + monkeypatch.setattr("core.order_executor.OrderExecutor.execute_buy", lambda *a, **k: pytest.fail("no orders")) + monkeypatch.setattr("core.order_executor.OrderExecutor.execute_buy_quantity", lambda *a, **k: pytest.fail("no orders")) + monkeypatch.setattr("core.order_executor.OrderExecutor.execute_sell", lambda *a, **k: pytest.fail("no orders")) + with models.get_session() as session: + session.add(models.TradeHistory(account_key="basket_rebalance:test", mode="paper", symbol="005930", action="BUY", + price=100, quantity=5, total_amount=500, commission=1, tax=0, + executed_at=datetime(2026, 10, 13, 10))) + session.add(models.Position(account_key="basket_rebalance:test", mode="paper", symbol="005930", quantity=5, + avg_price=100, total_invested=500)) + session.commit() + yield models + if models._engine: + models._engine.dispose() + + +def prices(value=110, wrong_day=None): + return SimpleNamespace(fetch_korean_stock=lambda symbol, start, end: pd.DataFrame({"date": [wrong_day or start], "close": [value]}), + get_last_source_info=lambda: {"source": "fixture"}) + + +@pytest.mark.parametrize("hour", [8, 10, 15]) +def test_intraday_only_reads_missing_coverage_without_orm_or_prices(ledger, monkeypatch, hour): + from config.config_loader import Config + database = Path(Config.get().database["sqlite_path"]) + before = hashlib.sha256(database.read_bytes()).hexdigest() + monkeypatch.setattr("database.models.get_session", lambda: pytest.fail("no ORM access before settlement")) + result = collect_basket_closes(Config.get(), now=datetime(2026, 10, 13, hour, 30), + collector_factory=lambda: pytest.fail("no provider before settlement")) + assert result["status"] == "previous_close_missing" and result["rows_saved"] == 0 + assert result["last_completed_session"] == "2026-10-12" + assert result["coverage"]["missing"] == 1 + assert hashlib.sha256(database.read_bytes()).hexdigest() == before + + +def test_weekend_reports_friday_gap_and_detects_existing_close_read_only(ledger, monkeypatch): + from config.config_loader import Config + from database.repositories import append_portfolio_valuation + now = datetime(2026, 10, 17, 8, 30) + result = collect_basket_closes(now=now, collector_factory=lambda: pytest.fail("no holiday prices")) + assert result["status"] == "previous_close_missing" + assert result["expected_session"] == "2026-10-16" + append_portfolio_valuation(account_key="basket_rebalance:test", mode="paper", day="2026-10-16", + measured_at=datetime(2026, 10, 16, 15, 30), total_value=1049, cash=499, cumulative_return=0, + daily_return=0, mdd=0, context={"valuation_kind": "close"}) + database = Path(Config.get().database["sqlite_path"]) + before = hashlib.sha256(database.read_bytes()).hexdigest() + monkeypatch.setattr("database.models.get_session", lambda: pytest.fail("coverage must not initialize ORM")) + result = collect_basket_closes(now=now, collector_factory=lambda: pytest.fail("no holiday prices")) + assert result["status"] == "awaiting_close" and result["coverage"]["present"] == 1 + assert result["coverage"]["verification"] == "presence_only" + assert hashlib.sha256(database.read_bytes()).hexdigest() == before + + +def test_missing_schema_is_reported_without_migration(tmp_path, monkeypatch): + from config.config_loader import Config + database = tmp_path / "old.db" + with sqlite3.connect(database) as connection: + connection.execute("CREATE TABLE sentinel (id INTEGER)") + monkeypatch.setenv("QUANT_DB_PATH", str(database)) + monkeypatch.setattr(Config, "_instance", None) + monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer._load_baskets_config", lambda: {"test": {"enabled": True}}) + before = database.read_bytes() + result = collect_basket_closes(now=datetime(2026, 10, 13, 8, 30)) + assert result["status"] == "previous_close_missing" + assert result["coverage"]["reason"] == "close_schema_missing" + assert database.read_bytes() == before + + +def test_first_direct_close_requires_no_intraday_seed_and_preserves_operating_ledger(ledger): + from database.repositories import get_portfolio_valuations + result = collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), collector_factory=prices) + assert result["status"] == "completed" and result["rows_saved"] == 1 + close = get_portfolio_valuations("basket_rebalance:test", kind="close")[0] + assert close["cash"] == 499 and close["total_value"] == 1049 + assert close["cumulative_return"] == 0 and close["context"]["first_close_in_series"] + assert close["context"]["return_method"] == DIRECT_CLOSE_METHOD + assert close["context"]["baseline_valuation_id"] is None + with ledger.get_session() as session: + assert session.query(ledger.PortfolioSnapshot).count() == 0 + assert session.query(ledger.TradeHistory).count() == 1 + assert session.query(ledger.CashFlow).count() == 0 + assert session.query(ledger.Position).one().quantity == 5 + again = collect_basket_closes(now=datetime(2026, 10, 13, 17), collector_factory=prices) + assert again["rows_saved"] == 0 and again["baskets"][0]["status"] == "already_recorded" + + +def test_both_writers_share_real_os_lock_and_leave_no_duplicate(ledger): + from core.basket_close import _close_lock + from database.repositories import get_portfolio_valuations + with _close_lock() as acquired: + assert acquired + result = collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), + collector_factory=lambda: pytest.fail("locked collector must not price")) + assert result["status"] == "busy" and result["rows_saved"] == 0 + assert finalize_basket_closes(SimpleNamespace(), datetime(2026, 10, 13, 16, 30)) == [] + assert get_portfolio_valuations("basket_rebalance:test", kind="close") == [] + assert collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), collector_factory=prices)["rows_saved"] == 1 + + +@pytest.mark.parametrize("bad", ["null_cost", "fractional_quantity", "future_flow"]) +def test_incomplete_cost_or_invalid_ledger_fails_before_price_fetch(ledger, bad): + from database.repositories import get_portfolio_valuations + with ledger.get_session() as session: + if bad == "null_cost": + session.query(ledger.TradeHistory).one().commission = None + elif bad == "fractional_quantity": + session.query(ledger.Position).one().quantity = 5.5 + else: + session.add(ledger.CashFlow(account_key="basket_rebalance:test", mode="paper", amount=100, + occurred_at=datetime(2026, 10, 14, 10))) + session.commit() + provider = prices() + provider.fetch_korean_stock = lambda *a: pytest.fail("invalid ledger must not price") + result = collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), collector_factory=lambda: provider) + assert result["status"] == "unavailable" and result["rows_saved"] == 0 + assert get_portfolio_valuations("basket_rebalance:test", kind="close") == [] + + +def test_after_close_trade_and_deposit_are_excluded_from_close_cutoff(ledger): + from database.repositories import get_portfolio_valuations + with ledger.get_session() as session: + session.add(ledger.TradeHistory(account_key="basket_rebalance:test", mode="paper", symbol="005930", action="SELL", + quantity=2, price=120, total_amount=240, commission=1, tax=1, + executed_at=datetime(2026, 10, 13, 16))) + session.query(ledger.Position).one().quantity = 3 + session.add(ledger.CashFlow(account_key="basket_rebalance:test", mode="paper", amount=100, + occurred_at=datetime(2026, 10, 13, 16))) + session.commit() + result = collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), collector_factory=prices) + assert result["rows_saved"] == 1 + close = get_portfolio_valuations("basket_rebalance:test", kind="close")[0] + assert close["cash"] == 499 and close["context"]["positions"][0]["quantity"] == 5 + assert close["total_value"] == 1049 + # The next true close includes both the later trade and contribution. + result = collect_basket_closes(now=datetime(2026, 10, 14, 16, 30), collector_factory=prices) + assert result["rows_saved"] == 1 + second = get_portfolio_valuations("basket_rebalance:test", kind="close")[-1] + assert second["cash"] == 837 and second["total_value"] == 1167 + assert second["context"]["flow_since"] == 100 + assert second["cumulative_return"] == pytest.approx((1167 / 1149 - 1) * 100) + + +def test_intraday_recovery_continues_direct_series_without_old_profit_jump(ledger): + from config.config_loader import Config + from database.repositories import append_portfolio_valuation, get_portfolio_valuations + collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), collector_factory=prices) + append_portfolio_valuation(account_key="basket_rebalance:test", mode="paper", day="2026-10-14", + measured_at=datetime(2026, 10, 14, 10), total_value=999, cash=499, cumulative_return=80, + daily_return=0, mdd=0, context={"valuation_kind": "intraday", "positions": [{"symbol": "005930", "quantity": 5, "price": 100}]}) + rb = SimpleNamespace(config=Config.get(), account_key="basket_rebalance:test", data_collector=prices(120), + portfolio_mgr=SimpleNamespace(initial_capital=1000), _ledger_mode=lambda: "paper") + assert finalize_basket_closes(rb, datetime(2026, 10, 15, 8, 30)) == ["2026-10-14"] + close = get_portfolio_valuations(rb.account_key, kind="close")[-1] + assert close["total_value"] == 1099 + assert close["cumulative_return"] == pytest.approx((1099 / 1049 - 1) * 100) + assert close["context"]["return_method"] == DIRECT_CLOSE_METHOD + assert close["context"]["initial_capital"] == 1000 + assert len(close["context"]["trade_ids"]) == 1 + + +def test_actual_writer_rows_pass_paper_validation_with_deposit_and_recovery(ledger): + from config.config_loader import Config + import core.paper_validation as validation + from database.repositories import append_portfolio_valuation + key = "basket_rebalance:test" + assert collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), collector_factory=prices)["rows_saved"] == 1 + with ledger.get_session() as session: + session.add(ledger.CashFlow(account_key=key, mode="paper", amount=100, occurred_at=datetime(2026, 10, 14, 9))) + session.commit() + assert collect_basket_closes(now=datetime(2026, 10, 14, 16, 30), collector_factory=lambda: prices(120))["rows_saved"] == 1 + append_portfolio_valuation(account_key=key, mode="paper", day="2026-10-15", measured_at=datetime(2026, 10, 15, 10), + total_value=1099, cash=599, cumulative_return=80, daily_return=0, mdd=0, + context={"valuation_kind": "intraday", "positions": [{"symbol": "005930", "quantity": 5, "price": 100}]}) + rb = SimpleNamespace(config=Config.get(), account_key=key, data_collector=prices(130), + portfolio_mgr=SimpleNamespace(initial_capital=1000), _ledger_mode=lambda: "paper") + assert finalize_basket_closes(rb, datetime(2026, 10, 16, 8, 30)) == ["2026-10-15"] + # The fixture clock is ahead of wall time. Set only this isolated fixture's + # recording timestamps to follow each simulated observation. + with ledger.get_session() as session: + for row in session.query(ledger.PortfolioValuation): + observed = [datetime.fromisoformat(p["observed_at"]) for p in json.loads(row.detail).get("positions", []) if "observed_at" in p] + row.recorded_at = max([row.measured_at] + observed) + timedelta(minutes=1) + session.commit() + database, issues = validation._read_database(Path(Config.get().database["sqlite_path"]).resolve()) + assert not issues + by_id = {row["id"]: row for row in database["portfolio_valuations"]} + closes = sorted((row for row in by_id.values() if row["kind"] == "close"), key=lambda row: row["date"]) + previous = None + for row in closes: + source = validation._source_close(row, by_id, datetime(2026, 10, 16, 17, tzinfo=validation.KST), initial_capital=1000) + end = validation._dt(source["measured_at"]) + _, issues = validation._ledger_interval(database, key, previous or end, end, source) + assert not issues + previous = end + assert len(closes) == 3 + + +@pytest.mark.parametrize("bad", ["stale", "missing", "positions"]) +def test_unverified_prices_or_ledger_never_make_a_close(ledger, bad): + from database.repositories import get_portfolio_valuations + if bad == "positions": + with ledger.get_session() as session: + session.query(ledger.Position).one().quantity = 6 + session.commit() + provider = prices(wrong_day="2026-10-12") if bad == "stale" else prices() + if bad == "missing": + provider.fetch_korean_stock = lambda *a: pd.DataFrame() + result = collect_basket_closes(now=datetime(2026, 10, 13, 16, 30), collector_factory=lambda: provider) + assert result["status"] == "unavailable" and result["rows_saved"] == 0 + assert get_portfolio_valuations("basket_rebalance:test", kind="close") == [] diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py index 0afc1acc..1685cd4b 100644 --- a/tests/test_basket_overlay_integration.py +++ b/tests/test_basket_overlay_integration.py @@ -84,6 +84,20 @@ def test_no_overlay_block_keeps_design(self): assert rb.data_collector.calls == [] # 지수 조회조차 하지 않는다 +def test_overlay_issue_is_reported_on_change_and_recovery_only(monkeypatch): + cfg = _basket({"trend_filter": {"enabled": True, "index_symbol": "KS200", "ma_days": 200}}) + events = MagicMock() + monkeypatch.setattr("core.cycle_observability.record_cycle_event", events) + first = _make(cfg, closes=None) + first.overlay_decision() + again = _make(cfg, closes=None) + again.overlay_decision() + assert [c.args[0] for c in events.call_args_list] == ["OVERLAY_DATA_ISSUE"] + recovered = _make(cfg, closes=[100.] * 220) + recovered.overlay_decision() + assert [c.args[0] for c in events.call_args_list] == ["OVERLAY_DATA_ISSUE", "OVERLAY_DATA_RECOVERED"] + + class TestTrendFilter: overlays = {"trend_filter": {"enabled": True, "index_symbol": "KS200", "ma_days": 200, "band": 0.02, "off_scale": 0.5}} diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py index 31346fc8..19b104f1 100644 --- a/tests/test_basket_rebalancer.py +++ b/tests/test_basket_rebalancer.py @@ -68,6 +68,12 @@ def mock_baskets_config(): yield +@pytest.fixture(autouse=True) +def market_session(monkeypatch): + from datetime import datetime + monkeypatch.setattr("core.trading_hours._now_kst", lambda: datetime(2026, 10, 13, 10, 10)) + + @pytest.fixture def rebalancer(mock_baskets_config): with patch("core.basket_rebalancer.PortfolioManager"): diff --git a/tests/test_basket_snapshot_observability.py b/tests/test_basket_snapshot_observability.py new file mode 100644 index 00000000..be0cff86 --- /dev/null +++ b/tests/test_basket_snapshot_observability.py @@ -0,0 +1,213 @@ +from datetime import date, datetime, time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pandas as pd +import pytest + +from core.basket_observability import allocation_diagnostics, monthly_contribution_status, valuation_context + + +@pytest.fixture +def ledger(tmp_path, monkeypatch): + import database.models as models + from config.config_loader import Config + monkeypatch.setenv("QUANT_DB_PATH", str(tmp_path / "test.db")) + monkeypatch.setattr(Config, "_instance", None) + for field in ("_engine", "_ScopedSession", "_SessionFactory"): + monkeypatch.setattr(models, field, None) + models.init_database() + yield models + if models._engine: + models._engine.dispose() + + +def seed(account="basket_rebalance:test", at=datetime(2026, 10, 12, 10, 10), **changes): + from database.repositories import save_portfolio_snapshot + values = dict(account_key=account, total_value=1000, cash=100, invested=900, cumulative_return=0, + daily_return=0, mdd=0, snapshot_date=at, measured_at=at) + values.update(changes) + assert save_portfolio_snapshot(**values) + + +def test_current_mdd_blocks_pass_but_historical_rejection_does_not(ledger, monkeypatch): + import core.basket_evaluation as evaluation + from core.basket_rebalancer import BasketRebalancer + from core.cycle_observability import record_cycle_event + from core.trading_hours import _KST + monkeypatch.setattr("core.trading_hours._now_kst", lambda: datetime(2026, 10, 13, 10, tzinfo=_KST)) + monkeypatch.setattr(BasketRebalancer, "_load_baskets_config", lambda: {"test": {"initial_capital": 1000}}) + monkeypatch.setattr(evaluation, "evaluate_basket_paper_operation", lambda **kw: {"verdict": "PASS_CANDIDATE", "metrics": {}, "issues": []}) + seed(mdd=16.26) + record_cycle_event("ORDER_REJECTED", "MDD", strategy="basket_rebalance:test") + record_cycle_event("ORDER_REJECTED", "other", strategy="basket_rebalance:other") + result, _ = evaluation.collect_basket_paper_evaluation(basket_name="test", include_benchmark=False) + assert result["verdict"] == "WAIT" + assert result["operation_verdict"] == "PASS_CANDIDATE" + assert result["entry_status"]["status"] == "BLOCKED" + assert result["metrics"]["recent_order_rejections"] == 1 + seed(mdd=7, at=datetime(2026, 10, 13, 10, 10)) + result, _ = evaluation.collect_basket_paper_evaluation(basket_name="test", include_benchmark=False) + assert result["verdict"] == "PASS_CANDIDATE" + assert result["entry_status"]["allowed"] is None + + +@pytest.mark.parametrize("hour,minute", [(8, 49), (15, 31), (17, 43)]) +@pytest.mark.parametrize("action", ["BUY", "SELL"]) +def test_basket_executor_rejects_off_session_before_creating_executor(monkeypatch, hour, minute, action): + from core.basket_rebalancer import BasketRebalancer, RebalanceOrder + from config.config_loader import Config + from core.trading_hours import _KST + monkeypatch.setattr("core.trading_hours._now_kst", lambda: datetime(2026, 10, 13, hour, minute, tzinfo=_KST)) + executor = MagicMock(side_effect=AssertionError("must not construct executor")) + monkeypatch.setattr("core.order_executor.OrderExecutor", executor) + rb = object.__new__(BasketRebalancer) + rb.config = Config.get() + result = rb.execute([RebalanceOrder("069500", action, 1, 100000)]) + assert result["blocked"] and result["executed"] == 0 + executor.assert_not_called() + + +@pytest.mark.parametrize("action", ["BUY", "SELL"]) +def test_direct_basket_order_precheck_also_obeys_session(monkeypatch, action): + from core.order_executor import OrderExecutor + from core.trading_hours import TradingHours, _KST + from config.config_loader import Config + monkeypatch.setattr("core.trading_hours._now_kst", lambda: datetime(2026, 10, 13, 8, 49, tzinfo=_KST)) + executor = object.__new__(OrderExecutor) + executor.mode = "paper" + executor.account_key = "basket_rebalance:test" + executor.trading_hours = TradingHours(Config.get()) + for name in ("_global_trading_halt_check", "_monthly_buy_cap_check", "_drawdown_pre_order_check"): + monkeypatch.setattr(executor, name, lambda *a, **kw: {"allowed": True}) + assert not executor._pre_order_check(action=action)["allowed"] + + +def test_premarket_snapshot_is_not_attributed_to_today(monkeypatch): + from core.basket_rebalancer import BasketRebalancer + from config.config_loader import Config + class Clock(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 10, 13, 8, 49, tzinfo=tz) + monkeypatch.setattr("core.basket_rebalancer.datetime", Clock) + rb = object.__new__(BasketRebalancer) + rb.config, rb.basket_name = Config.get(), "test" + rb.portfolio_mgr = MagicMock() + assert rb._nav_attribution_date().date() == date(2026, 10, 12) + assert rb.save_daily_nav_snapshot() is False + rb.portfolio_mgr.save_daily_snapshot.assert_not_called() + + +def test_equity_drift_is_visible_even_when_total_deployment_is_close(): + diag = allocation_diagnostics(380092, 45707, + [{"symbol": "069500", "quantity": 1, "price": 101605}, {"symbol": "357870", "quantity": 4, "price": 58195}], + {"069500": .475, "357870": .475}, "357870") + assert diag["status"] == "ATTENTION" + assert diag["assets"][0]["gap"] == pytest.approx(-.20768318) + assert len(diag["issues"]) == 2 + + +def test_partial_contribution_does_not_reset_missing_month(): + result = monthly_contribution_status({"enabled": True, "amount": 100000, "cadence": "monthly"}, + [(datetime(2026, 8, 26), 100000), (datetime(2026, 9, 30), 10000)], date(2026, 7, 10), date(2026, 10, 13)) + assert result["missing_amount"] == 90000 + assert "2026-09" in result["issues"][0] + assert not result["months"][-1]["due"] + + +def test_quote_date_and_observation_time_prevent_false_close(): + p = SimpleNamespace(symbol="A", quantity=1) + now = datetime(2026, 10, 13, 16) + quote = {"A": {"price": 100, "quote_date": "2026-10-12", "observed_at": now.isoformat()}} + assert valuation_context(quote, [p], now, now, time(15, 30))["valuation_kind"] == "unverified" + quote["A"].update(quote_date="2026-10-13", observed_at="2026-10-13T10:10:00") + assert valuation_context(quote, [p], now, now, time(15, 30))["valuation_kind"] == "unverified" + quote["A"]["observed_at"] = now.isoformat() + assert valuation_context(quote, [p], now, now, time(15, 30))["valuation_kind"] == "close" + + +def test_append_only_measurements_survive_daily_upserts(ledger): + from database.repositories import get_portfolio_valuations + morning = datetime(2026, 10, 12, 10, 10) + seed(at=morning, cumulative_return=1.123456789, valuation_context={"valuation_kind": "intraday"}) + seed(at=morning, cumulative_return=1.123456789, valuation_context={"valuation_kind": "intraday"}) + seed(at=morning.replace(hour=13), total_value=1100, cash=200, cumulative_return=1.123456789) + rows = get_portfolio_valuations("basket_rebalance:test") + assert len(rows) == 2 + assert rows[0]["cumulative_return"] == 1.123456789 + assert rows[0]["kind"] == "intraday" and rows[1]["kind"] == "unverified" + with ledger.get_session() as session: + assert session.query(ledger.PortfolioSnapshot).count() == 1 + + +def test_failed_measurement_insert_rolls_back_daily_upsert(ledger, monkeypatch): + from database.repositories import save_portfolio_snapshot, get_latest_snapshot_summary + seed() + monkeypatch.setattr("database.repositories._append_valuation", MagicMock(side_effect=ValueError("write failed"))) + assert save_portfolio_snapshot(total_value=2000, cash=1100, invested=900, account_key="basket_rebalance:test", snapshot_date=datetime(2026, 10, 12)) is False + assert get_latest_snapshot_summary("basket_rebalance:test")["total_value"] == 1000 + + +def test_deposit_day_return_links_measured_subperiods_without_rounding(ledger, monkeypatch): + from core.portfolio_manager import PortfolioManager + from database.repositories import record_cash_flow, get_portfolio_valuations + from main import account_performance_points + key = "basket_rebalance:test" + with ledger.get_session() as s: + s.add(ledger.Position(account_key=key, mode="paper", symbol="A", quantity=1, avg_price=300000, total_invested=300000)) + s.add(ledger.TradeHistory(account_key=key, mode="paper", symbol="A", action="BUY", quantity=1, + price=300000, total_amount=300000, executed_at=datetime(2026, 8, 24))) + s.commit() + seed(at=datetime(2026, 8, 25, 10), total_value=280000, cash=0, cumulative_return=(280000 / 300000 - 1) * 100) + pm = PortfolioManager(account_key=key, initial_capital=300000) + assert pm.save_daily_snapshot({"A": 285001}, datetime(2026, 8, 26), valuation_context={"measured_at": "2026-08-26T10:10:00"}) + record_cash_flow(100000, account_key=key, occurred_at=datetime(2026, 8, 26, 12)) + assert pm.save_daily_snapshot({"A": 286003}, datetime(2026, 8, 26), valuation_context={"measured_at": "2026-08-26T13:10:00"}) + expected_index = (285001 / 300000) * (386003 / 385001) + with ledger.get_session() as s: + rows = s.query(ledger.PortfolioSnapshot).order_by(ledger.PortfolioSnapshot.date).all() + assert rows[-1].cumulative_return == pytest.approx((expected_index - 1) * 100, abs=1e-10) + assert rows[-1].daily_return == pytest.approx((expected_index / (280000 / 300000) - 1) * 100) + points = account_performance_points(key, rows) + assert points[-1][1] == pytest.approx(expected_index) + assert len(get_portfolio_valuations(key)) == 3 + + +def test_close_replays_later_trades_flows_and_never_overwrites_operating_nav(ledger): + from core.basket_close import finalize_basket_closes + from database.repositories import get_portfolio_valuations, record_cash_flow, get_latest_snapshot_summary + from config.config_loader import Config + key = "basket_rebalance:test" + context = {"valuation_kind": "intraday", "positions": [{"symbol": "A", "quantity": 2, "price": 100}]} + seed(total_value=1000, cash=800, valuation_context=context) + with ledger.get_session() as s: + s.add(ledger.TradeHistory(account_key=key, mode="paper", symbol="A", action="BUY", quantity=1, + total_amount=110, price=110, commission=1, tax=0, slippage=5, executed_at=datetime(2026, 10, 12, 12))) + s.add(ledger.TradeHistory(account_key="other", mode="paper", symbol="A", action="BUY", quantity=5, + total_amount=550, price=110, executed_at=datetime(2026, 10, 12, 12))) + s.commit() + record_cash_flow(100, account_key=key, occurred_at=datetime(2026, 10, 12, 11)) + collector = SimpleNamespace(fetch_korean_stock=lambda *args: pd.DataFrame({"date": [datetime(2026, 10, 12)], "close": [120]}), + get_last_source_info=lambda: {"source": "fixture"}) + rb = SimpleNamespace(config=Config.get(), account_key=key, data_collector=collector, _ledger_mode=lambda: "paper") + assert finalize_basket_closes(rb, datetime(2026, 10, 12, 15, 45)) == [] + assert finalize_basket_closes(rb, datetime(2026, 10, 13, 8)) == ["2026-10-12"] + assert finalize_basket_closes(rb, datetime(2026, 10, 13, 8)) == [] + close = get_portfolio_valuations(key, kind="close")[0] + assert close["cash"] == 789 + assert close["total_value"] == 1149 + assert close["cumulative_return"] == pytest.approx((1149 / 1100 - 1) * 100) + assert close["context"]["positions"][0]["quantity"] == 3 + assert get_latest_snapshot_summary(key)["total_value"] == 1000 + + +def test_stale_price_cannot_finalize_close(ledger): + from core.basket_close import finalize_basket_closes + from database.repositories import get_portfolio_valuations + from config.config_loader import Config + seed(valuation_context={"valuation_kind": "intraday", "positions": [{"symbol": "A", "quantity": 9, "price": 100}]}) + rb = SimpleNamespace(config=Config.get(), account_key="basket_rebalance:test", _ledger_mode=lambda: "paper", + data_collector=SimpleNamespace(fetch_korean_stock=lambda *a: pd.DataFrame({"date": [datetime(2026, 10, 9)], "close": [100]}))) + assert finalize_basket_closes(rb, datetime(2026, 10, 13, 10)) == [] + assert get_portfolio_valuations(rb.account_key, kind="close") == [] diff --git a/tests/test_daily_observation.py b/tests/test_daily_observation.py new file mode 100644 index 00000000..58e592e9 --- /dev/null +++ b/tests/test_daily_observation.py @@ -0,0 +1,113 @@ +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import subprocess +from types import SimpleNamespace +from unittest.mock import Mock + +from core import daily_observation as daily + + +def setup(monkeypatch): + monkeypatch.setattr(daily.shutil, "disk_usage", lambda _: SimpleNamespace(free=1024 ** 3)) + + +def test_close_failure_does_not_block_research_and_status_is_not_green(monkeypatch, tmp_path): + setup(monkeypatch) + run = Mock(side_effect=[(2, {"status": "partial", "issues": ["quote_missing"]}), (0, {"status": "completed"})]) + monkeypatch.setattr(daily, "_run_child", run) + result = daily.run_daily_observation(tmp_path) + assert result["status"] == "attention" + assert result["jobs"]["research"]["status"] == "completed" + assert "collect_basket_closes.py" in run.call_args_list[0].args[0][0] + assert "research_operations.py" in run.call_args_list[1].args[0][0] + assert "--force" in run.call_args_list[1].args[0] + assert result["orders_enabled"] is False + assert len(list((tmp_path / "runs").glob("*.json"))) == 1 + + +def test_before_close_is_waiting_not_a_saved_close(monkeypatch, tmp_path): + setup(monkeypatch) + monkeypatch.setattr(daily, "_run_child", Mock(side_effect=[(0, {"status": "awaiting_close"}), (0, {"status": "completed"})])) + result = daily.run_daily_observation(tmp_path) + assert result["status"] == result["jobs"]["close"]["status"] == "awaiting_close" + + +def test_low_disk_never_starts_children(monkeypatch, tmp_path): + monkeypatch.setattr(daily.shutil, "disk_usage", lambda _: SimpleNamespace(free=1)) + run = Mock() + monkeypatch.setattr(daily, "_run_child", run) + assert daily.run_daily_observation(tmp_path)["status"] == "attention" + run.assert_not_called() + + +def test_timeout_and_error_do_not_store_provider_secret(monkeypatch, tmp_path): + setup(monkeypatch) + monkeypatch.setattr(daily, "_run_child", Mock(side_effect=[subprocess.TimeoutExpired("provider?secret=HIDDEN", 1), RuntimeError("token=HIDDEN")])) + result = daily.run_daily_observation(tmp_path) + assert result["jobs"]["close"]["status"] == "timeout" + assert "HIDDEN" not in json.dumps(result) + + +def test_read_only_missing_and_interrupted_state(tmp_path): + assert daily.read_daily_observation(tmp_path / "missing")["status"] == "not_started" + assert not (tmp_path / "missing").exists() + now = datetime.now(timezone.utc) + daily._save({"schema_version": 1, "status": "running", "updated_at": (now - timedelta(minutes=21)).isoformat(), "jobs": {}}, tmp_path) + assert daily.read_daily_observation(tmp_path, now=now)["status"] == "interrupted" + + +def test_weekend_keeps_friday_expected_close_and_monday_is_due_only_after_schedule(): + def kst(value): + return datetime.fromisoformat(value).replace(tzinfo=daily.KST) + assert daily._expected_session(kst("2026-10-17T17:00")) == "2026-10-16" + assert daily._expected_session(kst("2026-10-19T08:30")) == "2026-10-16" + assert daily._expected_session(kst("2026-10-19T16:29")) == "2026-10-16" + assert daily._expected_session(kst("2026-10-19T16:30")) == "2026-10-19" + + +def test_overlapping_observation_does_not_spawn(monkeypatch, tmp_path): + from core.runtime_lock import process_runtime_lock + run = Mock() + monkeypatch.setattr(daily, "_run_child", run) + with process_runtime_lock(tmp_path / ".runner.lock") as acquired: + assert acquired + assert daily.run_daily_observation(tmp_path)["status"] == "busy" + run.assert_not_called() + + +def test_unknown_child_result_is_attention_even_with_zero_exit(monkeypatch, tmp_path): + setup(monkeypatch) + monkeypatch.setattr(daily, "_run_child", Mock(return_value=(0, {"status": "unknown"}))) + assert daily.run_daily_observation(tmp_path)["status"] == "attention" + + +def test_reader_does_not_initialize_calendar_or_call_network(monkeypatch, tmp_path): + from core import trading_hours + now = datetime.now(timezone.utc) + daily._save({"schema_version": 1, "status": "completed", "updated_at": now.isoformat(), + "expected_close_session": daily._expected_session(now), "jobs": {}}, tmp_path) + monkeypatch.setattr(trading_hours, "TradingHours", Mock(side_effect=AssertionError("calendar constructor may fetch"))) + assert daily.read_daily_observation(tmp_path, now=now)["status"] == "completed" + + +def test_timeout_stops_owned_grandchild(tmp_path): + import pytest + pid_file = tmp_path / "child_pid.txt" + script = "import subprocess,sys,time; from pathlib import Path; child=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)']); Path(sys.argv[1]).write_text(str(child.pid)); time.sleep(60)" + with pytest.raises(subprocess.TimeoutExpired): + daily._run_child(["-c", script, str(pid_file)], 2) + pid = int(pid_file.read_text()) + if os.name == "nt": + import ctypes + handle = ctypes.windll.kernel32.OpenProcess(0x100000, False, pid) + if handle: + try: + assert ctypes.windll.kernel32.WaitForSingleObject(handle, 2000) == 0 + finally: + ctypes.windll.kernel32.CloseHandle(handle) + else: + # A killed child can remain a zombie until the OS reaps it. + state = Path(f"/proc/{pid}/stat") + assert not state.exists() or state.read_text().split()[2] == "Z" diff --git a/tests/test_execution_analytics.py b/tests/test_execution_analytics.py new file mode 100644 index 00000000..9bcf9dd8 --- /dev/null +++ b/tests/test_execution_analytics.py @@ -0,0 +1,244 @@ +"""Actual executor/ledger observations, cumulative broker reconciliation and read-only reports.""" +from datetime import datetime, timedelta, timezone +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +from types import SimpleNamespace +import uuid + +import pytest + +from core.execution_analytics import execution_cost_report + + +@pytest.fixture +def executor(monkeypatch): + from config.config_loader import Config + from core.order_executor import OrderExecutor + from database.models import get_engine, init_database + assert os.environ.get("QUANT_DB_PATH") + assert Path(os.environ["QUANT_DB_PATH"]).resolve() != Path("data/quant_trader.db").resolve() + monkeypatch.setattr(Config, "_instance", None) + monkeypatch.setattr("socket.socket.connect", lambda *a, **k: pytest.fail("Execution tests must be offline")) + monkeypatch.setattr("core.notifier.Notifier", lambda *a, **k: SimpleNamespace()) + init_database() + ex = OrderExecutor(account_key="execution_test_" + uuid.uuid4().hex) + ex.config.trading["skip_earnings_days"] = 0 + ex.config.risk_params["gap_risk"]["enabled"] = False + ex.config.risk_params["transaction_costs"] = { + "commission_rate": .001, "tax_rate": .002, "slippage": .001, + "slippage_ticks": 0, "dynamic_slippage": {"enabled": False}, + } + monkeypatch.setattr(ex, "_should_block_new_buy_volatility_window", lambda: False) + monkeypatch.setattr(ex, "_pre_order_check", lambda **kw: {"allowed": True}) + monkeypatch.setattr(ex, "_report_buy_rejection", lambda *a, **kw: None) + monkeypatch.setattr(ex.risk_manager, "check_correlation_risk", lambda *a, **kw: {"scale": 1., "high_corr_symbols": [], "reason": ""}) + ex.report_path = get_engine().url.database + yield ex + from core.order_guard import OrderGuard + OrderGuard.clear("005930") + + +def _buy(executor, **kwargs): + return executor.execute_buy_quantity( + symbol="005930", price=60000, quantity=5, capital=10000000, + available_cash=10000000, strategy="execution_observation_test", avg_daily_volume=1000000, + **kwargs, + ) + + +def _report(executor): + return execution_cost_report(account_key=executor.account_key, db_path=executor.report_path) + + +def test_actual_paper_round_trip_observes_decision_and_signed_fill_costs(executor): + decision_at = (datetime.now(timezone(timedelta(hours=9))) - timedelta(seconds=20)).isoformat() + bought = _buy(executor, decision_context={"price": 59900, "at": decision_at, "source": "basket_plan"}) + assert bought["success"], bought + sold = executor.execute_sell("005930", 61000, quantity=5, emergency=True) + assert sold["success"], sold + report = _report(executor) + rows = {row["action"]: row for row in report["orders"]} + buy, sell = rows["BUY"], rows["SELL"] + assert buy["reference_kind"] == "basket_plan" and buy["reference_price"] == 59900 + assert buy["reference_at"] == decision_at + assert buy["price_shortfall_amount"] == (60060 - 59900) * 5 + assert sell["reference_kind"] == "executor_input" + assert sell["price_shortfall_amount"] == (61000 - 60939) * 5 + for row, result in ((buy, bought), (sell, sold)): + assert row["order_id"] == result["order_id"] + assert row["fill_price_basis"] == row["fees_basis"] == "modeled" + assert row["submitted_at"] and row["fill_observed_at"] + assert row["reference_to_submit_seconds"] >= 0 + assert row["price_plus_fees_amount"] == row["price_shortfall_amount"] + result["costs"]["commission"] + result["costs"]["tax"] + assert row["fully_observed_total_cost_amount"] is None + assert row["opportunity_cost_amount"] is None and not row["opportunity_cost_included"] + assert report["coverage"]["attempts"] == report["coverage"]["filled_orders"] == 2 + assert sum(group["filled_orders"] for group in report["summary_by_basis"]) == 2 + + +def test_preorder_rejection_is_recorded_without_inventing_a_fill(executor): + result = executor.execute_buy_quantity("005930", 60000, 0, 10000000, 10000000) + assert not result["success"] + row = _report(executor)["orders"][0] + assert row["status"] == "REJECTED_BEFORE_ORDER" and row["order_id"] is None + assert row["filled_qty"] == 0 and row["price_shortfall_amount"] is None + assert row["reason"] == "quantity must be positive" + + +def test_observation_storage_failure_does_not_change_actual_fill(executor, monkeypatch): + from database.repositories import get_position + monkeypatch.setattr("database.repositories.append_execution_cost_event", lambda *a, **kw: (_ for _ in ()).throw(OSError("disk unavailable"))) + result = _buy(executor) + assert result["success"] and result["execution_observability"] == "incomplete" + assert get_position("005930", account_key=executor.account_key).quantity == 5 + assert _report(executor)["coverage"]["legacy_unmeasured_orders"] == 1 + + +def _live(executor, monkeypatch, execution): + executor.mode = "live" + executor.kis_api = SimpleNamespace(buy_order=lambda *a, **kw: {"odno": "123456"}) + monkeypatch.setattr(executor, "_live_buy_gate_check", lambda *a, **kw: {"allowed": True}) + monkeypatch.setattr(executor, "_live_unfilled_order_block", lambda *a, **kw: None) + monkeypatch.setattr(executor, "_resolve_live_execution", lambda *a, **kw: execution) + monkeypatch.setattr(executor, "_halt_after_unconfirmed_live_execution", lambda **kw: None, raising=False) + return executor + + +def test_real_partial_fill_recovery_updates_cumulative_quantity_once(executor, monkeypatch): + from database.models import ExecutionCostEvent, get_session + from database.repositories import reconcile_order_record + ex = _live(executor, monkeypatch, {"confirmed": False, "filled_qty": 2, "fill_price": 60090, + "remaining_qty": 3, "reason": "partial", "actual_slippage_pct": .15}) + pending = _buy(ex) + assert not pending["success"] and pending["order_pending"], pending + partial = _report(ex)["orders"][0] + assert partial["status"] == "PARTIAL_FILLED" and partial["filled_qty"] == 2 + assert partial["price_shortfall_amount"] == 180 + assert partial["fees_basis"] == "unavailable" + # Actual recovery path fetches a new cumulative broker average, without a resend. + ex.kis_api.get_order_execution_after_order = lambda *a, **kw: {"filled_qty": 5, "fill_price": 60100, "remaining_qty": 0} + recovered = ex._reconcile_persistent_open_order_records([]) + assert len(recovered) == 1 + reconcile_order_record(pending["order_id"], filled_qty=5, filled_price=60100, execution_price_observed=True, + reason="broker_execution_confirmed_after_recovery_check") + report = _report(ex) + row = report["orders"][0] + assert row["filled_qty"] == 5 and row["price_shortfall_amount"] == 500 + assert row["remaining_qty"] == 0 and row["fill_price_basis"] == "broker_observed" + assert row["price_plus_fees_amount"] is None # no broker fees / full-size fee estimate + assert report["coverage"]["attempts"] == 1 + assert sum(g["price_shortfall_amount"] or 0 for g in report["summary_by_basis"]) == 500 + with get_session() as session: + assert session.query(ExecutionCostEvent).filter_by(order_id=pending["order_id"], event_type="reconciliation").count() == 1 + + +def test_reconciliation_cannot_reuse_partial_price_for_more_shares(executor, monkeypatch): + from database.repositories import reconcile_order_record + ex = _live(executor, monkeypatch, {"confirmed": False, "filled_qty": 2, "fill_price": 60090, + "remaining_qty": 3, "reason": "partial"}) + pending = _buy(ex) + assert pending["order_pending"], pending + ex.kis_api.get_order_execution_after_order = lambda *a, **kw: {"filled_qty": 5, "remaining_qty": 0} + assert ex._reconcile_persistent_open_order_records([]) + row = _report(ex)["orders"][0] + assert row["filled_qty"] == 5 and row["filled_price"] is None + assert row["measurement_status"] == "fill_price_unavailable" + assert row["price_shortfall_amount"] is None + # A later close/cancel update still carrying the old ledger price cannot + # upgrade the missing cumulative average into observed evidence. + reconcile_order_record(pending["order_id"], filled_qty=5, filled_price=60090) + assert _report(ex)["orders"][0]["filled_price"] is None + + +def test_confirmed_live_fill_does_not_label_model_fees_as_actual(executor, monkeypatch): + ex = _live(executor, monkeypatch, {"confirmed": True, "filled_qty": 5, "fill_price": 59900, + "remaining_qty": 0, "actual_slippage_pct": -.1667}) + result = _buy(ex) + assert result["success"], result + row = _report(ex)["orders"][0] + assert row["price_shortfall_amount"] == -500 # price improvement remains negative + assert row["fees_basis"] == "estimated" and row["fill_price_basis"] == "broker_observed" + assert row["fully_observed_total_cost_amount"] is None + assert row["price_plus_fees_amount"] == -500 + result["costs"]["commission"] + + +@pytest.mark.parametrize("unknown_response", [False, True]) +def test_broker_retry_or_unknown_response_is_one_unfilled_attempt(executor, monkeypatch, unknown_response): + from api.kis_api import KISOrderResponseUnknown + ex = _live(executor, monkeypatch, {}) + calls = [] + def broker(*args): + calls.append(args) + if unknown_response: + raise KISOrderResponseUnknown("test response lost") + return None + ex.kis_api.buy_order = broker + ex.kis_api.use_mock = True + monkeypatch.setattr("core.order_executor.time_mod.sleep", lambda seconds: None) + result = _buy(ex) + assert not result["success"] + assert len(calls) == (1 if unknown_response else 3) + report = _report(ex) + assert report["coverage"]["attempts"] == 1 + row = report["orders"][0] + assert row["status"] == ("SUBMITTED" if unknown_response else "REJECTED") + assert row["filled_qty"] == 0 and row["price_shortfall_amount"] is None + assert row["broker_environment"] == "vts" + + +def test_since_offsets_limits_and_account_scope_are_consistent(executor): + assert _buy(executor)["success"] + assert executor.execute_sell("005930", 61000, quantity=5, emergency=True)["success"] + report = execution_cost_report(db_path=executor.report_path, account_key=executor.account_key, + since=(datetime.now(timezone.utc) - timedelta(days=1)).isoformat(), limit=1) + assert report["coverage"]["total_matching_attempts"] == 2 + assert report["coverage"]["attempts"] == 1 and report["coverage"]["truncated"] + assert report["orders"][0]["action"] == "SELL" + assert execution_cost_report(db_path=executor.report_path, account_key="unrelated")["orders"] == [] + assert execution_cost_report(db_path=executor.report_path, since="not-a-date")["reason"] == "invalid_since_timestamp" + + +def test_report_missing_database_and_schema_never_create_or_migrate(tmp_path): + missing = tmp_path / "absent.db" + assert execution_cost_report(db_path=missing)["reason"] == "database_missing" + assert not missing.exists() + old = tmp_path / "old.db" + with sqlite3.connect(old) as connection: + connection.execute("CREATE TABLE unrelated (x INTEGER)") + before = hashlib.sha256(old.read_bytes()).hexdigest() + assert execution_cost_report(db_path=old)["reason"] == "execution_observation_schema_missing" + assert hashlib.sha256(old.read_bytes()).hexdigest() == before + assert {p.name for p in tmp_path.iterdir()} == {"old.db"} + + +def test_legacy_trade_is_missing_coverage_never_backfilled(executor): + from database.repositories import save_trade + save_trade(symbol="005930", action="BUY", price=59000, quantity=1, + account_key=executor.account_key, mode="paper", order_id="legacy-before-analytics", expected_price=58000) + save_trade(symbol="005930", action="SELL", price=59000, quantity=1, + account_key=executor.account_key, mode="paper") + report = _report(executor) + assert report["orders"] == [] + assert report["coverage"]["legacy_unmeasured_orders"] == 1 + assert report["coverage"]["legacy_unlinked_trades"] == 1 + + +def test_cli_reads_persisted_report_without_changing_ledger(executor, tmp_path): + assert _buy(executor)["success"] + # Backup a consistent read-only snapshot, no copies of production data here. + target = tmp_path / "cli.db" + with sqlite3.connect(executor.report_path) as source, sqlite3.connect(target) as dest: + source.backup(dest) + before = hashlib.sha256(target.read_bytes()).hexdigest() + completed = subprocess.run([sys.executable, "tools/execution_cost_report.py", "--db", str(target), + "--account-key", executor.account_key], capture_output=True, text=True, + encoding="utf-8", check=True) + report = json.loads(completed.stdout) + assert report["coverage"]["filled_orders"] == 1 + assert report["orders"][0]["currency"] == "KRW" + assert hashlib.sha256(target.read_bytes()).hexdigest() == before diff --git a/tests/test_fundamental_data_integrity.py b/tests/test_fundamental_data_integrity.py new file mode 100644 index 00000000..03dd234c --- /dev/null +++ b/tests/test_fundamental_data_integrity.py @@ -0,0 +1,190 @@ +from datetime import datetime +from types import SimpleNamespace + +import pandas as pd +import pytest + +from core import fundamental_loader as loader +from strategies import fundamental_factor as factor + + +@pytest.mark.parametrize("info,expected", [ + ({"debtToEquity": 145.03}, 145.03), + ({"debtToEquity": 0.7}, 0.7), + ({"totalDebt": 145.03, "totalStockholderEquity": 100}, 145.03), + ({"debtToEquity": float("inf")}, None), + ({"debtToEquity": float("nan")}, None), + ({"debtToEquity": -5}, None), + ({"totalDebt": 100, "totalStockholderEquity": -20}, None), + ({"totalDebt": 100, "totalStockholderEquity": 0}, None), +]) +def test_yahoo_debt_percentage_units_and_invalid_equity(info, expected): + assert loader.yahoo_debt_to_equity_pct(info) == expected + + +def test_successful_per_does_not_prevent_loading_debt_and_preserves_provenance(monkeypatch): + monkeypatch.setattr(loader, "_get_fundamentals_pykrx", lambda symbol: {"per": 8., "debt_ratio": None, "available": True, "source": "pykrx"}) + monkeypatch.setattr(loader, "_get_fundamentals_yfinance", lambda symbol: {"per": 10., "debt_ratio": 35., "available": True, "source": "yfinance"}) + output = loader.get_fundamentals("005930") + assert output["per"] == 8 + assert output["debt_ratio"] == 35 + assert output["field_sources"] == {"per": "pykrx", "debt_ratio": "yfinance"} + assert output["debt_ratio_basis"] == "interest_bearing_debt_to_equity_pct" + passed, reason = loader.check_fundamental_filter("005930", debt_ratio_max=200) + assert passed is False and "요구 총부채/자본, 제공 차입금/자본" in reason + assert loader.check_fundamental_filter("005930", debt_ratio_max=200, + debt_ratio_basis="interest_bearing_debt_to_equity_pct")[0] is True + + +def test_yahoo_unavailable_does_not_relabel_available_pykrx_per_as_none(monkeypatch): + monkeypatch.setattr(loader, "_get_fundamentals_pykrx", lambda symbol: {"per": 8., "debt_ratio": None, "available": True, "source": "pykrx"}) + monkeypatch.setattr(loader, "_get_fundamentals_yfinance", lambda symbol: {"per": None, "debt_ratio": None, "available": False, "source": "yfinance"}) + assert loader.get_fundamentals("005930")["source"] == "pykrx" + + +@pytest.mark.parametrize("basis,passed", [(None, False), ("unknown", False), + ("interest_bearing_debt_to_equity_pct", False), + ("total_liabilities_to_equity_pct", True)]) +def test_default_debt_filter_requires_verified_total_liabilities_basis(monkeypatch, basis, passed): + monkeypatch.setattr(loader, "get_fundamentals", lambda symbol: {"per": 10., "debt_ratio": 35., "debt_ratio_basis": basis}) + actual, reason = loader.check_fundamental_filter("005930") + assert actual is passed + assert ("기준 불일치" in reason) is (not passed) + + +def test_explicit_interest_debt_basis_uses_its_own_threshold_and_label(monkeypatch): + monkeypatch.setattr(loader, "get_fundamentals", lambda symbol: {"per": 10., "debt_ratio": 250., + "debt_ratio_basis": "interest_bearing_debt_to_equity_pct"}) + passed, reason = loader.check_fundamental_filter("005930", debt_ratio_max=200, + debt_ratio_basis="interest_bearing_debt_to_equity_pct") + assert passed is False and "차입금/자본 250.0% > 200%" in reason + + +def test_historical_bundle_never_queries_current_yahoo_financials(monkeypatch): + seen = [] + monkeypatch.setattr(factor, "_stock_per_pykrx", lambda symbol, as_of_date=None: seen.append(as_of_date) or 12.) + monkeypatch.setattr(factor, "_market_fundamental_for_symbol", lambda *args, **kwargs: (None, "", "")) + def forbidden(*args, **kwargs): + pytest.fail("current Yahoo values must not enter a historical bundle") + monkeypatch.setattr(factor, "_yf_per", forbidden) + monkeypatch.setattr(factor, "_yf_roe_debt_op_yoy", forbidden) + date = datetime(2023, 1, 3) + output = factor._fetch_fundamental_bundle("005930", date) + assert seen == [date] + assert output["roe"] is None and output["debt_ratio"] is None and output["op_income_yoy"] is None + assert output["financial_history_available"] is False + assert output["per"] == 12 + + +def test_historical_missing_per_never_falls_back_to_current_yahoo(monkeypatch): + monkeypatch.setattr(factor, "_stock_per_pykrx", lambda *args, **kwargs: None) + monkeypatch.setattr(factor, "_market_fundamental_for_symbol", lambda *args, **kwargs: (None, "", "")) + monkeypatch.setattr(factor, "_yf_per", lambda *args: pytest.fail("current PER fallback")) + output = factor._fetch_fundamental_bundle("005930", datetime(2023, 1, 3)) + assert output["per"] is None and output["source"] == "none" + + +def _strategy(): + return factor.FundamentalFactorStrategy(SimpleNamespace(strategies={"fundamental_factor": {"per_sector_relative": False}})) + + +def test_mixed_source_string_cannot_make_yahoo_debt_trusted(): + strategy = _strategy() + bundle = {"debt_ratio": 35, "source": "pykrx+yfinance_financials", "field_sources": {"debt_ratio": "yfinance"}, + "debt_ratio_basis": "interest_bearing_debt_to_equity_pct"} + score, details = strategy._score_bundle(bundle) + assert score == 0 and details["_max_score"] == 0 + assert "debt_ratio_skipped" in details + bundle["field_sources"]["debt_ratio"] = "dart" + bundle["debt_ratio_basis"] = "total_liabilities_to_equity_pct" + assert strategy._score_bundle(bundle)[0] == 1 + + +def test_historical_analyzer_does_no_io_when_required_pit_financials_are_unavailable(monkeypatch): + strategy = _strategy() + dates = pd.to_datetime(["2023-01-02", "2023-01-03", "2023-01-04"]) + prices = pd.DataFrame({"close": [10, 11, 12]}, index=dates) + prices.attrs["symbol"] = "005930" + monkeypatch.setattr(strategy, "_cached_bundle", lambda *args, **kwargs: pytest.fail("no pointless historical network calls")) + result = strategy.analyze(prices) + assert result["signal"].tolist() == ["HOLD", "HOLD", "HOLD"] + assert result["ensemble_skip"].all() + assert result["fundamental_data_status"].eq("point_in_time_financial_history_unavailable").all() + + +def test_one_valid_factor_is_not_treated_as_complete_ensemble_vote(monkeypatch): + strategy = _strategy() + prices = pd.DataFrame({"close": [10]}, index=pd.to_datetime(["2023-01-02"])) + prices.attrs["symbol"] = "005930" + monkeypatch.setattr(strategy, "_cached_bundle", lambda *args, **kwargs: {"per": 10}) + row = strategy.analyze_current(prices).iloc[-1] + assert row["signal"] == "HOLD" and row["ensemble_skip"] + + +def test_fixed_sixty_day_lag_is_not_invented_as_filing_date(monkeypatch): + factor._CACHE.clear() + strategy = _strategy() + seen = [] + monkeypatch.setattr(factor, "_fetch_fundamental_bundle", lambda symbol, as_of_date=None: seen.append(as_of_date) or {}) + reference = datetime(2023, 1, 3) + strategy._cached_bundle("005930", as_of_date=reference) + assert seen == [reference] + + +def test_current_and_historical_cache_namespaces_cannot_mix(monkeypatch): + factor._CACHE.clear() + strategy = _strategy() + seen = [] + def fetch(symbol, as_of_date=None): + seen.append(as_of_date) + return {"roe": 999 if as_of_date is None else None} + monkeypatch.setattr(factor, "_fetch_fundamental_bundle", fetch) + assert strategy._cached_bundle("005930")["roe"] == 999 + reference = datetime(2023, 1, 3) + assert strategy._cached_bundle("005930", reference)["roe"] is None + assert strategy._cached_bundle("005930", reference)["roe"] is None + assert seen == [None, reference] + + +def test_explicit_historical_single_signal_uses_dated_bundle(monkeypatch): + strategy = _strategy() + seen = [] + monkeypatch.setattr(strategy, "_cached_bundle", lambda symbol, as_of_date=None: seen.append(as_of_date) or {}) + reference = datetime(2023, 1, 3) + strategy.generate_signal(pd.DataFrame(), symbol="005930", as_of_date=reference) + assert seen == [reference] + + +def test_current_fundamental_only_updates_last_row_and_historical_call_stays_pure(monkeypatch): + strategy = _strategy() + prices = pd.DataFrame({"close": [10, 11, 12]}, index=pd.date_range("2023-01-02", periods=3)) + prices.attrs["symbol"] = "005930" + seen = [] + monkeypatch.setattr(strategy, "_cached_bundle", lambda symbol, as_of_date=None: seen.append(as_of_date) or {"per": 10, "roe": 20}) + current = strategy.analyze_current(prices) + assert seen == [None] + assert current["signal"].tolist() == ["HOLD", "HOLD", "BUY"] + assert current["ensemble_skip"].tolist() == [True, True, False] + historical = strategy.analyze(prices) + assert historical["ensemble_skip"].all() and seen == [None] + + +def test_live_ensemble_includes_current_fundamentals_without_historical_contamination(monkeypatch): + from core.strategy_ensemble import StrategyEnsemble + strategy = _strategy() + cfg = SimpleNamespace(strategies={"ensemble": {"components": [{"name": "unknown", "enabled": True}], "auto_downgrade": False}}) + ensemble = StrategyEnsemble(cfg, skip_independence_check=True) + ensemble._strategies = [("fundamental_factor", strategy, 1.)] + prices = pd.DataFrame({"close": [10, 11, 12]}, index=pd.date_range("2023-01-02", periods=3)) + seen = [] + monkeypatch.setattr(strategy, "_cached_bundle", lambda symbol, as_of_date=None: seen.append((symbol, as_of_date)) or {"per": 10, "roe": 20}) + assert ensemble.analyze(prices)["signal"].tolist() == ["HOLD", "HOLD", "HOLD"] + assert seen == [] + live = ensemble.generate_signal(prices, symbol="005930") + assert live["signal"] == "BUY" + assert not live["details"].get("fundamental_factor_skipped") + assert seen == [("005930", None)] + historical = ensemble.generate_signal(prices, symbol="005930", as_of_date=datetime(2023, 1, 4)) + assert historical["signal"] == "HOLD" + assert historical["details"]["fundamental_factor_skipped"] is True + assert seen == [("005930", None)] diff --git a/tests/test_market_intelligence.py b/tests/test_market_intelligence.py new file mode 100644 index 00000000..1700d85e --- /dev/null +++ b/tests/test_market_intelligence.py @@ -0,0 +1,329 @@ +from copy import deepcopy +from io import BytesIO +import json +import time +import zipfile + +import pytest +import requests + +import core.market_intelligence as intelligence +from core.market_research import _now as real_now + + +SPEC = {"symbol": "005930", "name": "삼성전자"} +DATES = ["2026-10-06", "2026-10-07", "2026-10-08", "2026-10-12"] + + +def config(): + return {"version": 1, "universe": [SPEC], "workers": 1, + "disclosures": {"enabled": True, "lookback_calendar_days": 14, "max_pages_per_company": 2}, + "investor_flows": {"enabled": True, "provider": "naver_securities", "lookback_sessions": 4}} + + +def disclosure(receipt="20261012000001", title="[기재정정]사업보고서", rm="정"): + return {"rcept_no": receipt, "rcept_dt": "20261012", "report_nm": title, + "corp_code": "00126380", "stock_code": "005930", "corp_name": "삼성전자", "rm": rm} + + +def flows(dates=DATES): + return {"itemCode": "005930", "dealTrendInfos": [ + {"itemCode": "005930", "bizdate": day.replace("-", ""), "individualPureBuyQuant": "+1,000", + "organPureBuyQuant": "0", "foreignerPureBuyQuant": "-950"} for day in dates]} + + +class Transport: + def __init__(self, *, pages=None, flow=None): + self.pages = pages if pages is not None else [{"status": "000", "total_page": 1, "total_count": 1, "list": [disclosure()]}] + self.flow = flow if flow is not None else flows() + self.calls = [] + + def get_bytes(self, url, **kwargs): + self.calls.append("corp_code") + data = BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr("CORPCODE.xml", "00593000126380") + return data.getvalue() + + def get_json(self, url, *, params=None, **kwargs): + if "list.json" in url: + self.calls.append(("disclosures", params["last_reprt_at"], params["page_no"])) + response = self.pages[int(params["page_no"]) - 1] + else: + self.calls.append("flow") + response = self.flow + if isinstance(response, Exception): + raise response + return deepcopy(response) + + +@pytest.fixture +def clock(monkeypatch): + value = ["2026-10-13T12:00:00+09:00"] + monkeypatch.setattr(intelligence, "_now", lambda timestamp=None: real_now(value[0] if timestamp is None else timestamp)) + return value + + +def report(transport=None, **kwargs): + return intelligence.collect_market_intelligence(config(), transport=transport or Transport(), dart_api_key="fixture-key", **kwargs) + + +def test_real_collection_contract_tracks_dates_provider_corrections_and_units(clock): + transport = Transport() + result = report(transport) + assert result["status"] == "ready" + assert result["flow_sessions"] == DATES + assert result["expected_session"] == "2026-10-12" + assert result["replay"] is False + assert ("disclosures", "N", "1") in transport.calls + row = result["disclosures"]["records"][0] + assert row["provider"] == "OpenDART" and row["published_date"] == "2026-10-12" + assert row["published_at"] is None and row["published_precision"] == "date_only" + assert row["is_correction"] and row["has_later_correction"] + assert row["corrects_record_id"] is None + assert row["available_to_system_at"] == result["generated_at"] + flow = result["investor_flows"]["records"][0] + assert flow["individual_net_buy_shares"] == 1000 + assert flow["institution_net_buy_shares"] == 0 + assert flow["foreign_net_buy_shares"] == -950 + assert flow["unit"] == "shares" and flow["published_at"] is None + assert flow["market_scope"] == "provider_unspecified_venue_coverage" + + +def test_missing_api_key_is_unavailable_not_empty_success(clock): + transport = Transport() + result = intelligence.collect_market_intelligence(config(), transport=transport, dart_api_key="") + assert result["status"] == "partial" + assert result["disclosures"]["status"] == "unavailable" + assert result["disclosures"]["coverage"]["confirmed_empty"] == 0 + assert result["disclosures"]["symbols"][0]["issues"] == ["dart_api_key_missing"] + assert transport.calls == ["flow"] + + +def test_documented_no_data_is_confirmed_but_failed_response_is_not(clock): + empty = report(Transport(pages=[{"status": "013", "message": "조회된 데이터가 없습니다."}])) + failed = report(Transport(pages=[{"status": "020", "message": "fixture-key MUST NOT persist"}])) + assert empty["disclosures"]["status"] == "ready" + assert empty["disclosures"]["coverage"]["confirmed_empty"] == 1 + assert failed["disclosures"]["status"] == "unavailable" + assert "dart_status_020" in failed["disclosures"]["symbols"][0]["issues"] + assert "fixture-key" not in json.dumps(failed) + + +def test_corporation_archive_authentication_xml_retains_safe_status(clock): + transport = Transport() + transport.get_bytes = lambda *args, **kwargs: b"010fixture-key private" + result = report(transport) + assert result["disclosures"]["status"] == "unavailable" + assert result["disclosures"]["symbols"][0]["issues"] == ["dart_status_010"] + assert "fixture-key" not in json.dumps(result) + assert result["investor_flows"]["status"] == "ready" + + +def test_pagination_preserves_records_but_cannot_claim_complete_when_capped(clock): + pages = [{"status": "000", "total_page": 3, "total_count": 3, "list": [disclosure()]}, + {"status": "000", "total_page": 3, "total_count": 3, "list": [disclosure("20261012000002", "사업보고서", "철")]}] + result = report(Transport(pages=pages))["disclosures"] + assert len(result["records"]) == 2 + assert result["status"] == "partial" + assert "pagination_limit_reached" in result["symbols"][0]["issues"] + assert result["records"][1]["is_withdrawal"] + + +@pytest.mark.parametrize("bad", [None, {}, {"status": "000"}, {"status": "000", "list": [], "total_count": 2}, {"status": "000", "list": [], "total_count": 0, "total_page": 1}]) +def test_bad_disclosure_response_never_looks_healthy(clock, bad): + result = report(Transport(pages=[bad])) + assert result["disclosures"]["status"] == "unavailable" + assert result["disclosures"]["coverage"]["confirmed_empty"] == 0 + + +def test_current_day_and_unexpected_older_flows_are_excluded(clock): + data = flows(DATES + ["2026-10-13", "2026-09-30"]) + result = report(Transport(flow=data))["investor_flows"] + assert result["status"] == "ready" + assert {row["trade_date"] for row in result["records"]} == set(DATES) + + +def test_short_disclosure_page_cannot_claim_full_coverage(clock): + source = Transport(pages=[{"status": "000", "total_count": 2, "total_page": 1, "list": [disclosure()]}]) + section = report(source)["disclosures"] + assert section["status"] == "partial" + assert "disclosure_count_mismatch" in section["symbols"][0]["issues"] + + +def test_missing_quantity_is_null_and_marks_partial_never_zero(clock): + data = flows() + data["dealTrendInfos"][0]["organPureBuyQuant"] = "-" + result = report(Transport(flow=data))["investor_flows"] + assert result["status"] == "partial" + assert result["records"][0]["institution_net_buy_shares"] is None + assert result["records"][0]["missing_fields"] == ["institution_net_buy_shares"] + + +def test_duplicate_session_is_removed_instead_of_arbitrarily_selected(clock): + data = flows() + data["dealTrendInfos"].append(deepcopy(data["dealTrendInfos"][0])) + result = report(Transport(flow=data))["investor_flows"] + assert result["status"] == "partial" + assert DATES[0] not in {row["trade_date"] for row in result["records"]} + assert result["symbols"][0]["missing_sessions"] == [DATES[0]] + + +@pytest.mark.parametrize("data", [{"itemCode": "035420", "dealTrendInfos": []}, {"itemCode": "005930", "dealTrendInfos": []}, {}]) +def test_wrong_ticker_and_empty_flow_never_looks_healthy(clock, data): + result = report(Transport(flow=data))["investor_flows"] + assert result["status"] == "unavailable" + assert result["coverage"]["confirmed_empty"] == 0 + + +def test_transport_errors_do_not_leak_api_key_or_request_url(monkeypatch): + def fail(*args, **kwargs): + raise requests.ConnectionError("https://provider.example?crtfc_key=super-secret") + monkeypatch.setattr(requests, "get", fail) + with pytest.raises(intelligence.SourceError) as error: + intelligence.PublicTransport().get_json("https://provider.example", params={"crtfc_key": "super-secret"}) + assert str(error.value) == "ConnectionError" + + +def test_elapsed_budget_skips_request_and_records_unavailable(clock): + transport = Transport() + result = intelligence.collect_investor_flows(SPEC, transport=transport, sessions=DATES, timeout=1, deadline=time.monotonic() - 1) + assert result["status"] == "unavailable" + assert result["issues"] == ["collection_budget_exhausted"] + assert transport.calls == [] + + +def test_append_only_versions_retain_first_observation_and_revision_chain(clock, tmp_path): + first = report() + intelligence.save_market_intelligence(first, tmp_path) + initial = {path.name: path.read_bytes() for path in (tmp_path / "versions").glob("*.json")} + clock[0] = "2026-10-13T13:00:00+09:00" + second = report() + intelligence.save_market_intelligence(second, tmp_path) + latest = intelligence.read_market_intelligence(tmp_path / "latest.json") + assert latest["status"] == "ready" + assert latest["disclosures"]["records"][0]["first_observed_at"] == first["generated_at"] + assert latest["disclosures"]["records"][0]["observed_at"] == second["generated_at"] + assert {path.name: path.read_bytes() for path in (tmp_path / "versions").glob("*.json")} == initial + clock[0] = "2026-10-13T14:00:00+09:00" + altered = Transport() + altered.pages[0]["list"][0]["rm"] = "정철" + intelligence.save_market_intelligence(report(altered), tmp_path) + changed = intelligence.read_market_intelligence(tmp_path / "latest.json")["disclosures"]["records"][0] + assert changed["previous_revision_id"] == first["disclosures"]["records"][0]["revision_id"] + assert changed["first_observed_at"] == clock[0] + assert len(list((tmp_path / "versions").glob("*.json"))) == len(initial) + 1 + assert len(list((tmp_path / "snapshots").glob("*.json"))) == 3 + for filename, contents in initial.items(): + assert (tmp_path / "versions" / filename).read_bytes() == contents + + +def test_failure_snapshot_preserves_successful_evidence_history(clock, tmp_path): + intelligence.save_market_intelligence(report(), tmp_path) + first_versions = list((tmp_path / "versions").glob("*.json")) + clock[0] = "2026-10-13T13:00:00+09:00" + failed = report(Transport(pages=[{"status": "020"}], flow={})) + intelligence.save_market_intelligence(failed, tmp_path) + assert intelligence.read_market_intelligence(tmp_path / "latest.json")["status"] == "unavailable" + assert all(path.exists() for path in first_versions) + assert len(list((tmp_path / "snapshots").glob("*.json"))) == 2 + clock[0] = "2026-10-13T14:00:00+09:00" + changed = Transport() + changed.pages[0]["list"][0]["rm"] = "철" + intelligence.save_market_intelligence(report(changed), tmp_path) + row = intelligence.read_market_intelligence(tmp_path / "latest.json")["disclosures"]["records"][0] + assert row["previous_revision_id"] is not None + + +def test_immutable_atomic_write_never_replaces_existing_evidence(tmp_path): + path = tmp_path / "version.json" + intelligence._atomic_json(path, {"version": 1}, immutable=True) + with pytest.raises(FileExistsError): + intelligence._atomic_json(path, {"version": 2}, immutable=True) + assert json.loads(path.read_text()) == {"version": 1} + assert not list(tmp_path.glob("*.tmp")) + + +def test_revision_reversion_then_failure_links_next_to_latest_observation(clock, tmp_path): + first = report() + intelligence.save_market_intelligence(first, tmp_path) + clock[0] = "2026-10-13T13:00:00+09:00" + second_source = Transport() + second_source.pages[0]["list"][0]["rm"] = "정철" + second = report(second_source) + intelligence.save_market_intelligence(second, tmp_path) + clock[0] = "2026-10-13T14:00:00+09:00" + intelligence.save_market_intelligence(report(), tmp_path) # Provider reverts to A. + clock[0] = "2026-10-13T15:00:00+09:00" + intelligence.save_market_intelligence(report(Transport(pages=[{"status": "020"}], flow={})), tmp_path) + clock[0] = "2026-10-13T16:00:00+09:00" + third_source = Transport() + third_source.pages[0]["list"][0]["rm"] = "철" + intelligence.save_market_intelligence(report(third_source), tmp_path) + latest = intelligence.read_market_intelligence(tmp_path / "latest.json")["disclosures"]["records"][0] + assert latest["previous_revision_id"] == first["disclosures"]["records"][0]["revision_id"] + assert latest["previous_revision_id"] != second["disclosures"]["records"][0]["revision_id"] + + +@pytest.mark.parametrize("mutation", ["future", "missing_time", "naive_time", "tampered_quantity", "inconsistent_status", "duplicate"]) +def test_reader_rejects_invalid_schema_or_provenance(clock, tmp_path, mutation): + result = report() + if mutation == "future": + result["generated_at"] = "2026-10-14T13:00:00+09:00" + elif mutation == "missing_time": + result["generated_at"] = None + elif mutation == "naive_time": + result["generated_at"] = "2026-10-13T12:00:00" + elif mutation == "tampered_quantity": + result["investor_flows"]["records"][0]["individual_net_buy_shares"] = 88888 + elif mutation == "inconsistent_status": + result["disclosures"]["status"] = "unavailable" + else: + result["disclosures"]["records"].append(deepcopy(result["disclosures"]["records"][0])) + path = tmp_path / "invalid.json" + path.write_text(json.dumps(result), encoding="utf-8") + assert intelligence.read_market_intelligence(path)["status"] == "unavailable" + + +def test_reader_rechecks_session_and_default_configuration(clock, tmp_path, monkeypatch): + intelligence.save_market_intelligence(report(), tmp_path) + monkeypatch.setattr(intelligence, "intelligence_directory", lambda: tmp_path) + monkeypatch.setattr(intelligence, "load_intelligence_config", config) + assert intelligence.read_market_intelligence()["status"] == "ready" + changed = config() + changed["universe"] = [SPEC, {"symbol": "035420", "name": "NAVER"}] + monkeypatch.setattr(intelligence, "load_intelligence_config", lambda: changed) + assert intelligence.read_market_intelligence()["config_changed"] is True + assert intelligence.read_market_intelligence()["status"] == "stale" + clock[0] = "2026-10-14T09:00:00+09:00" + assert intelligence.read_market_intelligence(tmp_path / "latest.json")["status"] == "stale" + + +def test_replay_keeps_actual_observation_not_backdated_to_evaluation(clock): + result = report(now="2026-10-08T12:00:00+09:00") + assert result["replay"] is True + assert result["evaluation_at"] == "2026-10-08T12:00:00+09:00" + assert result["generated_at"] == clock[0] + assert all(row["available_to_system_at"] == clock[0] for row in result["investor_flows"]["records"]) + + +def test_modified_immutable_version_is_not_silently_reused(clock, tmp_path): + intelligence.save_market_intelligence(report(), tmp_path) + path = next((tmp_path / "versions").glob("*.json")) + data = json.loads(path.read_text(encoding="utf-8")) + data["provider"] = "tampered" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(ValueError, match="immutable evidence"): + intelligence.save_market_intelligence(report(), tmp_path) + + +def test_weekend_does_not_expire_same_completed_session_after_36_hours(clock, tmp_path): + clock[0] = "2026-10-17T08:30:00+09:00" + dates = ["2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16"] + intelligence.save_market_intelligence(report(Transport(flow=flows(dates))), tmp_path) + clock[0] = "2026-10-19T08:00:00+09:00" + result = intelligence.read_market_intelligence(tmp_path / "latest.json") + assert result["expected_session"] == result["read_expected_session"] == "2026-10-16" + assert result["status"] == "ready" and result["is_stale"] is False + clock[0] = "2026-10-20T08:00:00+09:00" + assert intelligence.read_market_intelligence(tmp_path / "latest.json")["status"] == "stale" diff --git a/tests/test_market_research.py b/tests/test_market_research.py new file mode 100644 index 00000000..bded9310 --- /dev/null +++ b/tests/test_market_research.py @@ -0,0 +1,241 @@ +from copy import deepcopy +import json +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from core.market_research import (build_market_research, collect_market_research, expected_session, + load_research_config, read_market_research, save_market_research) + + +def config(): + return {"benchmark": {"symbol": "069500", "name": "Index", "cohort": "benchmark", "role": "benchmark"}, + "horizons": [20, 60, 120], "ranking_horizon": 60, + "universe": [{"symbol": "005930", "name": "A", "cohort": "stocks", "role": "basket_target"}, + {"symbol": "091160", "name": "ETF", "cohort": "etfs", "role": "observation_only"}]} + + +def frame(rate=0.002, end="2026-10-12", periods=150): + dates = pd.bdate_range(end=end, periods=periods) + return pd.DataFrame({"close": 100 * (1 + rate) ** np.arange(periods), "volume": 1000.}, index=dates) + + +def report(prices=None, sources=None, **kwargs): + return build_market_research(config(), prices or {"069500": frame(.001), "005930": frame(.002), "091160": frame(.003)}, + sources=sources or {s: "FinanceDataReader" for s in ("069500", "005930", "091160")}, + generated_at="2026-10-13T11:00:00+09:00", holidays=[], **kwargs) + + +def test_completed_session_excludes_holiday_weekend_and_current_even_after_close(): + assert expected_session("2026-10-12T11:00:00+09:00", holidays=["2026-10-09"]) == "2026-10-08" + assert expected_session("2026-10-13T19:00:00+09:00", holidays=[]) == "2026-10-12" + assert expected_session("2026-10-12T23:59:00+00:00", holidays=[]) == "2026-10-12" + + +def test_weekend_reader_uses_same_verified_session_without_wall_clock_expiry(tmp_path): + prices = {symbol: frame(end="2026-10-16") for symbol in ("069500", "005930", "091160")} + output = build_market_research(config(), prices, sources={symbol: "FinanceDataReader" for symbol in prices}, + generated_at="2026-10-17T08:30:00+09:00", holidays=[]) + paths = save_market_research(output, tmp_path) + monday = read_market_research(paths["latest"], now="2026-10-19T08:00:00+09:00") + assert monday["status"] == "ready" + assert monday["read_expected_session"] == monday["as_of"] == "2026-10-16" + assert read_market_research(paths["latest"], now="2026-10-20T08:00:00+09:00")["status"] == "stale" + + +def test_exact_horizon_and_volume_baseline_excludes_latest(): + prices = {"069500": frame(.001), "005930": frame(.002), "091160": frame(.003)} + prices["005930"].iloc[-1, prices["005930"].columns.get_loc("volume")] = 2000 + output = report(prices) + row = output["observations"][0] + assert output["status"] == "ready" + assert row["horizons"]["20"]["return_pct"] == pytest.approx(((1.002**20) - 1)*100, abs=1e-6) + assert row["horizons"]["20"]["excess_pct"] == pytest.approx(((1.002**20) - (1.001**20))*100, abs=1e-6) + assert row["volume_ratio_20d"] == 2 + assert row["drawdown_120d_pct"] == 0 + assert row["above_sma60"] is True + assert output["breadth"][0]["total"] == 1 + assert all(row["rank"] == 1 for row in output["observations"]) # stocks never compete with ETF + + +def test_current_and_future_bars_cannot_change_report_metrics(): + prices = {"069500": frame(.001), "005930": frame(.002), "091160": frame(.003)} + expected = report(prices) + for data in prices.values(): + data.loc[pd.Timestamp("2026-10-13")] = [9999999, 9999999] + data.loc[pd.Timestamp("2026-10-14")] = [0.01, 9999999] + assert report(prices) == expected + + +def test_stale_asset_is_never_compared_over_its_own_endpoint(): + prices = {"069500": frame(.001), "005930": frame(.002).iloc[:-1], "091160": frame(.003)} + result = report(prices) + row = result["observations"][0] + assert row["as_of"] == "2026-10-09" + assert row["rank"] is None + assert row["horizons"]["20"] is None + assert row["missing_sessions"] == 1 + assert result["coverage"]["ranked"] == 1 + + +def test_stale_benchmark_prevents_all_rankings(): + result = report({"069500": frame(.001).iloc[:-1], "005930": frame(.002), "091160": frame(.003)}) + assert result["status"] == "unavailable" + assert result["as_of"] is None + assert result["leaders"] == [] + + +def test_halt_or_gap_inside_window_is_not_silently_forward_filled(): + prices = {"069500": frame(.001), "005930": frame(.002).drop(frame().index[-8]), "091160": frame(.003)} + row = report(prices)["observations"][0] + assert row["horizons"]["20"] is None + assert row["volatility_20d_pct"] is None + assert row["rank"] is None + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), 0, -1]) +def test_invalid_price_remains_unknown(bad): + prices = {"069500": frame(.001), "005930": frame(.002), "091160": frame(.003)} + prices["005930"].iloc[-1, 0] = bad + row = report(prices)["observations"][0] + assert row["rank"] is None + assert row["horizons"]["20"] is None + json.dumps(row, allow_nan=False) + + +def test_duplicate_dates_are_ambiguous_not_arbitrarily_selected(): + prices = {"069500": frame(.001), "005930": pd.concat([frame(), frame().tail(1)]), "091160": frame(.003)} + row = report(prices)["observations"][0] + assert row["status"] == "unavailable" + assert "duplicate_session" in row["issues"] + + +def test_insufficient_history_stays_missing_instead_of_annualized_or_shortened(): + row = report({"069500": frame(.001), "005930": frame(.002, periods=35), "091160": frame(.003)})["observations"][0] + assert row["horizons"]["20"] is not None + assert row["horizons"]["60"] is None + assert row["horizons"]["120"] is None + assert row["rank"] is None + + +def test_unknown_adjustment_basis_and_raw_kis_data_not_ranked(): + output = report(sources={"069500": "FinanceDataReader", "005930": "KIS", "091160": "offline_csv"}) + assert output["status"] == "unavailable" + assert all(row["rank"] is None and "adjustment_unverified" in row["issues"] for row in output["observations"]) + + +def test_zero_volume_baseline_is_unknown_not_infinite_activity(): + prices = {"069500": frame(.001), "005930": frame(.002), "091160": frame(.003)} + prices["005930"]["volume"] = 0 + row = report(prices)["observations"][0] + assert row["volume_ratio_20d"] is None + assert "volume_unavailable" in row["issues"] + + +def test_lead_changes_require_same_config_and_same_eligible_members(): + cfg = config() + cfg["universe"][1]["cohort"] = "stocks" + prices = {"069500": frame(.001), "005930": frame(.002), "091160": frame(.003)} + kwargs = {"sources": {s: "FinanceDataReader" for s in prices}, "generated_at": "2026-10-13", "holidays": []} + previous = build_market_research(cfg, prices, **kwargs) + previous["as_of"] = "2026-10-09" + previous["observations"][0]["rank"], previous["observations"][1]["rank"] = 1, 2 + assert len(build_market_research(cfg, prices, previous=previous, **kwargs)["lead_changes"]) == 2 + previous["observations"][1]["rank"] = None + assert build_market_research(cfg, prices, previous=previous, **kwargs)["lead_changes"] == [] + previous["config_hash"] = "other" + assert build_market_research(cfg, prices, previous=previous, **kwargs)["lead_changes"] == [] + + +def test_read_recomputes_freshness_without_modifying_saved_file(tmp_path, monkeypatch): + import core.market_research as module + original = report() + monkeypatch.setattr(module, "expected_session", lambda now=None, **kwargs: str(now)[:10]) + paths = save_market_research(original, tmp_path) + text = (tmp_path / "latest.json").read_text(encoding="utf-8") + output = read_market_research(paths["latest"], now="2026-10-14T11:00:00+09:00") + assert output["status"] == "stale" + assert output["is_stale"] is True + assert (tmp_path / "latest.json").read_text(encoding="utf-8") == text + save_market_research(original, tmp_path) + assert len(list((tmp_path / "snapshots").glob("*.json"))) == 1 + + +@pytest.mark.parametrize("payload", [[], {}, {"schema_version": 99}, {"schema_version": 1, "research_only": False}]) +def test_reader_invalid_schema_is_unavailable(tmp_path, payload): + path = tmp_path / "latest.json" + path.write_text(json.dumps(payload), encoding="utf-8") + assert read_market_research(path)["status"] == "unavailable" + + +def test_collector_retains_safe_error_class_only_and_has_no_write_api(monkeypatch): + import core.market_research as module + monkeypatch.setattr(module, "expected_session", lambda *args, **kwargs: "2026-10-12") + monkeypatch.setattr(module, "_calendar", lambda *args: set()) + class FakeCollector: + def fetch_korean_stock(self, symbol, start_date, end_date): + assert end_date == "2026-10-12" + if symbol == "005930": + raise RuntimeError("secret_api_key=private") + return frame() + def get_last_source_info(self): + return {"source": "FinanceDataReader"} + output = collect_market_research(config(), FakeCollector(), now="2026-10-13") + assert output["collection_failures"] == {"005930": "RuntimeError"} + assert "private" not in json.dumps(output) + + +def test_invalid_universe_bounded_and_default_config_is_observation_only(): + cfg = config() + cfg["universe"] *= 51 + with pytest.raises(ValueError): + build_market_research(cfg, {}, holidays=[]) + default = load_research_config() + assert all(row["role"] in {"basket_target", "observation_only"} for row in default["universe"]) + + +def test_mixed_provider_tail_without_scale_proof_has_no_return_or_rank(): + result = report(sources={"069500": "FinanceDataReader", "005930": "FinanceDataReader+yfinance", "091160": "FinanceDataReader"}) + row = result["observations"][0] + assert "mixed_price_sources" in row["issues"] + assert row["rank"] is None and row["horizons"]["20"] is None + assert row["above_sma60"] is None + + +def test_mixed_benchmark_does_not_leak_uncomparable_excess_returns(): + result = report(sources={"069500": "FinanceDataReader+yfinance", "005930": "FinanceDataReader", "091160": "FinanceDataReader"}) + assert result["status"] == "unavailable" + assert all(row["horizons"]["60"] is None for row in result["observations"]) + + +def test_offline_builder_marks_replay_and_keeps_generation_separate_from_cutoff(): + assert report()["replay"] is True + output = build_market_research(config(), {}, generated_at="2026-10-14T11:00:00+09:00", + evaluation_at="2026-10-13T11:00:00+09:00", holidays=[]) + assert output["expected_session"] == "2026-10-12" + assert output["generated_at"].startswith("2026-10-14") + assert output["evaluation_at"].startswith("2026-10-13") + + +def test_backdated_live_source_provenance_is_rejected(tmp_path): + output = report() + output["replay"] = False + output["observations"][0]["source"]["observed_at"] = "2026-10-14T11:00:00+09:00" + path = tmp_path / "latest.json" + path.write_text(json.dumps(output), encoding="utf-8") + assert read_market_research(path, now="2026-10-15")["status"] == "unavailable" + + +def test_default_reader_detects_changed_universe_even_same_session(tmp_path, monkeypatch): + import core.market_research as module + output = report() + save_market_research(output, tmp_path) + monkeypatch.setenv("QUANT_MARKET_RESEARCH_DIR", str(tmp_path)) + changed = config() + changed["name"] = "changed configuration" + monkeypatch.setattr(module, "load_research_config", lambda: changed) + monkeypatch.setattr(module, "expected_session", lambda now=None: "2026-10-12") + actual = read_market_research(now="2026-10-13T12:00:00+09:00") + assert actual["config_changed"] is True and actual["status"] == "stale" diff --git a/tests/test_observation_benchmark.py b/tests/test_observation_benchmark.py new file mode 100644 index 00000000..33d8f42c --- /dev/null +++ b/tests/test_observation_benchmark.py @@ -0,0 +1,105 @@ +from datetime import datetime +from types import SimpleNamespace + +import pytest +import requests + +from core.observation_benchmark import BenchmarkCollector, BenchmarkUnavailable + + +class Transport: + def __init__(self, rows, status=200): + self.rows, self.status, self.calls = rows, status, [] + + def get(self, url, **kwargs): + self.calls.append((url, kwargs)) + return SimpleNamespace(status_code=self.status, json=lambda: self.rows) + + +def collector(rows, at="2026-10-13T13:00:00+09:00", status=200): + transport = Transport(rows, status) + return BenchmarkCollector(transport=transport, clock=lambda: datetime.fromisoformat(at)), transport + + +def row(day="2026-10-12", price="6,384.24"): + return {"localTradedAt": day, "closePrice": price} + + +def test_exact_previous_session_ignores_current_provisional_row(): + source, transport = collector([row("2026-10-13", "6,500.00"), row()]) + result = source.fetch_korean_stock("KS11", "2026-10-12", "2026-10-12") + assert result["close"].iloc[0] == 6384.24 + assert list(result.index.strftime("%Y-%m-%d")) == ["2026-10-12"] + info = source.get_last_source_info() + assert info["source"] == "NAVERPublicIndex" and info["price_basis"] == "price_index" + assert info["direct_krx_feed"] is False and info["publication_timestamp"] is None + assert info["observed_at"] == "2026-10-13T13:00:00+09:00" + assert info["replay"] is True + assert transport.calls[0][0].endswith("/KOSPI/price") + assert transport.calls[0][1]["timeout"] == 10 + assert transport.calls[0][1]["params"] == {"pageSize": 60, "page": 1} + + +@pytest.mark.parametrize("at,allowed", [("15:59:59", False), ("16:00:00", True)]) +def test_current_day_is_not_available_until_publication_buffer(at, allowed): + source, transport = collector([row("2026-10-13")], at=f"2026-10-13T{at}+09:00") + if allowed: + assert not source.fetch_korean_stock("KS11", "2026-10-13", "2026-10-13").empty + else: + with pytest.raises(BenchmarkUnavailable, match="not_completed"): + source.fetch_korean_stock("KS11", "2026-10-13", "2026-10-13") + assert not transport.calls + + +@pytest.mark.parametrize("price", [None, True, "NaN", float("nan"), float("inf"), "0", "-3", "6,38.24"]) +def test_invalid_close_never_becomes_a_zero_or_filled_price(price): + source, _ = collector([row(price=price)]) + with pytest.raises(BenchmarkUnavailable, match="invalid_benchmark_price"): + source.fetch_korean_stock("KS11", "2026-10-12", "2026-10-12") + assert source.get_last_source_info()["status"] == "unavailable" + + +@pytest.mark.parametrize("rows,reason", [([row(), row()], "duplicate"), + ([row("2026-10-08")], "exact_benchmark_session_missing"), + ([row("2026-99-12")], "invalid_benchmark_date"), + ({"error": "private response"}, "invalid_benchmark_schema")]) +def test_duplicate_missing_or_malformed_dates_fail_closed(rows, reason): + source, _ = collector(rows) + with pytest.raises(BenchmarkUnavailable, match=reason): + source.fetch_korean_stock("KS11", "2026-10-12", "2026-10-12") + + +def test_holiday_is_not_fabricated_in_exact_multisession_window(): + source, _ = collector([row("2026-10-12"), row("2026-10-08", "6,625.93")]) + result = source.fetch_korean_stock("KS11", "2026-10-08", "2026-10-12") + assert list(result.index.strftime("%Y-%m-%d")) == ["2026-10-08", "2026-10-12"] + + +def test_series_mapping_and_failure_do_not_reuse_previous_success(): + source, transport = collector([row(price="1,004.92")]) + assert source.fetch_korean_stock("KS200", "2026-10-12", "2026-10-12")["close"].iloc[0] == 1004.92 + assert transport.calls[0][0].endswith("/KPI200/price") + transport.rows = [] + with pytest.raises(BenchmarkUnavailable): + source.fetch_korean_stock("KS200", "2026-10-12", "2026-10-12") + assert source.get_last_source_info()["status"] == "unavailable" + assert "quote_dates" not in source.get_last_source_info() + + +def test_future_and_unbounded_history_never_request_network(): + source, transport = collector([row()]) + for day in ("2026-10-14", "2020-01-01"): + with pytest.raises(BenchmarkUnavailable): + source.fetch_korean_stock("KS11", day, day) + assert not transport.calls + + +def test_request_errors_and_body_are_not_exposed(): + source, transport = collector([row()]) + def fail(*args, **kwargs): + raise requests.Timeout("https://example?private=SECRET") + transport.get = fail + with pytest.raises(BenchmarkUnavailable) as error: + source.fetch_korean_stock("KS11", "2026-10-12", "2026-10-12") + assert "SECRET" not in str(error.value) + assert str(error.value) == "benchmark_request_failed:Timeout" diff --git a/tests/test_paper_validation.py b/tests/test_paper_validation.py new file mode 100644 index 00000000..52b176a6 --- /dev/null +++ b/tests/test_paper_validation.py @@ -0,0 +1,580 @@ +"""No operating DB, public price network or real-clock dependencies.""" +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +import pandas as pd +import pytest +import yaml + +from core import paper_validation as pv + + +class Prices: + def __init__(self): + self.values = {} + self.calls = [] + self.source = "FinanceDataReader" + + def fetch_korean_stock(self, symbol, start, end): + self.calls.append((symbol, start, end)) + value = self.values.get(start) + return pd.DataFrame({"close": [value]}, index=pd.to_datetime([start])) if value else pd.DataFrame() + + def get_last_source_info(self): + return {"source": self.source} + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + root = tmp_path / "project" + config = root / "config" + config.mkdir(parents=True) + protocol = {"schema_version": 1, "protocol_version": "test-v1", "minimum_observations": 2, + "minimum_calendar_days": 2, "minimum_regime_observations": 1, "maximum_missing_ratio": 0.1, + "maximum_capture_delay_sessions": 2, + "baskets": {"test": {"benchmark": {"symbol": "KS11", "name": "KOSPI", "basis": "price_index", "provider": "data_collector"}}}} + policies = {"baskets": {"test": {"enabled": True, "name": "test", "target_stock_weight": 1.0, + "holdings": {"005930": 1.0}, "rebalance": {"min_trade_amount": 50000}}}} + values = {"paper_validation.yaml": protocol, "baskets.yaml": policies, + "settings.yaml": {"trading": {"market_close": "15:30"}, "data_source": {"api_secret": "must-not-leak"}}, + "risk_params.yaml": {"position_sizing": {"initial_capital": 100}}, + "holidays.yaml": {"holidays": ["2026-01-01", "2027-01-01"]}} + for name, value in values.items(): + (config / name).write_text(yaml.safe_dump(value), encoding="utf-8") + for name in pv.OPERATING_CODE_FILES: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# operating version 1\n", encoding="utf-8") + monkeypatch.setattr(pv, "ROOT", root) + clock = [datetime.fromisoformat("2026-10-13T07:00:00+09:00").astimezone(timezone.utc)] + monkeypatch.setattr(pv, "_utc_now", lambda: clock[0]) + db = tmp_path / "ledger.db" + with sqlite3.connect(db) as connection: + connection.executescript(""" + CREATE TABLE portfolio_valuations(id INTEGER PRIMARY KEY, mode TEXT, account_key TEXT, + date TEXT, measured_at TEXT, recorded_at TEXT, kind TEXT, total_value REAL, cash REAL, + cumulative_return REAL, daily_return REAL, mdd REAL, detail TEXT, fingerprint TEXT); + CREATE TABLE trade_history(id INTEGER PRIMARY KEY, mode TEXT, account_key TEXT, + executed_at TEXT, action TEXT, total_amount REAL, commission REAL, tax REAL, slippage REAL); + CREATE TABLE cash_flows(id INTEGER PRIMARY KEY, mode TEXT, account_key TEXT, + occurred_at TEXT, amount REAL); + """) + folder = tmp_path / "evidence" + prices = Prices() + + def at(value): + clock[0] = datetime.fromisoformat(value).astimezone(timezone.utc) + + def close(day, nav=100, cumulative=0, *, flow=0, baseline_nav=None, baseline_cumulative=None, + source="FinanceDataReader", measured=None, recorded=None): + # The intra-day baseline itself may already include returns since prior close. + baseline_nav = nav - flow if baseline_nav is None else baseline_nav + baseline_cumulative = cumulative if baseline_cumulative is None else baseline_cumulative + measured = measured or f"{day}T15:30:00" + with sqlite3.connect(db) as connection: + base = connection.execute("INSERT INTO portfolio_valuations(mode,account_key,date,measured_at,recorded_at,kind,total_value,cash,cumulative_return,detail) VALUES(?,?,?,?,?,?,?,?,?,?)", + ("paper", "basket_rebalance:test", day, f"{day}T10:00:00", f"{day}T10:01:00", "intraday", baseline_nav, baseline_nav, baseline_cumulative, "{}")).lastrowid + context = {"return_method": "linked_measurements_start_period_flows", "quote_dates": [day], + "positions": [{"symbol": "005930", "quantity": 1, "price": nav, + "quote_date": day, "source": source, "observed_at": f"{day}T16:00:00"}], + "baseline_valuation_id": base, "baseline_measured_at": f"{day}T10:00:00", "flow_since": flow} + row = connection.execute("INSERT INTO portfolio_valuations(mode,account_key,date,measured_at,recorded_at,kind,total_value,cash,cumulative_return,detail,fingerprint) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + ("paper", "basket_rebalance:test", day, measured, recorded or f"{day}T16:00:00", "close", nav, 0, cumulative, json.dumps(context), f"close-{base}")).lastrowid + return row + + def collect(): + return pv.collect_paper_validation(db, config_path=config / "paper_validation.yaml", output_dir=folder, collector=prices) + + def write(name, value): + (config / name).write_text(yaml.safe_dump(value), encoding="utf-8") + + return {"root": root, "db": db, "folder": folder, "prices": prices, "at": at, "close": close, + "collect": collect, "write": write, "protocol": protocol, "policies": policies} + + +def active(result): + return next(row for row in result["cohorts"] if row["active"]) + + +def test_existing_closes_are_context_only_and_registration_never_backdates(setup): + s = setup + s["close"]("2026-10-12") + before = s["db"].read_bytes() + report = s["collect"]() + row = active(report) + assert row["observations"] == row["return_observations"] == 0 + assert row["historical_closes_excluded"] == 1 + assert row["status"] == "awaiting_forward" + assert row["performance"]["twr_pct"] is None + assert row["costs"]["trade_count"] is None + assert not report["automatic_live_promotion"] + assert s["prices"].calls == [] + assert s["db"].read_bytes() == before + assert "must-not-leak" not in "".join(path.read_text(encoding="utf-8") for path in (s["folder"] / "events").glob("*.json")) + + +def test_future_closes_twr_neutralizes_deposit_and_records_costs_regimes(setup): + s = setup + s["collect"]() + s["close"]("2026-10-13", nav=100, cumulative=0) + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-14T07:00:00+09:00") + assert active(s["collect"]())["observations"] == 1 + # A 100 deposit and 2% return produce 204 NAV, not a 104% investment return. + s["close"]("2026-10-14", nav=204, cumulative=2, flow=100, baseline_nav=100, baseline_cumulative=0) + with sqlite3.connect(s["db"]) as connection: + connection.execute("INSERT INTO cash_flows VALUES(1,'paper','basket_rebalance:test','2026-10-14T12:00:00',100)") + connection.execute("INSERT INTO trade_history VALUES(1,'paper','basket_rebalance:test','2026-10-14T11:00:00','BUY',50,1,2,3)") + connection.execute("INSERT INTO trade_history VALUES(2,'live','basket_rebalance:test','2026-10-14T11:00:00','BUY',999,9,9,9)") + s["prices"].values["2026-10-14"] = 101 + s["at"]("2026-10-15T07:00:00+09:00") + row = active(s["collect"]()) + assert row["performance"]["twr_pct"] == 2 + assert row["performance"]["benchmark_return_pct"] == 1 + assert row["performance"]["excess_return_pct"] == 1 + assert row["costs"]["explicit_cost"] == 3 + assert row["costs"]["modeled_slippage_embedded"] == 3 + assert row["costs"]["gross_turnover_ratio"] == pytest.approx(50 / 152) + assert row["costs"]["net_external_flow"] == 100 + assert row["regimes"]["up"]["observations"] == 1 + assert row["regimes"]["down"]["portfolio_return_pct"] is None + s["close"]("2026-10-15", nav=202, cumulative=1) + s["prices"].values["2026-10-15"] = 100 + s["at"]("2026-10-16T07:00:00+09:00") + row = active(s["collect"]()) + assert row["status"] == "ready_for_review" + assert row["return_observations"] == 2 + assert row["regimes"]["down"]["observations"] == 1 + assert row["profitability_pass"] is None and row["automatic_live_promotion"] is False + + +def test_benchmark_retry_appends_later_evidence_without_rewriting_failed_attempt(setup): + s = setup + s["collect"]() + s["close"]("2026-10-13") + s["at"]("2026-10-14T07:00:00+09:00") + assert "benchmark_missing" in active(s["collect"]())["issues"] + original = {path: path.read_bytes() for path in (s["folder"] / "events").glob("*.json")} + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-14T08:00:00+09:00") + row = active(s["collect"]()) + assert "benchmark_missing" not in row["issues"] + assert all(path.read_bytes() == value for path, value in original.items()) + retries = [event for event in pv._load_events(s["folder"]) if event["kind"] == "benchmark_observed"] + assert len(retries) == 1 + assert retries[0]["data"]["benchmark"]["observed_at"] == "2026-10-13T23:00:00.000000Z" + + +def test_missing_days_are_not_one_day_returns_or_recovered_as_forward(setup): + s = setup + s["collect"]() + s["close"]("2026-10-13") + s["prices"].values.update({"2026-10-13": 100, "2026-10-15": 103}) + s["at"]("2026-10-14T07:00:00+09:00") + s["collect"]() + s["close"]("2026-10-15", nav=103, cumulative=3) + s["at"]("2026-10-16T07:00:00+09:00") + row = active(s["collect"]()) + assert row["performance"]["twr_pct"] == 3 + assert row["return_observations"] == 0 + assert row["coverage"]["missing_sessions"] == ["2026-10-14"] + s["close"]("2026-10-14", nav=102, cumulative=2) + s["at"]("2026-10-20T07:00:00+09:00") + row = active(s["collect"]()) + assert row["observations"] == 2 + assert "2026-10-14" in row["coverage"]["missing_sessions"] + assert row["status"] == "stale" + + +def test_long_offline_closes_are_excluded_even_when_measured_after_registration(setup): + s = setup + s["collect"]() + s["close"]("2026-10-13") + s["at"]("2026-10-20T07:00:00+09:00") + row = active(s["collect"]()) + assert row["observations"] == 0 + assert "2026-10-13" in row["coverage"]["missing_sessions"] + assert not s["prices"].calls + + +@pytest.mark.parametrize("change", ["policy", "code", "protocol"]) +def test_policy_code_protocol_change_starts_separate_empty_cohort(setup, change): + s = setup + first = active(s["collect"]()) + s["close"]("2026-10-13") + s["at"]("2026-10-14T07:00:00+09:00") + s["prices"].values["2026-10-13"] = 100 + s["collect"]() + if change == "policy": + s["policies"]["baskets"]["test"]["target_stock_weight"] = 0.8 + s["write"]("baskets.yaml", s["policies"]) + elif change == "code": + (s["root"] / pv.OPERATING_CODE_FILES[0]).write_text("# version 2", encoding="utf-8") + else: + s["protocol"]["minimum_observations"] = 20 + s["write"]("paper_validation.yaml", s["protocol"]) + s["at"]("2026-10-14T08:00:00+09:00") + report = s["collect"]() + row = active(report) + assert row["cohort_id"] != first["cohort_id"] + assert row["observations"] == 0 + assert row["historical_closes_excluded"] == 1 + old = next(row for row in report["cohorts"] if not row["active"]) + assert old["observations"] == 1 + assert ("config_changed" if change == "policy" else change + "_changed") in row["registration_reason"] + + +def test_ui_commentary_changes_do_not_restart_cohort(setup): + s = setup + first = active(s["collect"]()) + (s["root"] / "main.py").write_text("# scheduling change", encoding="utf-8") + s["policies"]["baskets"]["test"]["name"] = "new label" + s["policies"]["baskets"]["test"]["monitoring"] = {"deployment_tolerance": 0.03} + s["write"]("baskets.yaml", s["policies"]) + assert active(s["collect"]())["cohort_id"] == first["cohort_id"] + + +def test_reader_does_not_create_files_or_query_database_and_detects_staleness(setup, monkeypatch): + s = setup + absent = s["folder"] / "absent" + assert pv.read_paper_validation(absent)["status"] == "not_started" + assert not absent.exists() + s["collect"]() + original = sorted(s["folder"].rglob("*")) + monkeypatch.setattr(sqlite3, "connect", lambda *a, **k: pytest.fail("reader opened database")) + s["at"]("2026-10-14T07:00:00+09:00") + row = active(pv.read_paper_validation(s["folder"])) + assert row["status"] == "stale" + assert "assessment_stale" in row["issues"] + assert sorted(s["folder"].rglob("*")) == original + + +def test_default_reader_flags_policy_change_before_next_collection(setup, monkeypatch): + s = setup + s["collect"]() + monkeypatch.setenv("QUANT_PAPER_VALIDATION_DIR", str(s["folder"])) + assert pv.read_paper_validation()["status"] == "observing" + s["policies"]["baskets"]["test"]["target_stock_weight"] = 0.5 + s["write"]("baskets.yaml", s["policies"]) + result = pv.read_paper_validation() + assert active(result)["status"] == "stale" + assert "policy_changed_pending_collection" in active(result)["issues"] + + +def test_unknown_calendar_year_cannot_leave_an_active_cohort_healthy(setup): + s = setup + s["collect"]() + s["at"]("2028-06-01T07:00:00+09:00") + result = pv.read_paper_validation(s["folder"]) + assert result["status"] == "unavailable" + assert active(result)["status"] == "stale" + assert "trading_calendar_unavailable" in active(result)["issues"] + + +def test_database_missing_does_not_create_it_and_missing_tables_stay_unknown(setup): + s = setup + missing = s["db"].parent / "absent.db" + report = pv.collect_paper_validation(missing, output_dir=s["folder"]) + assert report["status"] == "unavailable" and not missing.exists() + with sqlite3.connect(s["db"]) as connection: + connection.execute("DROP TABLE trade_history") + s["collect"]() + s["close"]("2026-10-13") + s["close"]("2026-10-14") + s["prices"].values.update({"2026-10-13": 100, "2026-10-14": 101}) + s["at"]("2026-10-15T07:00:00+09:00") + row = active(s["collect"]()) + assert row["costs"]["trade_count"] is None + assert row["costs"]["explicit_cost"] is None + assert "trade_history_missing" in row["issues"] + + +def test_invalid_yaml_fails_without_echoing_configuration_or_credentials(setup): + s = setup + (s["root"] / "config" / "settings.yaml").write_text("private_secret: [must-not-leak\n", encoding="utf-8") + result = s["collect"]() + assert result["status"] == "unavailable" + assert "must-not-leak" not in json.dumps(result) + assert "must-not-leak" not in "".join(path.read_text(encoding="utf-8") for path in (s["folder"] / "events").glob("*.json")) + + +def test_mutated_ledger_is_not_silently_accepted(setup): + s = setup + s["collect"]() + first_id = s["close"]("2026-10-13") + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-14T07:00:00+09:00") + s["collect"]() + with sqlite3.connect(s["db"]) as connection: + connection.execute("UPDATE portfolio_valuations SET total_value=999 WHERE id=?", (first_id,)) + row = active(s["collect"]()) + assert "observed_ledger_revised_or_missing" in row["issues"] + assert not row["requirements"]["integrity"] + assert row["performance"]["twr_pct"] is None + + +@pytest.mark.parametrize("mutation", ["late_trade", "late_flow", "baseline"]) +def test_late_interval_inserts_and_revised_baselines_invalidate_evidence(setup, mutation): + s = setup + s["collect"]() + s["close"]("2026-10-13") + s["close"]("2026-10-14", nav=101, cumulative=1) + s["prices"].values.update({"2026-10-13": 100, "2026-10-14": 101}) + s["at"]("2026-10-15T07:00:00+09:00") + assert active(s["collect"]())["requirements"]["integrity"] + with sqlite3.connect(s["db"]) as connection: + if mutation == "late_trade": + connection.execute("INSERT INTO trade_history VALUES(99,'paper','basket_rebalance:test','2026-10-14T11:00:00','BUY',50,1,2,3)") + elif mutation == "late_flow": + connection.execute("INSERT INTO cash_flows VALUES(99,'paper','basket_rebalance:test','2026-10-14T09:00:00',100)") + else: + connection.execute("UPDATE portfolio_valuations SET cumulative_return=999 WHERE kind='intraday' AND date='2026-10-14'") + row = active(s["collect"]()) + assert row["status"] == "data_gaps" + assert "observed_ledger_revised_or_missing" in row["issues"] + assert not row["requirements"]["integrity"] + assert row["performance"]["twr_pct"] is None + + +@pytest.mark.parametrize("bad", ["quote", "time", "cash_flow", "twr"]) +def test_unverified_closes_never_count_as_matched_return_evidence(setup, bad): + s = setup + s["collect"]() + arguments = {"source": "unknown"} if bad == "quote" else {"measured": "2026-10-13T11:00:00"} if bad == "time" else {"flow": 100} if bad == "cash_flow" else {"baseline_cumulative": 55} + s["close"]("2026-10-13", **arguments) + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-14T07:00:00+09:00") + row = active(s["collect"]()) + assert row["return_observations"] == 0 + assert row["status"] == "data_gaps" + assert not row["requirements"]["integrity"] + + +@pytest.mark.parametrize("mutation", ["baseline_recorded_in_future", "quote_after_close_recording"]) +def test_source_close_cannot_claim_inputs_observed_after_its_recording(setup, mutation): + s = setup + s["collect"]() + close_id = s["close"]("2026-10-13") + with sqlite3.connect(s["db"]) as connection: + if mutation == "baseline_recorded_in_future": + connection.execute("UPDATE portfolio_valuations SET recorded_at='2026-10-15T10:00:00' WHERE kind='intraday'") + else: + connection.execute("UPDATE portfolio_valuations SET recorded_at='2026-10-13T15:45:00' WHERE id=?", (close_id,)) + s["at"]("2026-10-14T07:00:00+09:00") + row = active(s["collect"]()) + assert row["observations"] == 0 and not row["requirements"]["integrity"] + + +def test_current_session_future_record_and_clock_rollback_not_forward(setup): + s = setup + s["collect"]() + s["close"]("2026-10-13", recorded="2026-10-15T16:00:00") + s["at"]("2026-10-13T17:00:00+09:00") + assert active(s["collect"]())["observations"] == 0 + s["at"]("2026-10-14T07:00:00+09:00") + assert active(s["collect"]())["observations"] == 0 + s["at"]("2026-10-12T07:00:00+09:00") + with pytest.raises(ValueError, match="clock_rollback"): + s["collect"]() + + +def test_tampered_journal_fails_closed_and_os_lock_releases(setup): + s = setup + s["collect"]() + with pv._exclusive_lock(s["folder"]): + with pytest.raises(ValueError, match="already_running"): + s["collect"]() + assert s["collect"]()["status"] == "observing" + path = sorted((s["folder"] / "events").glob("*.json"))[0] + value = json.loads(path.read_text(encoding="utf-8")) + value["data"]["started_at"] = "2020-01-01T00:00:00Z" + path.write_text(json.dumps(value), encoding="utf-8") + assert pv.read_paper_validation(s["folder"])["status"] == "corrupt" + with pytest.raises(ValueError, match="integrity_error"): + s["collect"]() + + +def direct_close(s, day, *, cash=100, positions=None, prior=None, flow=0, index=1, changes=None): + """Source fixture only: no inferred historical return or fabricated baseline.""" + positions = positions or [] + measured = f"{day}T15:30:00" + with sqlite3.connect(s["db"]) as connection: + columns = {row[1] for row in connection.execute("PRAGMA table_info(trade_history)")} + if "symbol" not in columns: + connection.execute("ALTER TABLE trade_history ADD COLUMN symbol TEXT") + connection.execute("ALTER TABLE trade_history ADD COLUMN quantity INTEGER") + baseline = connection.execute("SELECT measured_at FROM portfolio_valuations WHERE id=?", (prior,)).fetchone() if prior else None + trade_ids = [row[0] for row in connection.execute("SELECT id FROM trade_history WHERE mode='paper' AND account_key='basket_rebalance:test' AND executed_at<=?", (measured,))] + context = {"return_method": pv.DIRECT_CLOSE_METHOD, "first_close_in_series": prior is None, + "baseline_valuation_id": prior, "baseline_measured_at": baseline[0] if baseline else measured, + "ledger_cutoff_at": measured, "flow_since": flow, "initial_capital": 100, + "trade_ids": trade_ids, "quote_dates": [day], + "positions": [{"symbol": symbol, "quantity": quantity, "price": price, + "quote_date": day, "source": "FinanceDataReader", "observed_at": f"{day}T16:00:00"} + for symbol, quantity, price in positions]} + context.update(changes or {}) + total = cash + sum(quantity * price for _, quantity, price in positions) + return connection.execute("INSERT INTO portfolio_valuations(mode,account_key,date,measured_at,recorded_at,kind,total_value,cash,cumulative_return,detail,fingerprint) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + ("paper", "basket_rebalance:test", day, measured, f"{day}T16:00:00", "close", total, cash, + (index - 1) * 100, json.dumps(context), "direct-" + day)).lastrowid + + +def test_empty_ledger_registration_has_no_collector_or_price_access(setup, monkeypatch): + s = setup + monkeypatch.setattr(pv, "_make_benchmark_collector", lambda *_: pytest.fail("empty enrollment must not initialize a price client")) + before = s["db"].read_bytes() + report = pv.collect_paper_validation(s["db"], config_path=s["root"] / "config/paper_validation.yaml", output_dir=s["folder"]) + registered = active(report) + assert registered["observations"] == 0 and registered["status"] == "awaiting_forward" + assert s["db"].read_bytes() == before and not s["prices"].calls + direct_close(s, "2026-10-13") + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-13T16:30:00+09:00") + row = active(s["collect"]()) + assert row["cohort_id"] == registered["cohort_id"] and row["observations"] == 1 + assert row["return_observations"] == 0 and row["performance"]["twr_pct"] is None + assert row["issues"] == [] + # Null first-close baseline is valid when re-audited the next morning. + s["at"]("2026-10-14T08:30:00+09:00") + assert active(s["collect"]())["issues"] == [] + + +def test_same_day_direct_closes_use_actual_cutoff_and_neutralize_after_hours_flow(setup): + s = setup + s["collect"]() + # Add columns without modifying the legacy tests' nine-column fixtures. + with sqlite3.connect(s["db"]) as connection: + connection.execute("ALTER TABLE trade_history ADD COLUMN symbol TEXT") + connection.execute("ALTER TABLE trade_history ADD COLUMN quantity INTEGER") + connection.execute("INSERT INTO trade_history VALUES(1,'paper','basket_rebalance:test','2026-10-13T10:00:00','BUY',100,0,0,0,'005930',1)") + connection.execute("INSERT INTO cash_flows VALUES(1,'paper','basket_rebalance:test','2026-10-13T17:00:00',100)") + connection.execute("INSERT INTO trade_history VALUES(2,'paper','basket_rebalance:test','2026-10-13T17:01:00','BUY',100,0,0,0,'005930',1)") + first = direct_close(s, "2026-10-13", cash=0, positions=[("005930", 1, 100)]) + s["prices"].values.update({"2026-10-13": 100, "2026-10-14": 101}) + s["at"]("2026-10-13T15:59:59+09:00") + assert active(s["collect"]())["observations"] == 0 + s["at"]("2026-10-13T16:00:00+09:00") + assert active(s["collect"]())["observations"] == 1 + direct_close(s, "2026-10-14", cash=0, positions=[("005930", 2, 102)], prior=first, flow=100, index=1.02) + s["at"]("2026-10-14T16:30:00+09:00") + row = active(s["collect"]()) + assert row["issues"] == [] and row["return_observations"] == 1 + assert row["performance"]["twr_pct"] == 2 and row["performance"]["benchmark_return_pct"] == 1 + assert row["costs"]["net_external_flow"] == 100 and row["costs"]["trade_count"] == 1 + + +@pytest.mark.parametrize("now,expected", [ + ("2026-10-16T15:59:59+09:00", "2026-10-15"), + ("2026-10-16T16:00:00+09:00", "2026-10-16"), + ("2026-10-17T08:30:00+09:00", "2026-10-16"), + ("2026-10-18T16:30:00+09:00", "2026-10-16"), + ("2026-10-19T08:30:00+09:00", "2026-10-16"), +]) +def test_expected_session_uses_confirmed_close_and_preserves_weekend(now, expected): + assert pv._expected(datetime.fromisoformat(now), {"2026-01-01"}) == expected + assert pv._expected(datetime.fromisoformat("2026-10-19T16:30:00+09:00"), {"2026-01-01", "2026-10-19"}) == "2026-10-16" + assert pv._expected(datetime.fromisoformat("2026-10-19T16:30:00+09:00"), {"2026-01-01"}, "16:30") == "2026-10-16" + + +def test_weekend_reader_remains_fresh_until_next_confirmed_session(setup): + s = setup + s["at"]("2026-10-16T08:30:00+09:00") + s["collect"]() + direct_close(s, "2026-10-16") + s["prices"].values["2026-10-16"] = 100 + s["at"]("2026-10-16T16:30:00+09:00") + assert active(s["collect"]())["issues"] == [] + s["at"]("2026-10-19T08:30:00+09:00") + assert active(pv.read_paper_validation(s["folder"]))["status"] == "insufficient_sample" + s["at"]("2026-10-19T16:00:00+09:00") + assert "assessment_stale" in active(pv.read_paper_validation(s["folder"]))["issues"] + + +@pytest.mark.parametrize("changes,index", [ + ({"initial_capital": 200}, 1), ({"ledger_cutoff_at": "2026-10-13T16:00:00"}, 1), + ({"baseline_valuation_id": 99}, 1), ({"flow_since": 100}, 1), + ({"first_close_in_series": False}, 1), ({"trade_ids": [1, 1]}, 1), ({}, 1.8), +]) +def test_direct_close_bootstrap_cannot_invent_a_baseline_or_prior_profit(setup, changes, index): + s = setup + s["collect"]() + direct_close(s, "2026-10-13", changes=changes, index=index) + s["at"]("2026-10-13T16:30:00+09:00") + row = active(s["collect"]()) + assert row["observations"] == 0 and not row["requirements"]["integrity"] + + +@pytest.mark.parametrize("mutation,issue", [("cash", "close_ledger_cash_mismatch"), ("quantity", "close_ledger_quantity_mismatch"), ("ids", "close_ledger_trade_ids_mismatch")]) +def test_direct_close_full_ledger_must_explain_cash_positions_and_ids(setup, mutation, issue): + s = setup + s["collect"]() + direct_close(s, "2026-10-13", cash=99 if mutation == "cash" else 100, + positions=[("005930", 1, 1)] if mutation == "quantity" else [], + changes={"trade_ids": [99]} if mutation == "ids" else None) + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-13T16:30:00+09:00") + row = active(s["collect"]()) + assert issue in row["issues"] and row["performance"]["status"] == "unverified_ledger" + + +def test_late_insert_before_first_close_invalidates_saved_direct_evidence(setup): + s = setup + s["collect"]() + direct_close(s, "2026-10-13") + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-13T16:30:00+09:00") + assert active(s["collect"]())["issues"] == [] + # These offsetting old entries leave NAV unchanged; membership still changed. + with sqlite3.connect(s["db"]) as connection: + connection.execute("INSERT INTO cash_flows VALUES(1,'paper','basket_rebalance:test','2026-10-13T09:00:00',100)") + connection.execute("INSERT INTO cash_flows VALUES(2,'paper','basket_rebalance:test','2026-10-13T10:00:00',-100)") + row = active(s["collect"]()) + assert "observed_ledger_revised_or_missing" in row["issues"] + assert row["performance"]["status"] == "unverified_ledger" + + +def test_registration_after_close_does_not_relabel_that_day_as_forward(setup): + s = setup + s["at"]("2026-10-13T15:31:00+09:00") + s["collect"]() + direct_close(s, "2026-10-13") + s["at"]("2026-10-13T16:30:00+09:00") + row = active(s["collect"]()) + assert row["observations"] == 0 and not s["prices"].calls + + +def test_benchmark_provider_and_implementation_are_pinned_in_protocol(setup): + s = setup + s["protocol"]["baskets"]["test"]["benchmark"]["provider"] = "naver_public" + s["write"]("paper_validation.yaml", s["protocol"]) + implementation = s["root"] / "core/observation_benchmark.py" + implementation.write_text("# public provider version 1", encoding="utf-8") + first = active(s["collect"]()) + direct_close(s, "2026-10-13") + s["prices"].values["2026-10-13"] = 100 + s["at"]("2026-10-13T16:30:00+09:00") + assert "benchmark_provider_mismatch" in active(s["collect"]())["issues"] + implementation.write_text("# public provider version 2", encoding="utf-8") + second = active(s["collect"]()) + assert second["cohort_id"] != first["cohort_id"] and second["registration_reason"] == ["protocol_changed"] + assert second["observations"] == 0 and second["historical_closes_excluded"] == 1 + + +def test_public_benchmark_preserves_provenance_and_rejects_replay(setup, monkeypatch): + s = setup + s["prices"].values["2026-10-12"] = 100 + metadata = {"source": "NAVERPublicIndex", "source_url": "https://m.stock.naver.com/api/index/KOSPI/price", + "response_hash": "a" * 64, "publication_timestamp": None, "direct_krx_feed": False, + "provider": "NAVER Securities public web", "observed_at": pv._stamp(pv._utc_now()), "replay": False} + monkeypatch.setattr(s["prices"], "get_last_source_info", lambda: metadata) + quote, error = pv._benchmark(s["prices"], "KS11", "2026-10-12", pv._utc_now(), "naver_public") + assert error is None and quote["provenance"]["response_hash"] == "a" * 64 + assert quote["provenance"]["publication_timestamp"] is None + metadata["replay"] = True + assert pv._benchmark(s["prices"], "KS11", "2026-10-12", pv._utc_now(), "naver_public") == (None, "benchmark_replay_not_forward") + metadata.update(replay=False, observed_at="2027-01-01T00:00:00Z") + assert pv._benchmark(s["prices"], "KS11", "2026-10-12", pv._utc_now(), "naver_public") == (None, "benchmark_observation_in_future") diff --git a/tests/test_pytest_directory_isolation.py b/tests/test_pytest_directory_isolation.py new file mode 100644 index 00000000..9f105cb3 --- /dev/null +++ b/tests/test_pytest_directory_isolation.py @@ -0,0 +1,73 @@ +"""Check actual child-process shutdown without touching any caller-owned path.""" +import json +import os +from pathlib import Path +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[1] +ISOLATED_NAMES = ( + "QUANT_RESEARCH_OPERATIONS_DIR", "QUANT_PAPER_VALIDATION_DIR", "QUANT_MARKET_INTELLIGENCE_DIR", + "QUANT_DAILY_OBSERVATION_DIR", + "QUANT_OVERLAY_STATE_DIR", "QUANT_DASHBOARD_STATE_PATH", "QUANT_RUNTIME_LOCK_DIR", +) + + +def _child(script, tmp_path): + env = os.environ.copy() + # Skip ORM setup entirely; this DB is neither opened nor created by the hook. + env["QUANT_DB_PATH"] = str(tmp_path / "caller_database.db") + result = subprocess.run([sys.executable, "-B", "-c", script, str(ROOT / "tests" / "conftest.py"), str(tmp_path)], + capture_output=True, text=True, check=True, env=env, timeout=20) + return json.loads(result.stdout) + + +def test_supplied_paths_create_zero_directories_and_register_zero_cleanup(tmp_path): + result = _child(""" +import atexit, json, os, runpy, sys, tempfile +from pathlib import Path +module = runpy.run_path(sys.argv[1]) +names = %r +expected = {name: str(Path(sys.argv[2]) / name.lower()) for name in names} +os.environ.update(expected) +def forbidden(*args, **kwargs): + raise AssertionError('caller path must not create a directory or register deletion') +tempfile.mkdtemp = forbidden +atexit.register = forbidden +module['pytest_configure'](None) +assert all(os.environ[name] == value for name, value in expected.items()) +print(json.dumps({'new_directories': 0, 'registered_cleanup': 0})) +""" % (ISOLATED_NAMES,), tmp_path) + assert result == {"new_directories": 0, "registered_cleanup": 0} + assert not list(tmp_path.iterdir()) + + +def test_created_directories_removed_at_exit_even_after_env_points_to_user_directory(tmp_path): + user_directory = tmp_path / "user-preserved" + user_directory.mkdir() + sentinel = user_directory / "existing.txt" + sentinel.write_text("keep this caller file", encoding="utf-8") + result = _child(""" +import json, os, runpy, sys, tempfile +from pathlib import Path +module = runpy.run_path(sys.argv[1]) +names = %r +for name in names: + os.environ.pop(name, None) +tempfile.tempdir = sys.argv[2] +module['pytest_configure'](None) +first = {name: os.environ[name] for name in names} +module['pytest_configure'](None) +assert first == {name: os.environ[name] for name in names} +directories = [str(Path(value).parent if name.endswith('_PATH') else Path(value)) for name, value in first.items()] +assert len(set(directories)) == 7 and all(Path(path).is_dir() for path in directories) +for name in names: + os.environ[name] = str(Path(sys.argv[2]) / 'user-preserved') +print(json.dumps({'created_directories': directories})) +""" % (ISOLATED_NAMES,), tmp_path) + directories = [Path(value) for value in result["created_directories"]] + assert all(path.parent == tmp_path.resolve() for path in directories) + assert all(not path.exists() for path in directories) + assert sentinel.read_text(encoding="utf-8") == "keep this caller file" + assert sorted(path.name for path in tmp_path.iterdir()) == ["user-preserved"] diff --git a/tests/test_rebalance_snapshot.py b/tests/test_rebalance_snapshot.py index a9d31c0d..d7909081 100644 --- a/tests/test_rebalance_snapshot.py +++ b/tests/test_rebalance_snapshot.py @@ -26,6 +26,8 @@ def patched_rebalance(monkeypatch): """run_rebalance의 협력자들을 모킹하고 fake_rebalancer를 돌려준다.""" from config.config_loader import Config Config._instance = None # settings.yaml 재로드 (trading.mode=paper) + from datetime import datetime + monkeypatch.setattr("core.trading_hours._now_kst", lambda: datetime(2026, 10, 13, 10, 10)) fake_rb = MagicMock() fake_rb.get_status_report.return_value = "status" diff --git a/tests/test_research_admission.py b/tests/test_research_admission.py new file mode 100644 index 00000000..98e177a3 --- /dev/null +++ b/tests/test_research_admission.py @@ -0,0 +1,136 @@ +import copy +import json + +import pytest + +from core.research_admission import ( + RESEARCH_ADMISSION_POLICY, canonical_research_review, proof_from_sweep, +) + + +def evidence(): + from backtest.statistical_validation import deflated_sharpe_ratio + dsr = deflated_sharpe_ratio(2.0, 500, 2, 0.01, skew=0.2, kurtosis=4.0) + dsr["status"] = "observed" + bundle = { + "schema_version": 1, "artifact_type": "research_candidate_sweep_bundle", + "run_id": "test", "generated_at": "2026-10-13T11:00:00", "commit_hash": "abc", + "config_yaml_hash": "yaml", "config_resolved_hash": "resolved", + "initial_capital": 10_000_000, "research_code_fingerprint": "a" * 64, + "eval_start": "2023-01-01", "eval_end": "2025-12-31", "universe": ["005930", "000660"], + "universe_selection": {"survivorship_controlled": True}, + "candidate_ids_evaluated": ["winner", "loser"], + "candidates": [{"candidate_id": "winner", "strategy": "target_weight_rotation", "params": {"lookback": 60}}], + "oos_holdout": { + "status": "ok", "selected_candidate_id": "winner", "selected_on": "train_rank_score_only", + "selection_window": ["2023-01-01", "2024-12-31"], + "holdout_window": ["2025-01-01", "2025-12-31"], "trials": 2, + "train_sharpe": 2.0, "test_sharpe": 1.1, "test_benchmark_excess_return": 3.0, "test_benchmark_excess_sharpe": 0.2, + "test_benchmark_coverage_complete": True, + "selected_train_deflated_sharpe": dsr, + "selected_train_return_statistics": {"status": "observed", "n_obs": 500, "sharpe_annual": 2.0, "skew": 0.2, "kurtosis": 4.0, + "first_return_date": "2023-01-03", "last_return_date": "2024-12-31", "periods_per_year": 252, + "kurtosis_convention": "pearson", "source": "equity_curve.pct_change"}, + "train_multiple_testing": {"n_trials": 2, "sharpe_variance_across_trials_raw": 0.01}, + }, + } + metadata = {k: bundle[k] for k in ("commit_hash", "config_yaml_hash", "config_resolved_hash", "eval_start", "eval_end", "universe", "initial_capital", "research_code_fingerprint")} + metadata.update({"research_admission_policy": RESEARCH_ADMISSION_POLICY, + "strategy_specs": [{"candidate_id": "winner", "base_strategy": "target_weight_rotation", "params": {"lookback": 60}}], + "research_admission_proofs": {"winner": proof_from_sweep(bundle)}}) + return bundle, metadata + + +def test_proof_binds_candidate_and_config_instead_of_trusting_pass_labels(): + _, metadata = evidence() + assert canonical_research_review(metadata, "winner")["status"] == "passed" + assert canonical_research_review(metadata, "loser")["status"] == "unavailable" + metadata["config_resolved_hash"] = "changed" + assert "research_config_resolved_hash_mismatch" in canonical_research_review(metadata, "winner")["issues"] + + +@pytest.mark.parametrize("change,expected", [ + (lambda b: b["oos_holdout"].update(test_benchmark_excess_return=-1), "research_test_benchmark_excess_return_failed_or_missing"), + (lambda b: b["oos_holdout"]["selected_train_deflated_sharpe"].update(status="approximate"), "research_train_dsr_failed_or_unavailable"), + (lambda b: b["universe_selection"].update(survivorship_controlled=False), "research_point_in_time_universe_unverified"), + (lambda b: b["oos_holdout"].update(selection_window=["2023-01-01", "2025-02-01"]), "research_windows_invalid"), + (lambda b: b["candidates"][0]["params"].update(lookback=120), "research_candidate_parameters_mismatch"), + (lambda b: b["oos_holdout"]["selected_train_deflated_sharpe"].update(n_trials=1), "research_trial_count_mismatch"), + (lambda b: b.update(initial_capital=300_000), "research_initial_capital_mismatch"), + (lambda b: b.pop("initial_capital"), "research_initial_capital_mismatch"), + (lambda b: b.update(initial_capital=float("nan")), "research_initial_capital_mismatch"), + (lambda b: b.update(research_code_fingerprint="b" * 64), "research_code_fingerprint_mismatch"), + (lambda b: b["oos_holdout"]["selected_train_return_statistics"].update(first_return_date="2025-01-02", last_return_date="2025-12-31"), "research_observed_return_statistics_missing_or_invalid"), + (lambda b: b["oos_holdout"]["selected_train_return_statistics"].update(periods_per_year=365), "research_observed_return_statistics_missing_or_invalid"), + (lambda b: b["oos_holdout"]["selected_train_return_statistics"].update(kurtosis_convention="fisher"), "research_observed_return_statistics_missing_or_invalid"), + (lambda b: b["oos_holdout"]["selected_train_return_statistics"].update(n_obs=750), "research_observed_return_statistics_missing_or_invalid"), + (lambda b: b["oos_holdout"].update(train_sharpe=3.0), "research_observed_return_statistics_missing_or_invalid"), +]) +def test_invalid_evidence_cannot_pass_even_with_fresh_hash(change, expected): + bundle, metadata = evidence() + change(bundle) + metadata["research_admission_proofs"]["winner"] = proof_from_sweep(bundle) + result = canonical_research_review(metadata, "winner") + assert result["status"] == "failed" + assert expected in result["issues"] + + +def test_proof_mutation_is_detected_and_legacy_authorization_is_not_rewritten(): + _, metadata = evidence() + metadata["research_admission_proofs"]["winner"]["oos_holdout"]["test_sharpe"] = 999 + assert "research_proof_hash_mismatch" in canonical_research_review(metadata, "winner")["issues"] + legacy = canonical_research_review({}, "old_strategy") + assert legacy["required"] is False + assert legacy["status"] == "legacy_unverified" + + +def test_unknown_commit_is_not_a_valid_matching_identity(): + bundle, metadata = evidence() + bundle["commit_hash"] = metadata["commit_hash"] = "unknown" + metadata["research_admission_proofs"]["winner"] = proof_from_sweep(bundle) + assert "research_commit_identity_unavailable" in canonical_research_review(metadata, "winner")["issues"] + + +def test_code_fingerprint_detects_uncommitted_file_changes(tmp_path): + from core.research_admission import research_code_fingerprint + (tmp_path / "strategies").mkdir() + source = tmp_path / "strategies" / "uncommitted_strategy.py" + source.write_text("threshold = 10\n", encoding="utf-8") + before = research_code_fingerprint(tmp_path) + source.write_text("threshold = 20\n", encoding="utf-8") + assert research_code_fingerprint(tmp_path) != before + + +def test_new_canonical_build_and_artifact_reload_both_enforce_admission(tmp_path, monkeypatch): + from tools.evaluate_and_promote import build_promotion_results, build_data_snapshot_manifest + from core.promotion_engine import load_metrics_from_artifact, promote + from core.live_gate import LIVE_GATE_ARTIFACT_TYPE, LIVE_GATE_SCHEMA_VERSION + manifest = build_data_snapshot_manifest( + provider="test", universe_rule="test", eval_start="2023-01-01", eval_end="2025-12-31", + universe_lookback_start="2022-01-01", universe_lookback_end="2022-12-31", + universe=["005930"], liquidity_coverage={"005930": {"rows": 252}}, + benchmark_coverage={"005930": {"rows": 750}}, fetch_errors={}, + ) + metrics = {"scoring": {"total_return": 18, "profit_factor": 1.5, "mdd": -8, + "wf_positive_rate": 0.8, "wf_sharpe_positive_rate": 0.8, "wf_windows": 5, + "wf_total_trades": 120, "sharpe": 0.7, "benchmark_excess_return": 1.0, + "benchmark_excess_sharpe": 0.2}} + metadata = {"schema_version": LIVE_GATE_SCHEMA_VERSION, "artifact_type": LIVE_GATE_ARTIFACT_TYPE, + "data_snapshot_hash": manifest["data_snapshot_hash"], "data_snapshot_manifest": manifest, + "evaluation_errors": {}, "walk_forward_errors": {}, + "research_admission_policy": RESEARCH_ADMISSION_POLICY} + promotions = build_promotion_results(metrics, evidence_dir=str(tmp_path / "no_evidence"), canonical_metadata=metadata) + assert promotions["scoring"]["status"] == "research_only" + assert "research_admission_proof_missing" in promotions["scoring"]["reason"] + artifacts = {"run_metadata.json": metadata, "metrics_summary.json": metrics, + "walk_forward_summary.json": {"scoring": {"windows": 5, "positive": 4, "sharpe_pos": 4, "total_trades": 120}}, + "benchmark_comparison.json": {"strategy_excess_return_pct": {"scoring": 1.0}, "strategy_excess_sharpe": {"scoring": 0.2}}, + "promotion_result.json": promotions} + for name, value in artifacts.items(): + (tmp_path / name).write_text(json.dumps(value), encoding="utf-8") + loaded = load_metrics_from_artifact(str(tmp_path), evidence_dir=str(tmp_path / "no_evidence")) + assert loaded["scoring"].research_admission_required is True + assert promote(loaded["scoring"]).status == "research_only" + metadata.pop("research_admission_policy") + legacy = build_promotion_results(copy.deepcopy(metrics), evidence_dir=str(tmp_path / "no_evidence"), canonical_metadata=metadata) + assert legacy["scoring"]["status"] == "provisional_paper_candidate" diff --git a/tests/test_research_candidate_sweep.py b/tests/test_research_candidate_sweep.py index d14aacbc..776dee21 100644 --- a/tests/test_research_candidate_sweep.py +++ b/tests/test_research_candidate_sweep.py @@ -1,5 +1,6 @@ import json from pathlib import Path +import pytest def _minimal_bundle(candidates=None): @@ -384,7 +385,9 @@ def test_oos_holdout_passes_for_robust_winner(): def fake_eval(spec, start, end): is_train = start == "2023-01-01" - return {"sharpe": 1.5 if is_train else 1.1, "total_return": 40 if is_train else 25} + return {"sharpe": 1.5 if is_train else 1.1, "total_return": 40 if is_train else 25, + "benchmark_excess_return": 5, "benchmark_excess_sharpe": 0.2, + "benchmark_coverage_complete": True} out = evaluate_oos_holdout( [_FakeSpec("a"), _FakeSpec("b")], @@ -411,6 +414,67 @@ def test_oos_holdout_empty_specs(): assert out["trials"] == 0 +def test_oos_positive_absolute_performance_does_not_hide_benchmark_failure(): + from tools.research_candidate_sweep import evaluate_oos_holdout + out = evaluate_oos_holdout( + [_FakeSpec("winner")], train_start="2023-01-01", train_end="2024-12-31", + test_start="2025-01-01", test_end="2025-12-31", + evaluate_fn=lambda *args: {"sharpe": 2.0, "total_return": 50, + "benchmark_excess_return": -5, "benchmark_excess_sharpe": -0.2, + "benchmark_coverage_complete": True}, + ) + assert out["holdout_passes"] is False + assert out["validation_status"] == "failed" + + +def test_oos_missing_benchmark_and_overlapping_windows_fail_closed(): + from tools.research_candidate_sweep import evaluate_oos_holdout + kwargs = dict(specs=[_FakeSpec("a")], train_start="2023-01-01", train_end="2024-12-31", + test_start="2025-01-01", test_end="2025-12-31", + evaluate_fn=lambda *args: {"sharpe": 3.0, "total_return": 100}) + result = evaluate_oos_holdout(**kwargs) + assert result["validation_status"] == "unavailable" + assert result["holdout_passes"] is False + kwargs["test_start"] = "2024-12-31" + kwargs["evaluate_fn"] = lambda *args: pytest.fail("overlapping windows must not execute") + assert evaluate_oos_holdout(**kwargs)["status"] == "invalid_windows" + + +def test_dsr_uses_actual_observations_and_nonnormal_moments(): + import numpy as np + import pandas as pd + from tools.research_candidate_sweep import calculate_research_metrics, annotate_multiple_testing + dates = pd.to_datetime(["2025-01-02", "2025-01-03", "2025-01-06", "2025-01-10", "2025-01-17", "2025-01-20"]) + returns = np.array([0.01, -0.02, 0.005, 0.10, -0.03]) + values = np.concatenate(([100.0], 100.0 * np.cumprod(1 + returns))) + metrics = calculate_research_metrics({"equity_curve": pd.DataFrame({"value": values}, index=dates)}, 100.0) + statistics = metrics["return_statistics"] + assert statistics["n_obs"] == 5 + assert statistics["skew"] == pytest.approx(pd.Series(returns).skew()) + assert statistics["kurtosis"] == pytest.approx(pd.Series(returns).kurt() + 3) + annotate_multiple_testing([{"metrics": metrics}], n_obs=999) + assert metrics["deflated_sharpe"]["n_obs"] == 5 + assert metrics["deflated_sharpe"]["status"] == "observed" + assert metrics["deflated_sharpe"]["skew"] == statistics["skew"] + + +def test_research_admission_never_substitutes_full_period_winner(): + from tools.research_candidate_sweep import build_decision_summary, research_admission + oos = {"status": "ok", "validation_status": "passed", "selected_candidate_id": "train_winner", + "selected_train_deflated_sharpe": {"status": "observed", "dsr": 0.98, "passes": True}} + admission = research_admission({"survivorship_controlled": True}, oos) + records = [{"candidate_id": name, "alpha_pass": True, "promotion": {"status": "provisional_paper_candidate"}} + for name in ("full_period_winner", "train_winner")] + result = build_decision_summary(records, walk_forward_enabled=True, benchmark={"universe_size": 3}, admission=admission) + assert result["eligible_candidate_ids"] == ["train_winner"] + result = build_decision_summary(records[:1], walk_forward_enabled=True, benchmark={"universe_size": 3}, admission=admission) + assert result["action"] == "RESEARCH_VALIDATION_REQUIRED" + assert result["eligible_candidate_ids"] == [] + oos["selected_train_deflated_sharpe"]["status"] = "approximate" + assert research_admission({"survivorship_controlled": True}, oos)["status"] == "unavailable" + assert research_admission({}, None)["status"] == "unavailable" + + def test_run_candidate_sweep_wires_oos_holdout(monkeypatch): """run_candidate_sweep에 --oos-holdout-split이 들어오면 artifact에 oos_holdout이 채워진다. diff --git a/tests/test_research_dashboard.py b/tests/test_research_dashboard.py new file mode 100644 index 00000000..c8c5a3ef --- /dev/null +++ b/tests/test_research_dashboard.py @@ -0,0 +1,111 @@ +"""Research refresh is isolated from trading and cannot block ordinary page reads.""" +import asyncio +from unittest.mock import AsyncMock, patch + +from aiohttp.test_utils import TestClient, TestServer + +from monitoring import research_dashboard as rd +from monitoring.web_dashboard import create_app + + +def test_research_read_does_not_collect_or_initialize_database(): + async def run(): + with patch.object(rd, "get_research_desk_json", return_value={"research_only": True}), \ + patch.object(rd, "run_research_refresh", new_callable=AsyncMock) as collect, \ + patch("database.models.init_database", side_effect=AssertionError("DB write")): + async with TestClient(TestServer(create_app())) as client: + response = await client.get("/api/research") + assert response.status == 200 + assert (await response.json())["research_only"] is True + collect.assert_not_called() + asyncio.run(run()) + + +def test_refresh_coalesces_concurrent_requests_without_blocking_page(): + async def run(): + entered, release = asyncio.Event(), asyncio.Event() + async def collect(): + entered.set() + await release.wait() + with patch.object(rd, "run_research_refresh", side_effect=collect) as refresh, \ + patch.object(rd, "get_research_desk_json", return_value={"market": {"status": "ready"}}): + async with TestClient(TestServer(create_app())) as client: + tasks = [asyncio.create_task(client.post("/api/research/refresh", headers={"X-Requested-With": "quant-dashboard"})) for _ in range(3)] + try: + await asyncio.wait_for(entered.wait(), 1) + assert (await asyncio.wait_for(client.get("/"), 1)).status == 200 + finally: + release.set() + assert all(response.status == 200 for response in await asyncio.gather(*tasks)) + assert refresh.call_count == 1 + asyncio.run(run()) + + +def test_failed_refresh_is_retryable_and_not_reported_as_fresh(): + async def run(): + with patch.object(rd, "run_research_refresh", side_effect=[asyncio.TimeoutError(), None]) as refresh, \ + patch.object(rd, "get_research_desk_json", return_value={"market": {"status": "partial"}}): + async with TestClient(TestServer(create_app())) as client: + headers = {"X-Requested-With": "quant-dashboard"} + assert (await client.post("/api/research/refresh", headers=headers)).status == 504 + response = await client.post("/api/research/refresh", headers=headers) + assert response.status == 200 + assert (await response.json())["market"]["status"] == "partial" + assert refresh.call_count == 2 + asyncio.run(run()) + + +def test_concurrent_timeouts_share_one_child_and_later_click_can_retry(): + async def run(): + service = rd.ResearchRefresh() + entered, release = asyncio.Event(), asyncio.Event() + async def collect(): + entered.set() + await release.wait() + raise asyncio.TimeoutError() + with patch.object(rd, "run_research_refresh", side_effect=collect) as refresh: + tasks = [asyncio.create_task(service.refresh()) for _ in range(3)] + await entered.wait() + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(*tasks, return_exceptions=True) + assert all(isinstance(result, asyncio.TimeoutError) for result in results) + assert refresh.call_count == 1 + with patch.object(rd, "run_research_refresh", new_callable=AsyncMock), \ + patch.object(rd, "get_research_desk_json", return_value={"fresh": True}): + assert await service.refresh() == {"fresh": True} + asyncio.run(run()) + + +def test_refresh_requires_same_local_dashboard_request(): + async def run(): + with patch.object(rd, "run_research_refresh", new_callable=AsyncMock) as collect: + async with TestClient(TestServer(create_app())) as client: + for headers in ({}, {"X-Requested-With": "quant-dashboard", "Origin": "https://outside.example"}): + assert (await client.post("/api/research/refresh", headers=headers)).status == 403 + collect.assert_not_called() + asyncio.run(run()) + + +def test_stuck_refresh_child_is_killed(monkeypatch): + class Process: + returncode = None + killed = False + async def wait(self): + if not self.killed: + await asyncio.sleep(10) + return self.returncode + def kill(self): + self.killed = True + self.returncode = -1 + process = Process() + original_wait_for = asyncio.wait_for + monkeypatch.setattr(rd.asyncio, "create_subprocess_exec", AsyncMock(return_value=process)) + monkeypatch.setattr(rd.asyncio, "wait_for", lambda task, timeout: original_wait_for(task, timeout=0.01)) + async def run(): + try: + await rd.run_research_refresh() + raise AssertionError("timeout required") + except asyncio.TimeoutError: + assert process.killed + asyncio.run(run()) diff --git a/tests/test_research_evidence.py b/tests/test_research_evidence.py new file mode 100644 index 00000000..a46c7422 --- /dev/null +++ b/tests/test_research_evidence.py @@ -0,0 +1,211 @@ +"""Research history must describe what was known, not backfilled hindsight.""" + +import copy +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from core import research_evidence as evidence + +_ROOT = Path(__file__).resolve().parent.parent + + +@pytest.fixture +def setup_journal(tmp_path, monkeypatch): + clock = [datetime(2026, 10, 13, 2, tzinfo=timezone.utc)] + monkeypatch.setattr(evidence, "_utc_now", lambda: clock[0]) + payload = { + "schema_version": 1, "curator": "test curator", + "entries": [{ + "id": "sample_thesis", "title": "산업 가설", "theme": "산업 변화", + "thesis": "검증할 가설", "invalidation_condition": "실적에서 매출 연결이 확인되지 않음", + "horizon": "6개월", "review_at": "2026-11-13T09:00:00+09:00", + "expires_at": "2026-12-13T09:00:00+09:00", "evidence_type": "industry_thesis", + "sources": [{"url": "https://example.org/report", "title": "공식 보고서", "published_at": "2026-10-01T12:00:00+09:00"}], + "symbols": [{"symbol": "005930", "rationale": "보고서에서 자사 사업을 설명", "source_url": "https://example.org/report"}], + "counterevidence": ["증설로 현금흐름이 악화할 수 있음"], + }], + } + source = tmp_path / "input.json" + directory = tmp_path / "journal" + + def save(): + source.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + save() + return payload, source, directory, clock, save + + +def test_read_missing_journal_has_no_side_effect(tmp_path): + directory = tmp_path / "missing" + result = evidence.read_evidence_journal(directory) + assert result["status"] == "EMPTY" + assert result["automatic_collection"] is False + assert not directory.exists() + + +def test_import_preserves_source_and_prior_revision_and_filters_hindsight(setup_journal): + payload, source, directory, clock, save = setup_journal + original_source = source.read_bytes() + first = evidence.import_research_evidence(source, directory) + first_path = Path(first["path"]) + original_record = first_path.read_bytes() + assert source.read_bytes() == original_source + assert evidence.read_evidence_journal(directory, as_of="2026-10-13T01:59:59Z")["entries"] == [] + clock[0] = datetime(2026, 10, 14, 2, tzinfo=timezone.utc) + payload["entries"][0]["thesis"] = "수정한 가설" + save() + second = evidence.import_research_evidence(source, directory) + assert second["versions"] == {"sample_thesis": 2} + assert first_path.read_bytes() == original_record + historic = evidence.read_evidence_journal(directory, as_of="2026-10-13T03:00:00Z") + assert historic["entries"][0]["thesis"] == "검증할 가설" + current = evidence.read_evidence_journal(directory) + assert current["entries"][0]["thesis"] == "수정한 가설" + assert current["entries"][0]["trade_eligible"] is False + assert current["publication_verification"] == "curator_supplied" + + +@pytest.mark.parametrize("place,key,value", [ + ("root", "observed_at", "2020-01-01T00:00:00Z"), + ("entry", "observed_at", "2020-01-01T00:00:00Z"), + ("entry", "version", 7), + ("entry", "id", "../../unsafe"), + ("entry", "review_at", "2026-10-30"), + ("entry", "expires_at", "2026-10-02T00:00:00Z"), +]) +def test_invalid_batch_cannot_write_or_backdate(setup_journal, place, key, value): + payload, source, directory, _, save = setup_journal + (payload if place == "root" else payload["entries"][0])[key] = value + save() + with pytest.raises(ValueError): + evidence.import_research_evidence(source, directory) + assert not directory.exists() + + +def test_clock_regression_is_rejected(setup_journal): + _, source, directory, clock, _ = setup_journal + evidence.import_research_evidence(source, directory) + clock[0] = datetime(2026, 10, 12, 2, tzinfo=timezone.utc) + with pytest.raises(ValueError, match="clock"): + evidence.import_research_evidence(source, directory) + assert len(list(directory.glob("*.json"))) == 1 + assert not (directory / ".import.lock").exists() + + +@pytest.mark.parametrize("url", ["javascript:alert(1)", "file:///tmp/a", "https://user:secret@example.org", "https://example.org/a\nb"]) +def test_source_urls_cannot_be_code_or_credentials(setup_journal, url): + payload, _, _, _, _ = setup_journal + payload["entries"][0]["sources"][0]["url"] = url + with pytest.raises(ValueError, match="URL"): + evidence.validate_research_input(payload) + + +def test_symbol_requires_specific_cited_relationship(setup_journal): + payload, _, _, _, _ = setup_journal + payload["entries"][0]["symbols"][0]["source_url"] = "https://other.example/report" + with pytest.raises(ValueError, match="cite"): + evidence.validate_research_input(payload) + payload["entries"][0]["symbols"][0]["source_url"] = "https://example.org/report" + payload["entries"][0]["symbols"][0]["rationale"] = "" + with pytest.raises(ValueError, match="rationale"): + evidence.validate_research_input(payload) + + +def test_unknown_publication_is_not_usable_evidence(setup_journal): + payload, source, directory, _, save = setup_journal + entry = payload["entries"][0] + entry["sources"][0]["published_at"] = None + entry["sources"][0]["published_date"] = "2026-10-01" + save() + evidence.import_research_evidence(source, directory) + result = evidence.read_evidence_journal(directory) + assert result["status"] == "ATTENTION" + assert result["entries"][0]["status"] == "UNVERIFIED" + assert result["entries"][0]["sources"][0]["availability"] == "UNKNOWN_PUBLICATION" + + +def test_future_publication_cannot_become_evidence_without_new_observation(setup_journal): + payload, source, directory, _, save = setup_journal + payload["entries"][0]["sources"][0]["published_at"] = "2026-10-20T00:00:00Z" + save() + with pytest.raises(ValueError, match="event_at"): + evidence.import_research_evidence(source, directory) + assert not list(directory.glob("*.json")) + + +def test_review_expiry_and_event_are_independent(setup_journal): + payload, source, directory, clock, save = setup_journal + payload["entries"][0]["event_at"] = "2026-10-29T10:00:00+09:00" + payload["entries"][0]["evidence_type"] = "scheduled_event" + save() + evidence.import_research_evidence(source, directory) + current = evidence.read_evidence_journal(directory)["entries"][0] + assert current["status"] == current["event_status"] == "UPCOMING" + clock[0] = datetime(2026, 11, 13, 1, tzinfo=timezone.utc) + stale = evidence.read_evidence_journal(directory)["entries"][0] + assert stale["status"] == "STALE" + assert stale["event_status"] == "PASSED" + assert "REVIEW_DUE" in stale["issues"] + clock[0] = datetime(2026, 12, 13, 1, tzinfo=timezone.utc) + assert "EXPIRED" in evidence.read_evidence_journal(directory)["entries"][0]["issues"] + + +def test_corrupt_history_is_reported_and_blocks_new_import(setup_journal): + _, source, directory, clock, _ = setup_journal + imported = evidence.import_research_evidence(source, directory) + path = Path(imported["path"]) + record = json.loads(path.read_text(encoding="utf-8")) + record["observed_at"] = "2020-01-01T00:00:00Z" + path.write_text(json.dumps(record), encoding="utf-8") + result = evidence.read_evidence_journal(directory) + assert result["status"] == "ATTENTION" and result["issues"] + assert result["entries"] == [] + clock[0] = datetime(2026, 10, 14, 2, tzinfo=timezone.utc) + with pytest.raises(ValueError, match="integrity"): + evidence.import_research_evidence(source, directory) + + +def test_filter_and_count_before_limit(setup_journal): + payload, source, directory, _, save = setup_journal + second = copy.deepcopy(payload["entries"][0]) + second["id"] = "second" + second["symbols"] = [] + payload["entries"].append(second) + save() + evidence.import_research_evidence(source, directory) + result = evidence.read_evidence_journal(directory, limit=1) + assert result["total"] == 2 and result["truncated"] is True + assert result["counts"] == {"CURRENT": 2} + filtered = evidence.read_evidence_journal(directory, symbols=["005930"]) + assert filtered["total"] == 1 + assert filtered["entries"][0]["id"] == "sample_thesis" + + +def test_examples_have_no_fabricated_precise_publication_timestamps(): + payload = json.loads((_ROOT / "config/research_theses.json").read_text(encoding="utf-8")) + normalized = evidence.validate_research_input(payload) + assert len(normalized["entries"]) >= 2 + for entry in normalized["entries"]: + assert entry["counterevidence"] + assert all(source["published_at"] is None for source in entry["sources"]) + industry = next(entry for entry in normalized["entries"] if entry["evidence_type"] == "industry_thesis") + assert industry["symbols"] == [] + + +def test_cli_real_import_and_read_without_database(tmp_path): + directory = tmp_path / "journal" + command = [sys.executable, str(_ROOT / "tools/research_evidence.py"), "--journal-dir", str(directory)] + original = (_ROOT / "config/research_theses.json").read_bytes() + result = subprocess.run(command + ["import", str(_ROOT / "config/research_theses.json")], capture_output=True, text=True, encoding="utf-8", check=True) + assert json.loads(result.stdout)["imported"] == 2 + result = subprocess.run(command + ["list", "--json"], capture_output=True, text=True, encoding="utf-8", check=True) + viewed = json.loads(result.stdout) + assert viewed["total"] == 2 + assert viewed["automatic_collection"] is False + assert (_ROOT / "config/research_theses.json").read_bytes() == original + assert not list(tmp_path.rglob("*.db")) diff --git a/tests/test_research_operations.py b/tests/test_research_operations.py new file mode 100644 index 00000000..91e79da9 --- /dev/null +++ b/tests/test_research_operations.py @@ -0,0 +1,227 @@ +from datetime import datetime, timedelta, timezone +import json +import subprocess +from types import SimpleNamespace +from unittest.mock import Mock +from core import research_operations as ops + + +def configure(monkeypatch): + monkeypatch.delenv("QUANT_RESEARCH_AUTOMATION_DISABLED", raising=False) + monkeypatch.setattr(ops, "_config", lambda: {"enabled": True, "retry_minutes": 60, "stale_hours": 36, "jobs": {}}) + monkeypatch.setattr(ops, "_data_status", lambda name: "collecting" if name == "paper_validation" else "ready") + monkeypatch.setattr(ops.shutil, "disk_usage", lambda path: SimpleNamespace(free=1024 ** 3)) + + +def test_daily_run_persists_and_duplicate_does_not_refetch(monkeypatch, tmp_path): + configure(monkeypatch) + run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr(ops.subprocess, "run", run) + assert ops.run_research_operations(output_dir=tmp_path)["status"] == "completed" + assert ops.run_research_operations(output_dir=tmp_path)["skipped"] == "already_collected_period" + assert run.call_count == 3 + assert len(list((tmp_path / "runs").glob("*.json"))) == 1 + assert all(c.kwargs["timeout"] <= 300 and c.kwargs["stdout"] == subprocess.DEVNULL for c in run.call_args_list) + + +def test_failure_continues_remaining_jobs_without_false_health(monkeypatch, tmp_path): + configure(monkeypatch) + run = Mock(side_effect=[subprocess.TimeoutExpired("provider", 1), SimpleNamespace(returncode=0), SimpleNamespace(returncode=0)]) + monkeypatch.setattr(ops.subprocess, "run", run) + monkeypatch.setattr(ops, "_data_status", lambda name: "unavailable" if name == "intelligence" else "collecting") + report = ops.run_research_operations(output_dir=tmp_path) + assert report["status"] == "attention" + assert report["jobs"]["market"]["status"] == "timeout" + assert report["jobs"]["intelligence"]["status"] == "attention" + assert report["jobs"]["paper_validation"]["status"] == "completed" + assert ops.run_research_operations(output_dir=tmp_path)["skipped"] == "retry_backoff" + assert run.call_count == 3 + + +def test_killed_child_is_visible_and_read_does_not_create_files(tmp_path): + assert ops.read_research_operations(tmp_path / "absent")["status"] == "not_started" + assert not (tmp_path / "absent").exists() + now = datetime.now(timezone.utc) + ops._save({"schema_version": 1, "status": "running", "jobs": {}, "started_at": (now - timedelta(minutes=17)).isoformat(), "updated_at": (now - timedelta(minutes=16)).isoformat()}, tmp_path) + assert ops.read_research_operations(tmp_path, now=now)["status"] == "interrupted" + + +def test_concurrent_process_cannot_run_duplicate_jobs(monkeypatch, tmp_path): + from core.runtime_lock import process_runtime_lock + configure(monkeypatch) + run = Mock() + monkeypatch.setattr(ops.subprocess, "run", run) + with process_runtime_lock(tmp_path / ".collection.lock") as acquired: + assert acquired + assert ops.run_research_operations(output_dir=tmp_path)["status"] == "busy" + run.assert_not_called() + + +def test_launch_returns_immediately_and_disable_prevents_spawn(monkeypatch): + configure(monkeypatch) + spawn = Mock(return_value=SimpleNamespace(pid=12, wait=Mock())) + monkeypatch.setattr(ops.subprocess, "Popen", spawn) + assert ops.launch_research_operations("rebalance") == {"status": "launched", "pid": 12} + monkeypatch.setenv("QUANT_RESEARCH_AUTOMATION_DISABLED", "true") + assert ops.launch_research_operations()["status"] == "disabled" + assert spawn.call_count == 1 + + +def test_authenticated_error_urls_never_reach_saved_status(monkeypatch, tmp_path): + configure(monkeypatch) + monkeypatch.setattr(ops.subprocess, "run", Mock(side_effect=RuntimeError("https://provider?api_key=SECRET"))) + report = ops.run_research_operations(output_dir=tmp_path) + assert report["status"] == "attention" + assert "SECRET" not in json.dumps(report) + + +def test_disk_pressure_stops_collection_with_visible_reason(monkeypatch, tmp_path): + configure(monkeypatch) + monkeypatch.setattr(ops.shutil, "disk_usage", lambda path: SimpleNamespace(free=1024)) + run = Mock() + monkeypatch.setattr(ops.subprocess, "run", run) + report = ops.run_research_operations(output_dir=tmp_path) + assert report["status"] == "attention" + assert all(job["status"] == "disk_pressure" for job in report["jobs"].values()) + run.assert_not_called() + + +def test_bad_start_record_does_not_permanently_block_recovery(monkeypatch, tmp_path): + configure(monkeypatch) + monkeypatch.setattr(ops.subprocess, "run", Mock(return_value=SimpleNamespace(returncode=0))) + for started in ("broken", (datetime.now(timezone.utc) + timedelta(days=100)).isoformat()): + ops._save({"schema_version": 1, "status": "completed", "jobs": {}, "started_at": started, "updated_at": datetime.now(timezone.utc).isoformat()}, tmp_path) + assert ops.read_research_operations(tmp_path)["status"] == "unavailable" + assert ops.run_research_operations(output_dir=tmp_path)["status"] == "completed" + + +def test_operations_http_read_does_not_collect(monkeypatch): + import asyncio + from aiohttp.test_utils import TestClient, TestServer + from monitoring import operations_dashboard as dashboard + from monitoring.web_dashboard import create_app + monkeypatch.setattr(dashboard, "get_operations_json", lambda: {"operations": {"status": "not_started"}}) + monkeypatch.setattr(ops, "launch_research_operations", Mock(side_effect=AssertionError("collection forbidden"))) + async def run(): + async with TestClient(TestServer(create_app())) as client: + response = await client.get("/api/research/operations") + assert response.status == 200 + assert (await response.json())["operations"]["status"] == "not_started" + asyncio.run(run()) + + +def test_cli_partial_failure_returns_nonzero_with_saved_detail(monkeypatch, capsys): + from tools import research_operations as cli + monkeypatch.setattr(cli.sys, "argv", ["research_operations.py", "--status"]) + monkeypatch.setattr(cli, "read_research_operations", lambda: { + "status": "attention", "jobs": {"intelligence": {"status": "failed", "data_status": "partial"}} + }) + monkeypatch.setattr(cli, "run_research_operations", Mock(side_effect=AssertionError("read-only status"))) + assert cli.main() == 1 + assert json.loads(capsys.readouterr().out)["jobs"]["intelligence"]["data_status"] == "partial" + + +def clock_at(monkeypatch, value): + clock = [datetime.fromisoformat(value)] + monkeypatch.setattr(ops, "_now", lambda: clock[0]) + return clock + + +def test_morning_success_never_suppresses_settled_close_phase(monkeypatch, tmp_path): + configure(monkeypatch) + clock = clock_at(monkeypatch, "2026-10-13T10:30:00+09:00") + run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr(ops.subprocess, "run", run) + first = ops.run_research_operations(output_dir=tmp_path) + assert first["collection_period"]["phase"] == "intraday" + clock[0] = datetime.fromisoformat("2026-10-13T15:35:00+09:00") + assert ops.run_research_operations(output_dir=tmp_path)["skipped"] == "already_collected_period" + clock[0] = datetime.fromisoformat("2026-10-13T16:30:00+09:00") + assert ops.read_research_operations(tmp_path)["status"] == "stale" + second = ops.run_research_operations(output_dir=tmp_path) + assert second["collection_period"]["phase"] == "post_market" + assert second["status"] == "completed" + assert second["jobs"]["market"]["reused"] is True + assert not second["jobs"]["paper_validation"].get("reused") + assert run.call_count == 5 # Valid T-1 prices reused, disclosures and paper rerun. + assert ops.run_research_operations(output_dir=tmp_path)["skipped"] == "already_collected_period" + + +def test_phase_change_bypasses_previous_phase_retry_backoff(monkeypatch, tmp_path): + configure(monkeypatch) + clock = clock_at(monkeypatch, "2026-10-13T15:45:00+09:00") + monkeypatch.setattr(ops, "_data_status", lambda name: "partial" if name == "intelligence" else "observing" if name == "paper_validation" else "ready") + run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr(ops.subprocess, "run", run) + assert ops.run_research_operations(output_dir=tmp_path)["status"] == "attention" + clock[0] = datetime.fromisoformat("2026-10-13T16:10:00+09:00") + report = ops.run_research_operations(output_dir=tmp_path) + assert "skipped" not in report + assert report["jobs"]["paper_validation"]["status"] == "completed" + assert run.call_count == 5 + + +def test_partial_dart_retry_reuses_healthy_jobs_and_does_not_block_paper(monkeypatch, tmp_path): + configure(monkeypatch) + clock = clock_at(monkeypatch, "2026-10-13T10:00:00+09:00") + monkeypatch.setattr(ops, "_data_status", lambda name: "partial" if name == "intelligence" else "observing" if name == "paper_validation" else "ready") + run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr(ops.subprocess, "run", run) + first = ops.run_research_operations(output_dir=tmp_path) + assert first["jobs"]["paper_validation"]["status"] == "completed" + clock[0] = datetime.fromisoformat("2026-10-13T11:01:00+09:00") + result = ops.run_research_operations(output_dir=tmp_path) + assert result["status"] == "attention" + assert result["jobs"]["market"]["reused"] is True + assert result["jobs"]["paper_validation"]["reused"] is True + assert run.call_count == 4 + + +def test_forced_close_retry_reassesses_paper_without_reloading_valid_market(monkeypatch, tmp_path): + configure(monkeypatch) + clock_at(monkeypatch, "2026-10-13T16:30:00+09:00") + run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr(ops.subprocess, "run", run) + ops.run_research_operations(output_dir=tmp_path) + second = ops.run_research_operations(output_dir=tmp_path, force=True) + assert second["jobs"]["market"]["reused"] is True + assert not second["jobs"]["paper_validation"].get("reused") + assert run.call_count == 5 + + +def test_weekend_elapsed_hours_do_not_expire_completed_session(monkeypatch, tmp_path): + configure(monkeypatch) + clock = clock_at(monkeypatch, "2026-10-17T08:30:00+09:00") + monkeypatch.setattr(ops.subprocess, "run", Mock(return_value=SimpleNamespace(returncode=0))) + result = ops.run_research_operations(output_dir=tmp_path) + assert result["collection_period"]["expected_session"] == "2026-10-16" + clock[0] = datetime.fromisoformat("2026-10-19T08:00:00+09:00") + assert ops.read_research_operations(tmp_path)["status"] == "completed" + clock[0] = datetime.fromisoformat("2026-10-19T16:30:00+09:00") + assert ops.read_research_operations(tmp_path)["status"] == "stale" + + +def test_trading_holiday_keeps_last_session_but_new_session_marks_stale(monkeypatch, tmp_path): + configure(monkeypatch) + clock = clock_at(monkeypatch, "2026-10-09T08:30:00+09:00") # Hangul Day. + monkeypatch.setattr(ops.subprocess, "run", Mock(return_value=SimpleNamespace(returncode=0))) + result = ops.run_research_operations(output_dir=tmp_path) + assert result["collection_period"]["phase"] == "closed_day" + assert result["collection_period"]["expected_session"] == "2026-10-08" + clock[0] = datetime.fromisoformat("2026-10-12T08:00:00+09:00") + assert ops.read_research_operations(tmp_path)["status"] == "completed" + clock[0] = datetime.fromisoformat("2026-10-13T08:00:00+09:00") + assert ops.read_research_operations(tmp_path)["status"] == "stale" + + +def test_changed_child_evidence_does_not_skip_already_completed_period(monkeypatch, tmp_path): + configure(monkeypatch) + clock_at(monkeypatch, "2026-10-13T10:30:00+09:00") + run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr(ops.subprocess, "run", run) + ops.run_research_operations(output_dir=tmp_path) + monkeypatch.setattr(ops, "_data_status", lambda name: "stale" if name == "market" else "observing" if name == "paper_validation" else "ready") + result = ops.run_research_operations(output_dir=tmp_path) + assert "skipped" not in result + assert not result["jobs"]["market"].get("reused") + assert run.call_count == 4 diff --git a/tests/test_weekly_report.py b/tests/test_weekly_report.py index 5594f908..1c631f5c 100644 --- a/tests/test_weekly_report.py +++ b/tests/test_weekly_report.py @@ -54,7 +54,8 @@ def test_event_counts_and_no_incident(self): f = _fields(build_weekly_summary( basket_name="b", eval_result=_eval(), missing_days=0, cycle_errors=0, )) - assert "무사고" in f["🛠 주간 이벤트"] + assert "무사고" not in f["🛠 주간 이벤트"] + assert "주문 거부 기록 미확인" in f["🛠 주간 이벤트"] def test_event_counts_with_incidents(self): f = _fields(build_weekly_summary( diff --git a/tools/collect_basket_closes.py b/tools/collect_basket_closes.py new file mode 100644 index 00000000..6839acb2 --- /dev/null +++ b/tools/collect_basket_closes.py @@ -0,0 +1,60 @@ +"""Observe today's paper-basket closing NAV, without orders or external messages.""" +from __future__ import annotations + +import argparse +from contextlib import redirect_stdout +import json +import os +from pathlib import Path +import sys +import uuid + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db-path", "--db", dest="db_path", type=Path) + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--json", action="store_true", help="Emit only JSON on stdout; diagnostics go to stderr") + parser.add_argument("--status", action="store_true", help="Read the saved collection report, no DB/provider access") + args = parser.parse_args(argv) + directory = args.output_dir or Path(os.environ.get("QUANT_BASKET_CLOSE_DIR", ROOT / "output" / "basket_closes")) + report_path = directory / "latest.json" + if args.status: + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + if not isinstance(report, dict): + raise ValueError("invalid report") + except (OSError, ValueError): + report = {"status": "unavailable", "reason": "close_collection_report_missing"} + else: + if args.db_path: + os.environ["QUANT_DB_PATH"] = str(args.db_path.resolve()) + try: + with redirect_stdout(sys.stderr): + from config.config_loader import Config + from core.basket_close import collect_basket_closes + config = Config.get() + db = Path(config.database.get("sqlite_path", "data/quant_trader.db")) + db = (ROOT / db).resolve() if not db.is_absolute() else db.resolve() + if config.database.get("type") == "postgresql" or not db.is_file(): + report = {"status": "unavailable", "reason": "existing_sqlite_paper_ledger_required", "orders_submitted": 0, "rows_saved": 0} + else: + report = collect_basket_closes(config) + except Exception as exc: + report = {"status": "unavailable", "reason": type(exc).__name__, "orders_submitted": 0, "rows_saved": 0} + directory.mkdir(parents=True, exist_ok=True) + temporary = directory / f".{uuid.uuid4().hex}.tmp" + try: + temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8") + temporary.replace(report_path) + finally: + temporary.unlink(missing_ok=True) + print(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)) + return 0 if report.get("status") in {"completed", "awaiting_close", "disabled"} else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/daily_observation.py b/tools/daily_observation.py new file mode 100644 index 00000000..d14f403d --- /dev/null +++ b/tools/daily_observation.py @@ -0,0 +1,21 @@ +"""Run paper close valuation and research collection, without order execution.""" +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from core.daily_observation import read_daily_observation, run_daily_observation + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--status", action="store_true", help="Read saved status without collection or DB access") + args = parser.parse_args() + report = read_daily_observation() if args.status else run_daily_observation() + print(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)) + return 0 if report["status"] in {"completed", "awaiting_close", "busy"} else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/evaluate_and_promote.py b/tools/evaluate_and_promote.py index 042dd1f3..e00a4af0 100644 --- a/tools/evaluate_and_promote.py +++ b/tools/evaluate_and_promote.py @@ -514,6 +514,7 @@ def build_promotion_results( canonical_metadata=None, ): from core.live_gate import validate_canonical_metadata_integrity + from core.research_admission import canonical_research_review from core.promotion_engine import ( StrategyMetrics, attach_paper_evidence_metrics, @@ -574,6 +575,9 @@ def build_promotion_results( else datetime.now() ) for name, m in metrics_all.items(): + research_review = canonical_research_review(canonical_metadata or {}, name) + m["research_admission_status"] = research_review["status"] + m["research_admission_issues"] = research_review["issues"] for key in paper_fields: m.pop(key, None) paper_metrics = paper_evidence_metrics_from_package( @@ -605,6 +609,9 @@ def build_promotion_results( benchmark_excess_return=m.get("benchmark_excess_return"), benchmark_excess_sharpe=m.get("benchmark_excess_sharpe"), canonical_benchmark_required=True, + research_admission_required=research_review["required"], + research_admission_status=research_review["status"], + research_admission_issues=research_review["issues"], canonical_data_integrity_ok=m.get("canonical_data_integrity_ok"), canonical_data_integrity_issues=m.get("canonical_data_integrity_issues"), ev_per_trade=m.get("ev_per_trade"), @@ -5266,12 +5273,23 @@ def validate_promotion_operator_artifacts( ] -def run_canonical(): +def run_canonical(research_admission_artifact=None): """canonical 평가 실행 → artifact 저장.""" from config.config_loader import Config from backtest.portfolio_backtester import PortfolioBacktester from core.data_collector import DataCollector import FinanceDataReader as fdr + from core.research_admission import RESEARCH_ADMISSION_POLICY, proof_from_sweep, research_code_fingerprint + + research_proofs = {} + if research_admission_artifact: + source = json.loads(Path(research_admission_artifact).read_text(encoding="utf-8")) + if not isinstance(source, dict): + raise ValueError("research admission artifact must be a JSON object") + proof = proof_from_sweep(source) + selected = (proof.get("oos_holdout") or {}).get("selected_candidate_id") + if selected: + research_proofs[selected] = proof EVAL_START = CANONICAL_EVAL_START EVAL_END = CANONICAL_EVAL_END @@ -5586,6 +5604,11 @@ def run_strat(strategy, syms, capital, start, end, div=None): canonical_research_candidate_metadata(spec) for spec in research_specs ] + strategy_specs_metadata.extend({ + "candidate_id": name, "base_strategy": name, + "params": Config.get().strategies.get(name, {}), + "candidate_source": "configured_base_strategy", + } for name in STRATEGIES) data_snapshot_manifest = build_data_snapshot_manifest( provider="FinanceDataReader/DataCollector", @@ -5603,6 +5626,9 @@ def run_strat(strategy, syms, capital, start, end, div=None): metadata = { "schema_version": 1, "artifact_type": "canonical_promotion_bundle", + "research_admission_policy": RESEARCH_ADMISSION_POLICY, + "research_admission_proofs": research_proofs, + "research_code_fingerprint": research_code_fingerprint(), "eval_start": EVAL_START, "eval_end": EVAL_END, "universe_rule": UNIVERSE_RULE, @@ -5692,6 +5718,7 @@ def main(): import argparse parser = argparse.ArgumentParser(description="Canonical 평가 + 승격 판정") parser.add_argument("--canonical", action="store_true", help="전체 평가 실행") + parser.add_argument("--research-admission-artifact", help="새 paper 승격에 사용할 검증된 research sweep JSON") parser.add_argument("--check-only", action="store_true", help="promotion artifact 로드 및 운영 artifact 동기화 검증") parser.add_argument( "--blocker-summary", @@ -5731,7 +5758,7 @@ def main(): args = parser.parse_args() if args.canonical: - run_canonical() + run_canonical(research_admission_artifact=args.research_admission_artifact) elif args.paper_evidence_quarantine_invalid: results = quarantine_invalid_paper_evidence_operator_packages( "reports/promotion", diff --git a/tools/execution_cost_report.py b/tools/execution_cost_report.py new file mode 100644 index 00000000..8f5631ff --- /dev/null +++ b/tools/execution_cost_report.py @@ -0,0 +1,33 @@ +"""Read execution-cost observations without creating or migrating a database.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from core.execution_analytics import execution_cost_report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db", type=Path, help="SQLite ledger to inspect in read-only mode") + parser.add_argument("--account-key") + parser.add_argument("--mode", choices=["paper", "live"]) + parser.add_argument("--since", help="ISO date or timestamp, interpreted in KST if naive") + parser.add_argument("--limit", type=int, default=200) + parser.add_argument("--out", type=Path, help="Optional JSON report; default: stdout only") + args = parser.parse_args() + report = execution_cost_report(db_path=args.db, account_key=args.account_key, + mode=args.mode, since=args.since, limit=args.limit) + rendered = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(rendered, encoding="utf-8") + print(rendered) + + +if __name__ == "__main__": + main() diff --git a/tools/market_intelligence.py b/tools/market_intelligence.py new file mode 100644 index 00000000..1592b8be --- /dev/null +++ b/tools/market_intelligence.py @@ -0,0 +1,26 @@ +"""Collect actual public disclosure/flow evidence; never opens the operating DB.""" +import argparse +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from core.market_intelligence import collect_market_intelligence, load_intelligence_config, save_market_intelligence + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config") + parser.add_argument("--output-dir") + args = parser.parse_args() + report = collect_market_intelligence(load_intelligence_config(args.config)) + paths = save_market_intelligence(report, args.output_dir) + print(json.dumps({"status": report["status"], "generated_at": report["generated_at"], "expected_session": report["expected_session"], + "disclosures": report["disclosures"]["coverage"], "investor_flows": report["investor_flows"]["coverage"], **paths}, ensure_ascii=False)) + return 0 if report["status"] == "ready" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/market_research.py b/tools/market_research.py new file mode 100644 index 00000000..182b343a --- /dev/null +++ b/tools/market_research.py @@ -0,0 +1,45 @@ +"""Publish the market observation radar; no order or operating DB path is used.""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import pandas as pd +from core.market_research import (build_market_research, collect_market_research, load_research_config, + read_market_research, research_directory, save_market_research) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config") + parser.add_argument("--output-dir", default=str(research_directory())) + parser.add_argument("--prices-dir", help="Offline CSV directory: SYMBOL.csv, with date/close[/volume] columns") + parser.add_argument("--csv-price-basis", choices=("unknown", "adjusted", "unadjusted"), default="unknown") + parser.add_argument("--now", help="Reproducible observation time; naive timestamps use Asia/Seoul") + args = parser.parse_args() + if args.now and not args.prices_dir: + parser.error("--now is only permitted with --prices-dir for an explicitly marked offline replay") + config = load_research_config(args.config) + previous = read_market_research(Path(args.output_dir) / "latest.json", now=args.now) + if args.prices_dir: + prices, sources = {}, {} + for spec in [config["benchmark"]] + config.get("universe", []): + symbol = spec["symbol"] + path = Path(args.prices_dir) / f"{symbol}.csv" + if path.exists(): + prices[symbol] = pd.read_csv(path) + sources[symbol] = {"name": "offline_csv", "adjusted": {"unknown": None, "adjusted": True, "unadjusted": False}[args.csv_price_basis]} + report = build_market_research(config, prices, sources=sources, generated_at=args.now, previous=previous) + else: + report = collect_market_research(config, now=args.now, previous=previous) + paths = save_market_research(report, args.output_dir) + print(json.dumps({"status": report["status"], "as_of": report["as_of"], "coverage": report["coverage"], **paths}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/tools/paper_validation.py b/tools/paper_validation.py new file mode 100644 index 00000000..a49fbf89 --- /dev/null +++ b/tools/paper_validation.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Durable, prospective paper validation; no orders or operational DB writes.""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from core.paper_validation import collect_paper_validation, read_paper_validation + + +def main(argv=None): + parser = argparse.ArgumentParser(description="장기 모의운용 전진 관측. 실계좌 승격·주문·운영 DB 쓰기 없음.") + commands = parser.add_subparsers(dest="command", required=True) + collect = commands.add_parser("collect", help="현재 시각으로 종가·입출금·비교지수 관측 추가") + collect.add_argument("--db-path", type=Path, help="SQLite 원천 (mode=ro로만 조회)") + collect.add_argument("--config-path", type=Path) + show = commands.add_parser("status", help="저장 결과만 조회 (네트워크·DB 접근 없음)") + for subcommand in (collect, show): + subcommand.add_argument("--output-dir", type=Path) + subcommand.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + try: + result = (collect_paper_validation(args.db_path, config_path=args.config_path, output_dir=args.output_dir) + if args.command == "collect" else read_paper_validation(args.output_dir)) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)) + else: + print(f"전진 모의운용 검증: {result['status']} · 연구 전용 · 실계좌 자동 승격 없음") + for row in result["cohorts"]: + print(f"{row['basket']} [{row['status']}] {row['return_observations']}/{row['minimum_observations']} 관측수익률 · 시작 {row['started_at']}") + if row["issues"]: + print(" " + ", ".join(row["issues"])) + for issue in result["issues"]: + print(issue) + return 2 if result["status"] in {"unavailable", "corrupt"} else 0 + except (ValueError, OSError) as exc: + print(f"paper validation failed: {type(exc).__name__}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/research_candidate_sweep.py b/tools/research_candidate_sweep.py index ecd92077..bc3735d9 100644 --- a/tools/research_candidate_sweep.py +++ b/tools/research_candidate_sweep.py @@ -12,6 +12,7 @@ import argparse import json +import math import os import subprocess import sys @@ -4608,6 +4609,7 @@ def calculate_research_metrics( "ev_per_trade": 0, "cost_adjusted_cagr": -100, "turnover_per_year": 0, + "return_statistics": {"status": "unavailable", "reason": "empty_equity_curve", "n_obs": 0}, **exposure_summary(eq), **exposure_matched_benchmark_metrics(eq, benchmark_daily_returns, capital), } @@ -4619,7 +4621,7 @@ def calculate_research_metrics( final = float(eq["value"].iloc[-1]) total_return = (final / capital - 1) * 100 years = max(len(eq) / 252, 1 / 252) - daily_returns = eq["value"].pct_change().dropna() + daily_returns = eq["value"].pct_change(fill_method=None).dropna() std = float(daily_returns.std()) if len(daily_returns) > 1 else 0 sharpe = (float(daily_returns.mean()) * 252 - 0.03) / (std * np.sqrt(252)) if std > 0 else 0 peak = eq["value"].cummax() @@ -4650,11 +4652,35 @@ def calculate_research_metrics( if final > 0 and capital > 0 else -100, "turnover_per_year": round(notional / capital / years * 100, 1) if capital > 0 else 0, + "return_statistics": observed_return_statistics(eq, sharpe), **exposure_summary(eq), **exposure_matched_benchmark_metrics(eq, benchmark_daily_returns, capital), } +def observed_return_statistics(eq: pd.DataFrame, sharpe: float) -> dict[str, Any]: + """DSR inputs from actual equity observations, never calendar-day estimates.""" + values = pd.to_numeric(eq["value"], errors="coerce") + dates = pd.to_datetime(eq.index, errors="coerce") + if (dates.isna().any() or dates.has_duplicates or not dates.is_monotonic_increasing + or not np.isfinite(values).all() or (values <= 0).any()): + return {"status": "unavailable", "reason": "invalid_equity_observations", "n_obs": 0} + returns = values.pct_change(fill_method=None).iloc[1:] + n_obs = len(returns) + if n_obs < 4 or not np.isfinite(returns).all() or float(returns.std()) <= 0: + return {"status": "unavailable", "reason": "insufficient_variable_returns", "n_obs": n_obs} + skew, kurtosis = float(returns.skew()), float(returns.kurt()) + 3.0 + if not all(math.isfinite(x) for x in (skew, kurtosis, sharpe)): + return {"status": "unavailable", "reason": "nonfinite_return_moments", "n_obs": n_obs} + return { + "status": "observed", "n_obs": n_obs, "sharpe_annual": float(sharpe), + "skew": skew, "kurtosis": kurtosis, "periods_per_year": 252, + "first_return_date": dates[1].date().isoformat(), + "last_return_date": dates[-1].date().isoformat(), + "source": "equity_curve.pct_change", "kurtosis_convention": "pearson", + } + + def rank_score(metrics: dict[str, Any]) -> float: """Research ranking score. Promotion status remains the hard gate.""" excess_return = float(metrics.get("benchmark_excess_return", 0) or 0) @@ -4758,22 +4784,20 @@ def build_candidate_record( } -def annotate_multiple_testing(records: list[dict[str, Any]], n_obs: int) -> dict[str, Any]: - """시행 집합(여러 변형) 전체의 Sharpe 분산으로 각 record에 deflated Sharpe를 부여한다. +def annotate_multiple_testing(records: list[dict[str, Any]], n_obs: int | None = None) -> dict[str, Any]: + """Use observed per-candidate moments; legacy estimates cannot pass admission. - 한 패밀리에서 N개 변형을 탐색해 '최고'를 고르면 in-sample Sharpe가 다중검정으로 - 구조적으로 부풀려진다. Bailey & López de Prado의 Deflated Sharpe Ratio로 시행 횟수에 - 맞춰 할인하여 각 record.metrics['deflated_sharpe']에 기록한다(랭킹/승격은 변경하지 - 않고 보고만 — 과적합 신호 노출용). skew/kurt는 집계 단계에서 수익률 시계열이 없어 - 정규(0/3)로 가정하며, 다중검정 할인이 주효과다. + Trial counts cover this invocation only. Repeated research and correlated trials + require a broader experiment ledger; this diagnostic does not establish alpha. """ from backtest.statistical_validation import deflated_sharpe_ratio import statistics sharpes = [ - float(r["metrics"]["sharpe"]) + float((r["metrics"].get("return_statistics") or {}).get("sharpe_annual", r["metrics"]["sharpe"])) for r in records if isinstance((r.get("metrics") or {}).get("sharpe"), (int, float)) + and math.isfinite(float(r["metrics"]["sharpe"])) ] n_trials = len(records) variance = statistics.pvariance(sharpes) if len(sharpes) >= 2 else 0.0 @@ -4781,23 +4805,38 @@ def annotate_multiple_testing(records: list[dict[str, Any]], n_obs: int) -> dict for r in records: metrics = r.get("metrics") or {} s = metrics.get("sharpe") - if not isinstance(s, (int, float)): + if not isinstance(s, (int, float)) or not math.isfinite(float(s)): + continue + stats = metrics.get("return_statistics") or {} + observed = stats.get("status") == "observed" + count = stats.get("n_obs") if observed else n_obs + if count is None or int(count) < 4: + metrics["deflated_sharpe"] = { + "status": "unavailable", "reason": stats.get("reason", "observed_return_statistics_missing"), + "passes": False, "n_trials": n_trials, + } continue + s = float(stats.get("sharpe_annual", s)) if observed else float(s) dsr = deflated_sharpe_ratio( - float(s), n_obs=int(n_obs), n_trials=n_trials, + s, n_obs=int(count), n_trials=n_trials, sharpe_variance_across_trials=variance, + skew=float(stats.get("skew", 0.0)), kurtosis=float(stats.get("kurtosis", 3.0)), ) + dsr.update({"status": "observed" if observed else "approximate", + "skew": stats.get("skew", 0.0), "kurtosis": stats.get("kurtosis", 3.0), + "trial_scope": "current_sweep_only"}) metrics["deflated_sharpe"] = dsr if best_dsr is None or float(s) > best_dsr.get("observed_sharpe_annual", float("-inf")): best_dsr = dsr return { "n_trials": n_trials, "sharpe_variance_across_trials": round(variance, 6), - "n_obs": int(n_obs), + "sharpe_variance_across_trials_raw": variance, + "n_obs": int(n_obs) if n_obs is not None else None, "best_by_sharpe_deflated": best_dsr, "note": ( - "deflated Sharpe는 다중검정 보정 보고용이며 랭킹/승격 게이트를 바꾸지 않는다. " - "skew/kurt는 정규 가정." + "DSR은 실제 관측 수익률의 표본수·왜도·첨도를 사용한다. approximate/unavailable은 " + "연구 진입 심사를 통과하지 못한다. 시행수는 이번 실행만 포함하며 반복 탐색은 미통제다." ), } @@ -4848,22 +4887,21 @@ def evaluate_oos_holdout( evaluate_fn, rank_fn=None, ) -> dict[str, Any]: - """진짜 out-of-time holdout 평가. + """Select using train metrics only, then replay one winner out of time. - 지금 walk-forward는 전체 구간에서 고른 파라미터를 각 구간에 재평가하므로 사실상 - 전부 in-sample이라 변형 선택 과적합을 못 잡는다. 이 함수는 **train 구간 성과로만 - 변형을 고르고**, 한 번도 안 본 **test 구간 성과를 보고**한다. test_sharpe가 - train_sharpe보다 크게 떨어지면 선택 과적합(selection overfitting) 신호다. - - evaluate_fn(spec, start, end) -> metrics dict 를 주입받아 단위 테스트가 가능하다. + This does not certify an untouched dataset: previous runs may have inspected it. """ rank_fn = rank_fn or rank_score if not specs: return {"status": "no_specs", "trials": 0} + boundaries = [pd.Timestamp(x) for x in (train_start, train_end, test_start, test_end)] + if not (boundaries[0] <= boundaries[1] < boundaries[2] <= boundaries[3]): + return {"status": "invalid_windows", "holdout_passes": False, + "reason": "train_and_test_must_be_ordered_and_nonoverlapping"} def _num(metrics: dict[str, Any], key: str): value = (metrics or {}).get(key) - return float(value) if isinstance(value, (int, float)) else None + return float(value) if isinstance(value, (int, float)) and math.isfinite(float(value)) else None scored = [] for spec in specs: @@ -4875,12 +4913,25 @@ def _num(metrics: dict[str, Any], key: str): "train_rank_score": rank_fn(train_metrics), }) # train 랭킹으로만 선택 (test는 절대 보지 않음) + if any(not math.isfinite(float(item["train_rank_score"])) for item in scored): + return {"status": "invalid_train_metrics", "holdout_passes": False} + train_records = [{"candidate_id": item["candidate_id"], "metrics": item["train_metrics"]} for item in scored] + train_multiple_testing = annotate_multiple_testing(train_records) scored.sort(key=lambda item: item["train_rank_score"], reverse=True) winner = scored[0] test_metrics = evaluate_fn(winner["spec"], test_start, test_end) train_sharpe = _num(winner["train_metrics"], "sharpe") test_sharpe = _num(test_metrics, "sharpe") + test_excess_return = _num(test_metrics, "benchmark_excess_return") + test_excess_sharpe = _num(test_metrics, "benchmark_excess_sharpe") + coverage = test_metrics.get("benchmark_coverage_complete") is True + missing = [name for name, value in (("sharpe", test_sharpe), + ("benchmark_excess_return", test_excess_return), + ("benchmark_excess_sharpe", test_excess_sharpe)) if value is None] + if not coverage: + missing.append("benchmark_coverage_complete") + passes = not missing and all(value > 0 for value in (test_sharpe, test_excess_return, test_excess_sharpe)) sharpe_degradation = ( round(train_sharpe - test_sharpe, 3) if train_sharpe is not None and test_sharpe is not None @@ -4899,12 +4950,18 @@ def _num(metrics: dict[str, Any], key: str): "test_total_return": _num(test_metrics, "total_return"), "train_benchmark_excess_return": _num(winner["train_metrics"], "benchmark_excess_return"), "test_benchmark_excess_return": _num(test_metrics, "benchmark_excess_return"), + "test_benchmark_excess_sharpe": test_excess_sharpe, + "test_benchmark_coverage_complete": coverage, + "validation_status": "unavailable" if missing else ("passed" if passes else "failed"), + "missing_metrics": missing, + "train_multiple_testing": train_multiple_testing, + "selected_train_deflated_sharpe": winner["train_metrics"].get("deflated_sharpe"), + "selected_train_return_statistics": winner["train_metrics"].get("return_statistics"), "sharpe_degradation": sharpe_degradation, - # holdout 통과: 선택된 변형이 untouched 구간에서도 양(+)의 Sharpe 유지 - "holdout_passes": bool(test_sharpe is not None and test_sharpe > 0), + "holdout_passes": bool(passes), "note": ( - "선택은 train 구간 rank_score만 사용, 성과는 test(untouched) 구간 기준. " - "test_sharpe가 train보다 크게 낮으면 선택 과적합 신호." + "선택은 train rank_score만 사용한다. test의 양의 Sharpe·동일 유니버스 초과수익·초과Sharpe를 " + "모두 요구한다. 과거 실행의 test 열람 여부는 인증하지 않는 out-of-time replay다." ), } @@ -4990,11 +5047,35 @@ def sort_candidate_records(records: list[dict[str, Any]]) -> list[dict[str, Any] ) +def research_admission(universe_selection: dict, oos_holdout: dict | None) -> dict[str, Any]: + """Fail closed for recommendation, without changing runtime authorizations.""" + checks = [] + pit = universe_selection.get("survivorship_controlled") + checks.append({"name": "point_in_time_universe", + "status": "passed" if pit is True else ("failed" if pit is False else "unavailable")}) + oos = oos_holdout or {} + checks.append({"name": "out_of_time_benchmark_excess", + "status": oos.get("validation_status", "unavailable") if oos.get("status") == "ok" else "unavailable"}) + dsr = oos.get("selected_train_deflated_sharpe") or {} + dsr_status = "unavailable" + if dsr.get("status") == "observed" and isinstance(dsr.get("dsr"), (int, float)) and math.isfinite(dsr["dsr"]): + dsr_status = "passed" if dsr.get("passes") is True and dsr["dsr"] >= 0.95 else "failed" + checks.append({"name": "train_selection_deflated_sharpe", "status": dsr_status}) + issues = [f"{check['name']}:{check['status']}" for check in checks if check["status"] != "passed"] + status = "passed" if not issues else ("failed" if any(c["status"] == "failed" for c in checks) else "unavailable") + return {"version": 1, "status": status, "checks": checks, "issues": issues, + "selected_candidate_id": oos.get("selected_candidate_id"), + "selection_window": oos.get("selection_window"), "holdout_window": oos.get("holdout_window"), + "limits": ["current_sweep_trial_count_only", "holdout_reuse_not_tracked", + "research_screen_not_paper_or_live_authorization"]} + + def build_decision_summary( candidates: list[dict[str, Any]], *, walk_forward_enabled: bool, benchmark: dict[str, Any], + admission: dict[str, Any] | None = None, ) -> dict[str, Any]: eligible = [ r for r in candidates @@ -5026,10 +5107,24 @@ def build_decision_summary( } if eligible: - ids = [r["candidate_id"] for r in eligible] + selected = (admission or {}).get("selected_candidate_id") + ids = [r["candidate_id"] for r in eligible if r["candidate_id"] == selected] + if (admission or {}).get("status") != "passed" or not ids: + return { + "action": "RESEARCH_VALIDATION_REQUIRED", + "reason": "Canonical recommendation requires verified point-in-time data, train DSR and out-of-time benchmark excess for the train-selected candidate.", + "best_candidate_id": best_id, "selected_candidate_id": selected, + "eligible_candidate_ids": [], + "alpha_candidate_ids": [r["candidate_id"] for r in alpha_candidates], + "block_reasons": (admission or {}).get("issues", ["research_admission_missing"]) + + (["train_selected_candidate_not_provisional"] if selected and not ids else []), + "next_actions": ["Resolve missing or failed research evidence before canonical evaluation.", + "Do not replace a failing train winner with a full-period winner."], + } return { "action": "RUN_CANONICAL_EVALUATION", - "reason": "At least one candidate has positive benchmark excess and provisional paper status.", + "reason": "The train-selected candidate passed research admission, benchmark excess and provisional quality checks.", + "selected_candidate_id": selected, "best_candidate_id": best_id, "eligible_candidate_ids": ids, "alpha_candidate_ids": [r["candidate_id"] for r in alpha_candidates], @@ -5197,8 +5292,10 @@ def run_candidate_sweep( oos_holdout_split: str | None = None, ) -> dict[str, Any]: from config.config_loader import Config + from core.research_admission import research_code_fingerprint config = Config.get() + code_fingerprint = research_code_fingerprint() if symbols is None: universe_selection = { "source": "canonical_liquidity_universe", @@ -5273,6 +5370,7 @@ def run_candidate_sweep( "run_id": run_id, "generated_at": datetime.now().isoformat(), "commit_hash": get_git_hash(), + "research_code_fingerprint": code_fingerprint, "config_yaml_hash": config.yaml_hash, "config_resolved_hash": config.resolved_hash, "eval_start": start, @@ -5306,6 +5404,7 @@ def run_candidate_sweep( "reason": empty_reason, }, "decision": decision, + "research_admission": research_admission(universe_selection, None), "rejection_summary": [], "candidates": [], "summary": { @@ -5360,51 +5459,38 @@ def run_candidate_sweep( records.append(build_candidate_record(spec, metrics, benchmark)) ranked = sort_candidate_records(records) - # 다중검정(여러 변형 탐색)으로 부풀려진 Sharpe를 deflated Sharpe로 보고(랭킹 불변). + # Full-period DSR is descriptive. Admission uses the train-only DSR below. try: - import pandas as _pd - _n_obs = int(len(_pd.bdate_range(start, end))) - multiple_testing = annotate_multiple_testing(ranked, _n_obs) + multiple_testing = annotate_multiple_testing(ranked) except Exception as exc: # 보고용이므로 실패해도 스윕을 막지 않는다. logger.warning("deflated sharpe 주석 실패(무시): {}", exc) multiple_testing = {"error": str(exc)} family_slug = candidate_family.lower().strip() run_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{family_slug}" - eligible = [ - r for r in ranked - if r.get("alpha_pass") and r.get("promotion", {}).get("status") == "provisional_paper_candidate" - ] - decision = build_decision_summary( - ranked, - walk_forward_enabled=include_walk_forward, - benchmark=benchmark, - ) validation_warnings = compute_validation_warnings(ranked, multiple_testing, universe_selection) if validation_warnings: for _w in validation_warnings: logger.warning("validation warning: {}", _w) - # 진짜 out-of-time holdout: train 구간 성과로만 변형을 고르고 untouched test 구간 - # 성과를 보고한다(선택 과적합 노출). split 날짜가 주어진 경우에만 수행. + # Out-of-time replay; prior human inspection/reuse is not certified. oos_holdout = None if oos_holdout_split: try: import pandas as _pd - def _slice_benchmark(_s, _e): - if benchmark_daily_returns is None or benchmark_daily_returns.empty: - return benchmark_daily_returns - _idx = _pd.to_datetime(benchmark_daily_returns.index) - return benchmark_daily_returns[ - (_idx >= _pd.Timestamp(_s)) & (_idx <= _pd.Timestamp(_e)) - ] + period_benchmarks = {} def _oos_eval(_spec, _s, _e): - return evaluate_candidate( + key = (_s, _e) + if key not in period_benchmarks: + period_benchmarks[key] = buy_and_hold_benchmark_with_returns(symbols, _s, _e, capital) + period_benchmark, period_returns = period_benchmarks[key] + metrics = evaluate_candidate( _spec, symbols, _s, _e, capital, - _slice_benchmark(_s, _e), + period_returns, target_weight_collector=target_weight_collector, ) + return build_candidate_record(_spec, metrics, period_benchmark)["metrics"] # split 직전 영업일을 train 종료일로 사용. _split_ts = _pd.Timestamp(oos_holdout_split) @@ -5429,12 +5515,17 @@ def _oos_eval(_spec, _s, _e): logger.warning("OOS holdout 평가 실패(무시): {}", exc) oos_holdout = {"status": "error", "error": str(exc)} + admission = research_admission(universe_selection, oos_holdout) + decision = build_decision_summary( + ranked, walk_forward_enabled=include_walk_forward, benchmark=benchmark, admission=admission, + ) return { "schema_version": 1, "artifact_type": "research_candidate_sweep_bundle", "run_id": run_id, "generated_at": datetime.now().isoformat(), "commit_hash": get_git_hash(), + "research_code_fingerprint": code_fingerprint, "config_yaml_hash": config.yaml_hash, "config_resolved_hash": config.resolved_hash, "eval_start": start, @@ -5466,6 +5557,7 @@ def _oos_eval(_spec, _s, _e): else {"enabled": False} ), "decision": decision, + "research_admission": admission, "multiple_testing": multiple_testing, "validation_warnings": validation_warnings, "oos_holdout": oos_holdout, @@ -5473,7 +5565,7 @@ def _oos_eval(_spec, _s, _e): "candidates": ranked, "summary": { "evaluated": len(ranked), - "eligible_for_canonical_eval": len(eligible), + "eligible_for_canonical_eval": len(decision.get("eligible_candidate_ids", [])), "best_candidate_id": ranked[0]["candidate_id"] if ranked else None, "decision_action": decision["action"], }, @@ -5611,7 +5703,7 @@ def write_candidate_artifacts(bundle: dict[str, Any], output_dir: Path = DEFAULT for _w in validation_warnings: lines.append(f"- ⚠️ {_w}") else: - lines.append("- 경고 없음 (생존자 통제·deflated Sharpe 기준 통과)") + lines.append("- 추가 경고 없음. 연구 진입 판정은 아래 admission 상태를 확인하세요.") best_dsr = (multiple_testing or {}).get("best_by_sharpe_deflated") or {} if best_dsr: lines.append( @@ -5630,6 +5722,15 @@ def write_candidate_artifacts(bundle: dict[str, Any], output_dir: Path = DEFAULT f"검증구간={oos_holdout.get('holdout_window')}" ) + admission = bundle.get("research_admission") or {} + lines.extend([ + "", "## Research Admission", + f"- Status: {admission.get('status', 'unavailable')}", + f"- Train-selected candidate: {admission.get('selected_candidate_id') or 'unavailable'}", + f"- Issues: {', '.join(admission.get('issues', [])) or 'none reported'}", + "- Full-period ranking is descriptive; only the train-selected candidate can be recommended.", + "- Trial counts cover this sweep only; repeated holdout inspection is not certified.", + ]) lines.extend([ "", "## Ranking", diff --git a/tools/research_evidence.py b/tools/research_evidence.py new file mode 100644 index 00000000..6e23d99a --- /dev/null +++ b/tools/research_evidence.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Import or inspect curated hypotheses; no news collection or trading side effects.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_ROOT)) + +from core.research_evidence import import_research_evidence, read_evidence_journal + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="출처·반증 조건이 있는 수동 조사 기록. 주문을 만들지 않습니다.") + parser.add_argument("--journal-dir", help="기록 폴더 (기본 data/research_evidence)") + commands = parser.add_subparsers(dest="command", required=True) + add = commands.add_parser("import", help="JSON을 새로운 관측 시각과 버전으로 추가") + add.add_argument("source", type=Path) + show = commands.add_parser("list", help="해당 시점에 관측한 최신 버전 조회") + show.add_argument("--as-of", help="시간대가 있는 ISO 시각. 예: 2026-10-13T11:00:00+09:00") + show.add_argument("--symbol", action="append", help="연결 근거가 기록된 종목만 조회 (반복 가능)") + show.add_argument("--limit", type=int, default=50) + show.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + try: + if args.command == "import": + result = import_research_evidence(args.source, args.journal_dir) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + result = read_evidence_journal(args.journal_dir, as_of=args.as_of, symbols=args.symbol, limit=args.limit) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print(f"수동 조사 기록 {result['total']}개 · {result['status']} · {result['as_of']}") + print("공개 시각은 작성자가 입력한 정보입니다. 자동 뉴스 수집·매매 신호가 아닙니다.") + for entry in result["entries"]: + print(f"[{entry['status']}] {entry['title']} (v{entry['version']}, 관측 {entry['observed_at']})") + print(f" 가설: {entry['thesis']}") + print(f" 반증: {entry['invalidation_condition']}") + if entry["issues"]: + print(f" 점검: {', '.join(entry['issues'])}") + for issue in result["issues"]: + print(f"기록 오류: {issue}") + return 2 if result["issues"] else 0 + except (OSError, ValueError) as exc: + print(f"research evidence: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/research_operations.py b/tools/research_operations.py new file mode 100644 index 00000000..661dc29d --- /dev/null +++ b/tools/research_operations.py @@ -0,0 +1,25 @@ +"""Run observational collection once; daily runtime hooks use the same durable lock.""" +import argparse +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from core.research_operations import read_research_operations, run_research_operations + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--force", action="store_true", help="Collect even if already attempted today") + parser.add_argument("--status", action="store_true", help="Read saved status without collection") + parser.add_argument("--trigger", default="manual") + args = parser.parse_args() + report = read_research_operations() if args.status else run_research_operations(force=args.force, trigger=args.trigger) + print(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)) + return 1 if report["status"] in {"attention", "failed", "unavailable", "interrupted", "stale", "invalid"} else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_basket_snapshot_fixes.py b/tools/verify_basket_snapshot_fixes.py new file mode 100644 index 00000000..58610d7f --- /dev/null +++ b/tools/verify_basket_snapshot_fixes.py @@ -0,0 +1,153 @@ +"""Offline execution verification using a COPY of the 2026-10-12 paper ledger. + +Usage: python tools/verify_basket_snapshot_fixes.py --source-db + --prices-dir --out-dir +Never uses the source DB as the runtime ledger, and prohibits network access. +""" +import argparse +from contextlib import ExitStack +from datetime import datetime +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import sys +from unittest.mock import patch + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-db", required=True, type=Path) + parser.add_argument("--prices-dir", required=True, type=Path) + parser.add_argument("--out-dir", required=True, type=Path) + args = parser.parse_args() + source = args.source_db.resolve(strict=True) + output = args.out_dir.resolve() + output.mkdir(parents=True, exist_ok=False) + target = output / "verification.db" + if source == target: + raise ValueError("Source must not be the runtime DB") + digest_before = hashlib.sha256(source.read_bytes()).hexdigest() + with sqlite3.connect(source.as_uri() + "?mode=ro", uri=True) as src, sqlite3.connect(target) as dest: + src.backup(dest) + os.environ.update(QUANT_DB_PATH=str(target), QUANT_BACKUP_PATH=str(output / "backups"), + QUANT_OVERLAY_STATE_DIR=str(output / "overlays"), + QUANT_DASHBOARD_STATE_PATH=str(output / "dashboard.json"), + QUANT_RUNTIME_LOCK_DIR=str(output / "locks")) + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + import pandas as pd + from loguru import logger + from config.config_loader import Config + from core.basket_close import finalize_basket_closes + from core.basket_evaluation import collect_basket_paper_evaluation + from core.basket_rebalancer import BasketRebalancer, RebalanceOrder + from core.basket_observability import read_basket_diagnostics + from core.data_collector import DataCollector + from core.risk_overlays import OverlayDecision + from database.models import init_database, get_engine, get_session, TradeHistory + from database.repositories import get_all_positions, record_cash_flow, get_portfolio_valuations + + logger.remove() + logger.add(output / "execution.log", encoding="utf-8") + cfg = Config.get() + cfg.settings["trading"]["mode"] = "paper" + init_database() + assert Path(get_engine().url.database).resolve() == target + frames = {} + for path in args.prices_dir.glob("*.csv"): + frame = pd.read_csv(path) + frame.columns = [str(c).lower() for c in frame.columns] + frames[path.stem] = frame + + def fetch(self, symbol, start_date=None, end_date=None): + frame = frames[symbol].copy() + days = pd.to_datetime(frame["date"]).dt.strftime("%Y-%m-%d") + lower = str(start_date)[:10] if start_date else "0001-01-01" + upper = str(end_date)[:10] if end_date else "9999-12-31" + return frame.loc[(days >= lower) & (days <= upper)].copy() + + def make(name): + rb = BasketRebalancer(name, config=cfg) + rb.overlay_decision = lambda: OverlayDecision(scale=1.0) if name == "kr_pocket" else None + snapshot = {s: {"price": float(frames[s].iloc[-1]["close"]), "avg_volume": float(frames[s].volume.tail(20).mean()), + "quote_date": "2026-10-12", "source": "frozen_csv", "observed_at": "2026-10-12T10:10:00"} + for s in rb.holdings if s in frames} + rb._market_snapshot = snapshot + rb._fetch_market_snapshot = lambda: snapshot + return rb + + def count(): + with get_session() as s: + return s.query(TradeHistory).count() + + results = {} + with ExitStack() as stack: + stack.enter_context(patch("socket.socket.connect", side_effect=AssertionError("Offline verification: network forbidden"))) + stack.enter_context(patch("core.notifier.Notifier")) + stack.enter_context(patch.object(DataCollector, "fetch_korean_stock", fetch)) + stack.enter_context(patch.object(DataCollector, "get_last_source_info", return_value={"source": "frozen_csv"})) + stack.enter_context(patch("core.data_collector.SECTOR_MAP_CACHE_PATH", output / "sector_map.json")) + pocket = make("kr_pocket") + orders = pocket.plan_rebalance() + assert orders == [] + assert {d["code"] for d in pocket._plan_diagnostics} == {"MIN_LOT"} + results["pocket_before_deposit"] = {"plan": pocket._plan_diagnostics, "allocation": pocket.diagnose_deployment()["allocation"]} + before = count() + for hour, minute in [(8, 49), (17, 43)]: + with patch("core.trading_hours._now_kst", return_value=datetime(2026, 10, 13, hour, minute)): + for action in ("BUY", "SELL"): + outcome = pocket.execute([RebalanceOrder("357870", action, 1, 58195)]) + assert outcome["blocked"] and not outcome["executed"] + assert count() == before + results["off_session"] = {"blocked_orders": 4, "new_fills": count() - before} + + hold = make("kr_diversified_hold") + with patch("core.trading_hours._now_kst", return_value=datetime(2026, 10, 13, 10, 10)): + outcome = hold.execute([RebalanceOrder("005380", "BUY", 1, hold._market_snapshot["005380"]["price"])]) + assert outcome["executed"] == 0 and outcome["failed"] == 1 + assert "MDD" in outcome["details"][0]["reason"] + result, _ = collect_basket_paper_evaluation(config=cfg, basket_name="kr_diversified_hold", include_benchmark=False) + assert result["entry_status"]["status"] == "BLOCKED" and result["verdict"] != "PASS_CANDIDATE" + results["mdd_guard"] = {"execution": outcome, "entry_status": result["entry_status"], "verdict": result["verdict"]} + + # Replay dated marks through the real snapshot -> close ledger path. + # These are controlled fixture prices, not recovered intraday quotes. + class ReplayClock(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 10, 12, 10, 20, tzinfo=tz) + with patch("core.basket_rebalancer.datetime", ReplayClock): + assert pocket.save_daily_nav_snapshot() + original_nav = pocket.portfolio_mgr.get_portfolio_summary( + current_prices={s: q["price"] for s, q in pocket._market_snapshot.items()})["total_value"] + assert finalize_basket_closes(pocket, now=datetime(2026, 10, 13, 10)) == ["2026-10-12"] + close = get_portfolio_valuations(pocket.account_key, kind="close")[-1] + assert close["total_value"] == original_nav == 380092 + results["close_valuation_copy_only"] = {"date": close["date"], "total_value": close["total_value"], + "source": "controlled frozen CSV replay"} + + # Hypothetical deposit is written to this disposable copy only. The full + # planner and paper executor must be capable of buying one equity ETF. + record_cash_flow(100000, account_key=pocket.account_key, note="COPY ONLY: verification") + orders = pocket.plan_rebalance() + assert [(o.symbol, o.action, o.quantity) for o in orders] == [("069500", "BUY", 1)] + with patch("core.trading_hours._now_kst", return_value=datetime(2026, 10, 13, 10, 10)): + outcome = pocket.execute(orders) + assert outcome["executed"] == 1, outcome + assert next(p.quantity for p in get_all_positions(pocket.account_key, mode="paper") if p.symbol == "069500") == 2 + assert pocket.save_daily_nav_snapshot() + diag = read_basket_diagnostics(pocket.basket, pocket.account_key, cfg.risk_params) + assert diag["allocation"]["status"] == "OK" + assert diag["measurement"]["kind"] == "unverified" # Oct12 marks on Oct13 + results["deposit_copy_only"] = {"execution": outcome, "allocation": diag["allocation"]} + results["valuation_rows"] = len(get_portfolio_valuations(pocket.account_key)) + assert finalize_basket_closes(pocket, now=datetime(2026, 10, 13, 16)) == [] + assert hashlib.sha256(source.read_bytes()).hexdigest() == digest_before + results["source_unchanged"] = True + (output / "result.json").write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({"verified": list(results), "report": str(output / "result.json")}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/tools/verify_research_admission.py b/tools/verify_research_admission.py new file mode 100644 index 00000000..8cec9a6d --- /dev/null +++ b/tools/verify_research_admission.py @@ -0,0 +1,71 @@ +"""Replay stored canonical artifacts under the new policy in an isolated directory. + +No strategy execution, runtime activation, network request or production DB write. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def verify(source_dir: Path, out_dir: Path) -> dict: + source_dir, out_dir = source_dir.resolve(), out_dir.resolve() + if out_dir.exists() or source_dir == out_dir or source_dir in out_dir.parents: + raise ValueError("out-dir must be a new directory outside the source artifact directory") + names = ["run_metadata.json", "metrics_summary.json", "walk_forward_summary.json", + "benchmark_comparison.json", "promotion_result.json"] + hashes = {name: hashlib.sha256((source_dir / name).read_bytes()).hexdigest() for name in names} + out_dir.mkdir(parents=True) + os.environ["QUANT_DB_PATH"] = str(out_dir / "isolated.db") + from core.research_admission import RESEARCH_ADMISSION_POLICY, canonical_research_review + from core.promotion_engine import load_metrics_from_artifact, promote + from tools.evaluate_and_promote import recalculate_promotion_bundle_from_artifacts + + results = {} + for case in ("missing_proof", "invalid_proof"): + target = out_dir / case + target.mkdir() + for name in names: + shutil.copy2(source_dir / name, target / name) + metadata = json.loads((target / "run_metadata.json").read_text(encoding="utf-8")) + metrics = json.loads((target / "metrics_summary.json").read_text(encoding="utf-8")) + metadata["research_admission_policy"] = RESEARCH_ADMISSION_POLICY + metadata["research_admission_proofs"] = ( + {name: {"proof_hash": "invalid", "artifact_type": "invalid"} for name in metrics} + if case == "invalid_proof" else {} + ) + (target / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + promotions, refreshed, _ = recalculate_promotion_bundle_from_artifacts(target, evidence_dir=out_dir / "no_paper_evidence") + (target / "promotion_result.json").write_text(json.dumps(promotions, ensure_ascii=False, indent=2), encoding="utf-8") + (target / "metrics_summary.json").write_text(json.dumps(refreshed, ensure_ascii=False, indent=2), encoding="utf-8") + loaded = load_metrics_from_artifact(str(target), evidence_dir=str(out_dir / "no_paper_evidence")) + assert loaded and set(loaded) == set(promotions), "persisted artifact reload must cover all candidates" + assert all(p["status"] == "research_only" and p["allowed_modes"] == ["backtest"] for p in promotions.values()) + assert all(promote(m).status == "research_only" for m in loaded.values()) + assert all("research admission required" in p["reason"] for p in promotions.values()) + results[case] = {"candidate_count": len(promotions), "all_research_only": True, + "paper_or_live_authorizations": 0, "artifact_reload_verified": True, + "reviews": {name: canonical_research_review(metadata, name) for name in promotions}} + unchanged = all(hashlib.sha256((source_dir / name).read_bytes()).hexdigest() == digest for name, digest in hashes.items()) + assert unchanged, "source artifacts changed during verification" + result = {"source": str(source_dir), "source_sha256": hashes, "source_unchanged": unchanged, "cases": results} + (out_dir / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-dir", type=Path, default=Path("reports/promotion")) + parser.add_argument("--out-dir", type=Path, required=True) + args = parser.parse_args() + result = verify(args.source_dir, args.out_dir) + print(json.dumps({"source_unchanged": result["source_unchanged"], + "cases": {name: {key: value for key, value in case.items() if key != "reviews"} + for name, case in result["cases"].items()}}, ensure_ascii=False, indent=2)) From e9179d2adfe8965dad14cbf71426b313effc7fa0 Mon Sep 17 00:00:00 2001 From: Junsu <103491329+easygap@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:46:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=B4=20=EC=97=86=EB=8A=94=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=20UTC=20=EC=84=9C=EB=B2=84=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EA=B4=80=EC=B8=A1=20=EA=B2=80=EC=82=AC=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub 전체 회귀 검사에서 23건이 실패했다. 일일 관측과 연구 수집이 Git에서 제외한 settings.yaml을 직접 읽고 있었으므로, 기본값과 환경변수를 처리하는 공통 설정 로더를 사용하도록 바꿨다. 주문 상태의 시각은 서버의 datetime.now()로 기록되는데 체결비용 분석에서 이를 무조건 한국 시간으로 해석했다. UTC 서버에서는 주문 전송 지연이 음수로 계산돼 미확인으로 남았다. 서버 시간대를 반영해 한국 시간으로 변환하고 UTC·한국 시간 환경의 실제 모의 체결 검사도 추가했다. 설계 적용일 이후 종가끼리 비교하는 기준과 자료 미확인 표시에 맞춰 기존 보고서 검사를 수정했다. 관련 검사 69개 통과. Windows에서 프로세스 시간대를 바꾸는 두 경우는 건너뛰며 GitHub Linux 검사에서 확인한다. --- core/daily_observation.py | 4 ++-- core/execution_analytics.py | 15 ++++++++++----- core/research_operations.py | 4 ++-- core/weekly_report.py | 2 +- tests/test_audit_evaluation.py | 22 ++++++++++++++++++---- tests/test_audit_reporting.py | 12 ++++++++---- tests/test_daily_observation.py | 7 +++++++ tests/test_execution_analytics.py | 19 ++++++++++++++++++- tests/test_research_operations.py | 8 ++++++++ 9 files changed, 74 insertions(+), 19 deletions(-) diff --git a/core/daily_observation.py b/core/daily_observation.py index 4992146c..96dea0a9 100644 --- a/core/daily_observation.py +++ b/core/daily_observation.py @@ -30,11 +30,11 @@ def _now(): def _expected_session(now): - from config.config_loader import load_yaml + from config.config_loader import load_settings from core.market_research import _calendar, _sessions, expected_session calendar = _calendar() expected_session(now) # Fail closed if the locally saved calendar is unknown. - settings = load_yaml(str(ROOT / "config/settings.yaml")) + settings = load_settings() market_close = datetime.strptime(settings.get("trading", {}).get("market_close", "15:30"), "%H:%M").time() local = now.astimezone(KST).replace(tzinfo=None) # The scheduled close run starts one hour after the regular session ends. diff --git a/core/execution_analytics.py b/core/execution_analytics.py index 1e8f5323..1116dbde 100644 --- a/core/execution_analytics.py +++ b/core/execution_analytics.py @@ -37,12 +37,16 @@ def _number(value, *, positive=False): return None -def _stamp(value): +def _stamp(value, *, system_local=False): if value is None: return None try: parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value).replace("Z", "+00:00")) - return (parsed.replace(tzinfo=KST) if parsed.tzinfo is None else parsed.astimezone(KST)).isoformat() + # OrderState timestamps come from datetime.now() in the host time zone. + # Report query dates without an offset retain their documented KST basis. + if parsed.tzinfo is None and not system_local: + parsed = parsed.replace(tzinfo=KST) + return parsed.astimezone(KST).isoformat() except (TypeError, ValueError): return None @@ -111,7 +115,7 @@ def wrapped(self, *args, **kwargs): values = bound.arguments entered = _now() reference = _number(decision.get("price"), positive=True) - reference_at = _stamp(decision.get("at")) + reference_at = _stamp(decision.get("at"), system_local=True) genuine_decision = reference is not None and reference_at is not None and reference_at <= entered payload = { "schema_version": 1, "attempt_id": uuid.uuid4().hex, @@ -122,7 +126,7 @@ def wrapped(self, *args, **kwargs): "reference_price": reference if genuine_decision else _number(values.get("price"), positive=True), "reference_at": reference_at if genuine_decision else entered, "reference_kind": "basket_plan" if genuine_decision and decision.get("source") == "basket_plan" else ("decision" if genuine_decision else "executor_input"), - "executor_entered_at": entered, "signal_at": _stamp(values.get("signal_at")), + "executor_entered_at": entered, "signal_at": _stamp(values.get("signal_at"), system_local=True), "status": "INPUT_RECEIVED", "filled_qty": 0, "filled_price": None, "commission_amount": None, "tax_amount": None, "fees_basis": "unavailable", "fill_price_basis": "modeled" if self.mode != "live" else "broker_observed", @@ -192,7 +196,8 @@ def observe_order_state(order, *, result=None, outcome=None): payload.update(order_id=order.order_id, status=getattr(order.status, "value", order.status), requested_qty=order.requested_qty, filled_qty=order.filled_qty, filled_price=_number(order.filled_price, positive=True), - submitted_at=_stamp(order.submitted_at), fill_observed_at=_stamp(order.filled_at), + submitted_at=_stamp(order.submitted_at, system_local=True), + fill_observed_at=_stamp(order.filled_at, system_local=True), remaining_qty=max(0, order.requested_qty - order.filled_qty)) if result is not None: payload["outcome"] = "completed" if result.get("success") else ("pending" if result.get("order_pending") else "rejected") diff --git a/core/research_operations.py b/core/research_operations.py index 20a2d6af..db11265a 100644 --- a/core/research_operations.py +++ b/core/research_operations.py @@ -42,14 +42,14 @@ def _now(): def _collection_period(now): """Allow an independent settled-close run, preserving T-1 price evidence.""" - from config.config_loader import load_yaml + from config.config_loader import load_settings from core.market_research import _calendar, _sessions, expected_session local = now.astimezone(KST) day = local.date().isoformat() expected = expected_session(local) # Also validates the calendar year. is_session = bool(len(_sessions(day, day, _calendar()))) - settings = load_yaml(str(ROOT / "config" / "settings.yaml")) + settings = load_settings() close = datetime.strptime(settings.get("trading", {}).get("market_close", "15:30"), "%H:%M").time() settled = datetime.combine(local.date(), close, tzinfo=KST) + timedelta(minutes=30) phase = "post_market" if is_session and local >= settled else "intraday" if is_session else "closed_day" diff --git a/core/weekly_report.py b/core/weekly_report.py index ae8ae114..b32333b3 100644 --- a/core/weekly_report.py +++ b/core/weekly_report.py @@ -126,7 +126,7 @@ def build_weekly_summary( rd = int(reconstructed_days or 0) ev_line = f"결측 {md}일 · 사이클 오류 {ce}건" rejected = m.get("recent_order_rejections") - ev_line += f" · 주문 거부 기록 {rejected if rejected is not None else '미확인'}건" + ev_line += f" · 주문 거부 기록 {rejected}건" if rejected is not None else " · 주문 거부 기록 미확인" entry = (eval_result or {}).get("entry_status") or {} if entry.get("status") == "BLOCKED": fields.append({"name": "⛔ 최근 평가의 매수 차단", "value": "; ".join(entry.get("reasons") or []) + f" (측정 {entry.get('measured_at')})", "inline": False}) diff --git a/tests/test_audit_evaluation.py b/tests/test_audit_evaluation.py index 77fbcbe3..ce406857 100644 --- a/tests/test_audit_evaluation.py +++ b/tests/test_audit_evaluation.py @@ -82,26 +82,40 @@ def test_daily_card_shows_new_rules_progress(): def test_attribution_window_starts_at_design_date(): name = "t_design_window" days = [date(2026, 8, 5), date(2026, 8, 6), date(2026, 8, 7), date(2026, 8, 10)] - # 8/06까지 누적 +10%, 8/10 누적 +12.2% → 설계 적용 이후 구간은 +2% + # 새 설계 적용일인 8/07 종가부터 8/10 종가까지 비교한다. + # 8/06 종가를 기준으로 삼으면 설계 적용 전 구간이 섞인다. _seed(f"basket_rebalance:{name}", days, cum=[5.0, 10.0, 11.0, 12.2], trade_day=date(2026, 8, 5)) + from database.repositories import append_portfolio_valuation + for day, cumulative in zip(days, [5.0, 10.0, 11.0, 12.2]): + append_portfolio_valuation( + account_key=f"basket_rebalance:{name}", mode="paper", day=day, + measured_at=datetime.combine(day, datetime.strptime("15:30", "%H:%M").time()), + total_value=1_000_000 * (1 + cumulative / 100), cash=400_000, + cumulative_return=cumulative, daily_return=0, mdd=0, + context={"valuation_kind": "close"}, + ) cfg = {"enabled": True, "initial_capital": 1_000_000, "holdings": {"005930": 1.0}, "target_stock_weight": 0.6, "promotion": {"design_effective_from": "2026-08-07"}} seen = {} - def _design(holdings, fraction, start, end): + def _design(holdings, fraction, start, end, **kwargs): seen["start"] = start + seen["end"] = end + assert kwargs["require_complete"] is True return 1.0 with patch("core.basket_evaluation.compute_design_portfolio_return", _design): r = _collect(name, cfg, include_attribution=True) assert seen["start"] == date(2026, 8, 7) + assert seen["end"] == date(2026, 8, 10) m = r["metrics"] assert m["attribution_window"][0] == "2026-08-07" - assert m["attribution_nav_pct"] == pytest.approx(2.0, abs=1e-6) - assert m["execution_gap_pct"] == pytest.approx(1.0, abs=1e-6) + close_return = (1.122 / 1.11 - 1) * 100 + assert m["attribution_nav_pct"] == pytest.approx(close_return, abs=1e-6) + assert m["execution_gap_pct"] == pytest.approx(close_return - 1, abs=1e-6) # ------------------------------------------------------------ 승격 판정 NaN diff --git a/tests/test_audit_reporting.py b/tests/test_audit_reporting.py index 5800ef9a..e0108c91 100644 --- a/tests/test_audit_reporting.py +++ b/tests/test_audit_reporting.py @@ -63,16 +63,20 @@ def test_flow_after_last_snapshot_is_not_yet_counted(monkeypatch): assert main.account_flows_by_snapshot("acct", snaps) == {} -def test_weekly_summary_does_not_call_reconstructed_week_accident_free(): +def test_weekly_summary_requires_complete_records_before_reporting_no_issues(): from core.weekly_report import build_weekly_summary - base = dict(basket_name="t", eval_result={"verdict": "WAIT", "metrics": {}}, + base = dict(basket_name="t", eval_result={"verdict": "WAIT", "metrics": {"recent_order_rejections": 0}}, missing_days=0, cycle_errors=0) clean = build_weekly_summary(**base) restored = build_weekly_summary(**base, reconstructed_days=1) ev = {f["name"]: f["value"] for f in restored["fields"]}["🛠 주간 이벤트"] - assert "무사고" in {f["name"]: f["value"] for f in clean["fields"]}["🛠 주간 이벤트"] - assert "무사고" not in ev and "나중에 채운 기록 1일" in ev + assert "기록된 운영 이상 없음" in {f["name"]: f["value"] for f in clean["fields"]}["🛠 주간 이벤트"] + assert "기록된 운영 이상 없음" not in ev and "나중에 채운 기록 1일" in ev + unknown = build_weekly_summary(**{**base, "eval_result": {"verdict": "WAIT", "metrics": {}}}) + unknown_events = {f["name"]: f["value"] for f in unknown["fields"]}["🛠 주간 이벤트"] + assert "주문 거부 기록 미확인" in unknown_events + assert "미확인건" not in unknown_events and "기록된 운영 이상 없음" not in unknown_events def test_regime_note_is_shown(): diff --git a/tests/test_daily_observation.py b/tests/test_daily_observation.py index 58e592e9..ee22e811 100644 --- a/tests/test_daily_observation.py +++ b/tests/test_daily_observation.py @@ -13,6 +13,13 @@ def setup(monkeypatch): monkeypatch.setattr(daily.shutil, "disk_usage", lambda _: SimpleNamespace(free=1024 ** 3)) +def test_missing_local_settings_uses_default_market_close(monkeypatch, tmp_path): + from config import config_loader + monkeypatch.setattr(config_loader, "CONFIG_DIR", tmp_path) + assert not (tmp_path / "settings.yaml").exists() + assert daily._expected_session(datetime(2026, 10, 13, 16, 30, tzinfo=daily.KST)) == "2026-10-13" + + def test_close_failure_does_not_block_research_and_status_is_not_green(monkeypatch, tmp_path): setup(monkeypatch) run = Mock(side_effect=[(2, {"status": "partial", "issues": ["quote_missing"]}), (0, {"status": "completed"})]) diff --git a/tests/test_execution_analytics.py b/tests/test_execution_analytics.py index 9bcf9dd8..642d9809 100644 --- a/tests/test_execution_analytics.py +++ b/tests/test_execution_analytics.py @@ -7,6 +7,7 @@ import sqlite3 import subprocess import sys +import time from types import SimpleNamespace import uuid @@ -55,7 +56,23 @@ def _report(executor): return execution_cost_report(account_key=executor.account_key, db_path=executor.report_path) -def test_actual_paper_round_trip_observes_decision_and_signed_fill_costs(executor): +@pytest.fixture(params=[None, "UTC", "Asia/Seoul"]) +def host_timezone(request, monkeypatch): + if request.param is None: + yield + return + if not hasattr(time, "tzset"): + pytest.skip("이 운영체제는 프로세스 시간대 변경을 지원하지 않습니다.") + try: + with monkeypatch.context() as scoped: + scoped.setenv("TZ", request.param) + time.tzset() + yield + finally: + time.tzset() + + +def test_actual_paper_round_trip_observes_decision_and_signed_fill_costs(executor, host_timezone): decision_at = (datetime.now(timezone(timedelta(hours=9))) - timedelta(seconds=20)).isoformat() bought = _buy(executor, decision_context={"price": 59900, "at": decision_at, "source": "basket_plan"}) assert bought["success"], bought diff --git a/tests/test_research_operations.py b/tests/test_research_operations.py index 91e79da9..098e79a2 100644 --- a/tests/test_research_operations.py +++ b/tests/test_research_operations.py @@ -13,6 +13,14 @@ def configure(monkeypatch): monkeypatch.setattr(ops.shutil, "disk_usage", lambda path: SimpleNamespace(free=1024 ** 3)) +def test_missing_local_settings_uses_default_market_close(monkeypatch, tmp_path): + from config import config_loader + monkeypatch.setattr(config_loader, "CONFIG_DIR", tmp_path) + assert not (tmp_path / "settings.yaml").exists() + period = ops._collection_period(datetime(2026, 10, 13, 16, tzinfo=ops.KST)) + assert period["phase"] == "post_market" and period["is_trading_day"] + + def test_daily_run_persists_and_duplicate_does_not_refetch(monkeypatch, tmp_path): configure(monkeypatch) run = Mock(return_value=SimpleNamespace(returncode=0))