65 Commits

Author SHA1 Message Date
agentson
4ca582b418 docs: SSOT 문서 허브 도입 및 동기화 자동 검증 게이트 (#350)
Some checks failed
Gitea CI / test (pull_request) Failing after 5s
Gitea CI / test (push) Failing after 6s
문서 중복·드리프트를 구조적으로 방지하기 위해 SSOT 원칙을 문서 체계에 적용한다.

신규:
- docs/README.md: 문서 라우팅/역할/읽기 순서/SSOT 정의 (상대경로 링크)
- scripts/validate_docs_sync.py: 가변 수치 하드코딩 금지 + 누락 엔드포인트 검사

수정:
- CLAUDE.md: 문서 진입점 추가, SmartScanner 세부 동작 → architecture.md 링크
- README.md: 문서 네비게이션 섹션 추가, 고정 수치/파일별 케이스 수 제거
- docs/commands.md: validate_docs_sync.py 명령 추가; 중복 엔드포인트 2행 제거
- docs/testing.md: 테스트 총량 고정값 → pytest --collect-only -q 동적 확인으로 전환
- docs/ouroboros/82_doc_restructure_plan.md: draft → active, 실행 현황으로 전환
- .gitea/PULL_REQUEST_TEMPLATE.md: Docs Sync 체크리스트 추가
- .gitea/workflows/ci.yml + .github/workflows/ci.yml: validate_docs_sync 단계 추가

검증:
- python3 scripts/validate_docs_sync.py: PASS

Closes #350
2026-03-01 17:07:59 +09:00
agentson
6b34367656 docs: v2/v3 구현 감사 문서 피드백 전체 반영 (#349)
Some checks failed
Gitea CI / test (push) Failing after 3s
Gitea CI / test (pull_request) Failing after 3s
11회 리뷰 사이클에서 남긴 [코멘트]를 모두 본문에 반영하고 블록을 제거한다.

변경 문서:
- docs/architecture.md: SmartScanner 동작 모드(both), 대시보드 10 API,
  DB 스키마(session_id/fx_pnl/mode), config 변수 갱신
- docs/commands.md: /api/pnl/history, /api/positions 엔드포인트 추가
- docs/testing.md: 테스트 수 고정값 제거, SmartScanner fallback 최신화,
  Dashboard 10 API routes 반영
- README.md: 고정 수치 제거, Gitea CI 명시, 파일별 수치 'CI 기준 변동' 표기
- CLAUDE.md: SmartScanner 섹션명 변경, 고정 수치 제거
- docs/requirements-log.md: #318~#331 구현 항목 추가
- docs/ouroboros/80_implementation_audit.md: ROOT-5/6/7 분리,
  REQ-V3-008 함수명 병기, v3 ~85% / 거버넌스 ~60%로 갱신
- docs/ouroboros/85_loss_recovery_action_plan.md: ACT-07 함수명 병기,
  테스트 수 갱신, 6.1/6.2 정확도 개선
- docs/ouroboros/60_repo_enforcement_checklist.md: CI job/step 구분 표 추가
- docs/ouroboros/README.md: 50_* 문서 (A)/(B) 보조 표기

Closes #349
2026-03-01 17:06:56 +09:00
e65a0e3585 Merge pull request 'test: session_id 명시 전파 회귀 고정 (#326)' (#348) from feature/issue-326-session-id-explicit-propagation into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #348
2026-03-01 13:42:17 +09:00
agentson
e3a3aada83 test: set rt_cd success response in session-id daily regression test (#326)
Some checks failed
Gitea CI / test (push) Failing after 3s
Gitea CI / test (pull_request) Failing after 3s
2026-03-01 13:38:57 +09:00
agentson
db316c539b test: align daily session mock order response with rt_cd success path (#326)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Failing after 3s
2026-03-01 13:37:04 +09:00
agentson
2df787757a test: enforce explicit runtime session_id propagation in realtime/daily logs (#326)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Failing after 4s
2026-03-01 10:14:59 +09:00
5f079206c6 Merge pull request 'infra: CI 자동 검증 강화 (정책 레지스트리 + TASK-REQ 매핑) (#330)' (#347) from feature/issue-330-governance-ci-guard into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #347
2026-03-01 10:11:18 +09:00
agentson
e9de950bec ci: wire governance traceability env in gitea workflow and guard zero SHA (#330)
Some checks failed
Gitea CI / test (push) Failing after 3s
Gitea CI / test (pull_request) Failing after 3s
2026-03-01 10:06:25 +09:00
agentson
c31ee37f13 infra: enforce governance sync and TASK-REQ mapping in CI (#330)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Failing after 3s
2026-03-01 09:58:45 +09:00
2ba1d1ad4d Merge pull request 'feat: Triple Barrier 시간장벽을 캘린더 분 기반으로 전환 (#329)' (#346) from feature/issue-329-triple-barrier-calendar-minutes into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #346
2026-03-01 09:57:01 +09:00
agentson
273a3c182a refactor: simplify timestamp normalization after non-null validation (#329)
Some checks failed
Gitea CI / test (push) Failing after 3s
Gitea CI / test (pull_request) Failing after 4s
2026-03-01 09:50:45 +09:00
agentson
701350fb65 feat: switch backtest triple barrier to calendar-minute horizon (#329)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Failing after 3s
2026-03-01 09:44:24 +09:00
35d81fb73d Merge pull request 'feat: 블랙아웃 복구 시 가격/세션 재검증 강화 (#328)' (#345) from feature/issue-328-blackout-revalidation into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #345
2026-03-01 09:43:09 +09:00
agentson
5fae9765e7 test: add blackout recovery overseas/failure revalidation coverage (#328)
Some checks failed
Gitea CI / test (push) Failing after 3s
Gitea CI / test (pull_request) Failing after 3s
2026-03-01 09:40:00 +09:00
agentson
0ceb2dfdc9 feat: revalidate blackout recovery orders by price/session context (#328)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Failing after 3s
2026-03-01 09:33:28 +09:00
89347ee525 Merge pull request 'feat: 세션 전환 시 리스크 파라미터 동적 재로딩 (#327)' (#344) from feature/issue-327-session-risk-reload into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #344
2026-03-01 09:31:14 +09:00
agentson
42c06929ea test: add session-risk reload edge-case coverage (#327)
Some checks failed
Gitea CI / test (push) Failing after 3s
Gitea CI / test (pull_request) Failing after 3s
2026-02-28 22:20:59 +09:00
agentson
5facd22ef9 feat: reload session risk profile on session transitions (#327)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Failing after 4s
2026-02-28 21:04:06 +09:00
3af62ce598 Merge pull request 'feat: v2 staged exit에 실제 피처(ATR, pred_down_prob) 공급 (#325)' (#343) from feature/issue-325-staged-exit-real-features into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #343
2026-02-28 20:59:38 +09:00
agentson
62cd8a81a4 feat: feed staged-exit with ATR/RSI runtime features (#325)
Some checks failed
Gitea CI / test (push) Failing after 3s
Gitea CI / test (pull_request) Failing after 3s
2026-02-28 20:58:23 +09:00
dd8549b912 Merge pull request 'feat: KR ATR-based dynamic hard-stop threshold (#318)' (#342) from feature/issue-318-kr-atr-dynamic-stoploss into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #342
2026-02-28 20:56:18 +09:00
agentson
8bba85da1e feat: add KR ATR-based dynamic hard-stop threshold (#318)
Some checks failed
Gitea CI / test (push) Failing after 4s
Gitea CI / test (pull_request) Failing after 3s
2026-02-28 18:30:52 +09:00
fc6083bd2a Merge pull request 'feat: stop-loss reentry cooldown guard (#319)' (#341) from feature/issue-319-stoploss-reentry-cooldown into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #341
2026-02-28 18:27:12 +09:00
agentson
5f53b02da8 test: add stop-loss reentry cooldown behavioral coverage (#319)
Some checks failed
Gitea CI / test (pull_request) Failing after 4s
Gitea CI / test (push) Failing after 4s
2026-02-28 18:24:28 +09:00
agentson
82808a8493 feat: enforce stop-loss reentry cooldown window (#319) 2026-02-28 18:24:28 +09:00
9456d66de4 Merge pull request 'feat: US minimum price entry filter (#320)' (#340) from feature/issue-320-us-min-price-filter into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #340
2026-02-28 18:22:28 +09:00
33b97f21ac Merge pull request 'fix: log blackout recovery executions to DB (#324)' (#339) from feature/issue-324-blackout-recovery-trade-log into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #339
2026-02-28 18:22:11 +09:00
3b135c3080 Merge pull request 'fix: SELL outcome PnL uses sell quantity (#322)' (#337) from feature/issue-322-sell-pnl-sell-qty into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #337
2026-02-28 18:21:34 +09:00
1b0d5568d3 Merge pull request 'infra: governance registry sync gate in CI (#330)' (#335) from feature/issue-330-governance-ci-guard into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #335
2026-02-28 18:21:10 +09:00
agentson
2406a80782 test: add governance validator unit coverage (#330)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Failing after 53s
2026-02-28 17:40:51 +09:00
b8569d9de1 Merge pull request 'fix: exchange-aware latest BUY matching (#323)' (#338) from feature/issue-323-buy-match-exchange-code into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #338
2026-02-28 17:37:43 +09:00
agentson
92261da414 fix: include exchange_code in latest BUY matching key (#323)
Some checks failed
Gitea CI / test (pull_request) Has been cancelled
Gitea CI / test (push) Has been cancelled
2026-02-28 17:17:21 +09:00
agentson
9267f1fb77 test: add US minimum price boundary and KR-scope coverage (#320)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 17:15:10 +09:00
agentson
fd0246769a test: add sell qty fallback guard and quantity-basis coverage (#322)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 17:13:56 +09:00
ea7260d574 Merge pull request 'feat: explicit session_id propagation in logs (#326)' (#336) from feature/issue-326-session-id-explicit-propagation into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #336
2026-02-28 17:07:34 +09:00
a2855e286e Merge pull request 'feat: minute-based triple barrier horizon (#329)' (#334) from feature/issue-329-triple-barrier-minutes into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #334
2026-02-28 17:06:31 +09:00
28ded34441 Merge pull request 'fix: evolved strategy syntax guard (#321)' (#333) from feature/issue-321-evolution-syntax-guard into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #333
2026-02-28 17:06:04 +09:00
agentson
08607eaa56 feat: block US BUY entries below minimum price threshold (#320)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:40:19 +09:00
agentson
5c107d2435 fix: persist blackout recovery executions to trades log (#324)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:39:30 +09:00
agentson
6d7e6557d2 fix: compute SELL decision outcome using sell quantity (#322)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:38:10 +09:00
agentson
11b9ad126f feat: propagate runtime session_id across decision and trade logs (#326)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:37:32 +09:00
agentson
2e394cd17c infra: enforce governance registry sync checks in CI (#330)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:36:05 +09:00
agentson
c641097fe7 feat: support minute-based triple barrier horizon (#329)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:35:55 +09:00
agentson
2f3b2149d5 fix: add syntax guard for evolved strategy generation (#321)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:35:35 +09:00
13a6d6612a Merge pull request 'docs: 감사 문서 재구조화 + 손실 복구 실행 계획 (#331)' (#332) from feature/issue-331-doc-restructure into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Reviewed-on: #332
2026-02-28 14:18:46 +09:00
agentson
ca5fa73769 docs: restructure audit docs and create loss recovery action plan (#331)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
- Clean up 80_implementation_audit.md: remove review history (6.1/6.2),
  extract SQL queries, condense data quality section
- Create 85_loss_recovery_action_plan.md with 13 action items across
  3 phases (Phase 1: stop bleeding, Phase 2: data integrity + v2,
  Phase 3: v3 session optimization)
- Extract standard audit SQL queries to scripts/audit_queries.sql
- Update docs/ouroboros/README.md with 85_ link
- Create Gitea issues #318-#330 for all 13 action items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:21:15 +09:00
agentson
ab9ea56efa docs: consolidate implementation audit updates and add restructure plan
Some checks failed
Gitea CI / test (push) Has been cancelled
2026-02-28 13:04:15 +09:00
8dc9f95032 Merge pull request 'process: enforce forbidden runtime invariants in monitor (#316)' (#317) from feature/issue-316-weekend-forbidden-monitor into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
2026-02-28 09:37:41 +09:00
agentson
dd51ffb6ac process: enforce forbidden runtime invariants in monitor (#316)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 09:37:16 +09:00
0542e78f90 Merge pull request 'process: automate backtest gate for PR/push/schedule (#314)' (#315) from feature/issue-314-backtest-gate-automation into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
2026-02-28 03:25:45 +09:00
agentson
8396dc1606 process: automate backtest gate for PR/push/schedule (#314)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 03:25:00 +09:00
343631a935 Merge pull request 'feat: integrate v2 backtest validation pipeline (#305)' (#313) from feature/issue-305-backtest-pipeline-integration into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
2026-02-27 23:59:34 +09:00
agentson
c00525eb4d feat: integrate v2 backtest pipeline for triple barrier and walk-forward (#305)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-27 23:58:52 +09:00
1ae12f92f6 Merge pull request 'fix: runtime staged exit semantics in trading_cycle and run_daily_session (#304)' (#312) from feature/issue-304-runtime-staged-exit-semantics into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
2026-02-27 23:49:59 +09:00
agentson
98dab2e06e fix: apply staged exit semantics in runtime paths (#304)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-27 23:48:52 +09:00
a63d23fab9 Merge pull request 'process: harden implementation-start gate before coding (#310)' (#311) from feature/issue-310-implementation-start-gate into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
2026-02-27 23:24:40 +09:00
agentson
85a59542f8 process: harden implementation-start gate before coding
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-27 23:21:54 +09:00
5830791355 Merge pull request 'process: enforce session handover gate across sessions (#308)' (#309) from feature/issue-308-session-handover-gate into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
2026-02-27 23:09:04 +09:00
agentson
b1610f14c5 process: enforce session handover gate across sessions (#308)
Some checks failed
Gitea CI / test (pull_request) Has been cancelled
Gitea CI / test (push) Has been cancelled
2026-02-27 23:08:29 +09:00
1984065499 Merge pull request 'process: enforce process-change-first and staged acceptance gates (#306)' (#307) from feature/issue-306-process-change-first into feature/v3-session-policy-stream 2026-02-27 22:46:33 +09:00
agentson
d912471d0e process: enforce process-change-first and staged ticket maturity (#306) 2026-02-27 22:46:18 +09:00
5f337e2ebc Merge pull request 'fix: realtime include extended KR/US sessions (#301)' (#303) from feature/issue-301-extended-session-schedule into feature/v3-session-policy-stream 2026-02-27 22:30:26 +09:00
agentson
4a404875a9 fix: include extended KR/US sessions in realtime market scheduling (#301) 2026-02-27 22:30:13 +09:00
cdd3814781 Merge pull request 'governance: enforce runtime NOT_OBSERVED recovery gates (#301)' (#302) from feature/issue-301-runtime-verify-recovery into feature/v3-session-policy-stream 2026-02-27 22:14:03 +09:00
agentson
dbf57b5068 governance: enforce runtime verification coverage gates (#301) 2026-02-27 22:13:11 +09:00
43 changed files with 5426 additions and 183 deletions

View File

@@ -0,0 +1,41 @@
---
name: Runtime Verification Incident
about: 실운영/스테이징 동작 검증 중 발견된 이상 징후 등록
title: "[RUNTIME-VERIFY][SCN-XXX] "
labels: runtime, verification
---
## Summary
- 현상:
- 최초 관측 시각(UTC):
## Reproduction / Observation
- 실행 모드(`live`/`paper`):
- 세션(`NXT`, `US_PRE`, `US_DAY`, `US_AFTER`, ...):
- 실행 커맨드:
- 로그 경로:
## Expected vs Actual
- Expected:
- Actual:
## Requirement Mapping
- REQ:
- TASK:
- TEST:
## Temporary Mitigation
- 즉시 완화책:
## Close Criteria
- [ ] Dev 수정 반영
- [ ] Verifier 재검증 PASS
- [ ] Runtime Verifier 재관측 PASS
- [ ] `NOT_OBSERVED = 0`

View File

@@ -0,0 +1,60 @@
## Linked Issue
- Closes #N
## Scope
- REQ: `REQ-...`
- TASK: `TASK-...`
- TEST: `TEST-...`
## Docs Sync (SSOT)
- [ ] `docs/README.md` 라우팅/역할 영향 여부 확인
- [ ] SSOT 문서(architecture/commands/testing/ouroboros registry) 업데이트 또는 "변경 없음" 명시
- [ ] 요약 문서(`README.md`, `CLAUDE.md`)에 가변 수치 하드코딩 추가 없음
- SSOT 반영 위치(링크):
## Ticket Stage
- Current stage: `Implemented` / `Integrated` / `Observed` / `Accepted`
- Previous stage evidence link:
## Main -> Verifier Directive Contract
- Scope: 대상 요구사항/코드/로그 경로
- Method: 실행 커맨드 + 관측 포인트
- PASS criteria:
- FAIL criteria:
- NOT_OBSERVED criteria:
- Evidence format: PR 코멘트 `Coverage Matrix`
## Verifier Coverage Matrix (Required)
| Item | Evidence | Status (PASS/FAIL/NOT_OBSERVED) |
|---|---|---|
| REQ-... | 링크/로그 | PASS |
`NOT_OBSERVED`가 1개라도 있으면 승인/머지 금지.
## Gitea Preflight
- [ ] `docs/commands.md``docs/workflow.md` 트러블슈팅 선확인
- [ ] `tea` 사용 (`gh` 미사용)
## Session Handover Gate
- [ ] `python3 scripts/session_handover_check.py --strict` 통과
- [ ] `workflow/session-handover.md` 최신 엔트리가 현재 브랜치/당일(UTC) 기준으로 갱신됨
- 최신 handover 엔트리 heading:
## Runtime Evidence
- 시스템 실제 구동 커맨드:
- 모니터링 로그 경로:
- 이상 징후/이슈 링크:
## Approval Gate
- [ ] Static Verifier approval comment linked
- [ ] Runtime Verifier approval comment linked

57
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,57 @@
name: Gitea CI
on:
pull_request:
push:
branches:
- main
- feature/**
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install ".[dev]"
- name: Session handover gate
run: python3 scripts/session_handover_check.py --strict
- name: Validate governance assets
env:
GOVERNANCE_PR_TITLE: ${{ github.event.pull_request.title }}
GOVERNANCE_PR_BODY: ${{ github.event.pull_request.body }}
run: |
RANGE=""
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.sha }}"
elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
RANGE="${{ github.event.before }}...${{ github.sha }}"
fi
if [ -n "$RANGE" ]; then
python3 scripts/validate_governance_assets.py "$RANGE"
else
python3 scripts/validate_governance_assets.py
fi
- name: Validate Ouroboros docs
run: python3 scripts/validate_ouroboros_docs.py
- name: Validate docs sync
run: python3 scripts/validate_docs_sync.py
- name: Lint
run: ruff check src/ tests/
- name: Run tests with coverage
run: pytest -v --cov=src --cov-report=term-missing --cov-fail-under=80

66
.github/workflows/backtest-gate.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Backtest Gate
on:
pull_request:
branches: ["**"]
push:
branches:
- "feature/**"
schedule:
# Daily scheduled gate (KST 01:20)
- cron: "20 16 * * *"
workflow_dispatch:
inputs:
mode:
description: "backtest mode (auto|smoke|full)"
required: false
default: "auto"
base_ref:
description: "git base ref for changed-file diff"
required: false
default: "origin/main"
jobs:
backtest-gate:
runs-on: ubuntu-latest
concurrency:
group: backtest-gate-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install ".[dev]"
- name: Resolve base ref
id: base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.base_ref }}" ]; then
echo "ref=${{ github.event.inputs.base_ref }}" >> "$GITHUB_OUTPUT"
else
echo "ref=origin/main" >> "$GITHUB_OUTPUT"
fi
- name: Run backtest gate
env:
BASE_REF: ${{ steps.base.outputs.ref }}
BACKTEST_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mode || 'auto' }}
FORCE_FULL_BACKTEST: ${{ github.event_name == 'schedule' && 'true' || 'false' }}
run: bash scripts/backtest_gate.sh
- name: Upload backtest logs
if: always()
uses: actions/upload-artifact@v4
with:
name: backtest-gate-logs
path: data/backtest-gate/*.log

View File

@@ -21,6 +21,32 @@ jobs:
- name: Install dependencies
run: pip install ".[dev]"
- name: Session handover gate
run: python3 scripts/session_handover_check.py --strict
- name: Validate governance assets
env:
GOVERNANCE_PR_TITLE: ${{ github.event.pull_request.title }}
GOVERNANCE_PR_BODY: ${{ github.event.pull_request.body }}
run: |
RANGE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.sha }}"
elif [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
RANGE="${{ github.event.before }}...${{ github.sha }}"
fi
if [ -n "$RANGE" ]; then
python3 scripts/validate_governance_assets.py "$RANGE"
else
python3 scripts/validate_governance_assets.py
fi
- name: Validate Ouroboros docs
run: python3 scripts/validate_ouroboros_docs.py
- name: Validate docs sync
run: python3 scripts/validate_docs_sync.py
- name: Lint
run: ruff check src/ tests/

View File

@@ -2,6 +2,10 @@
AI-powered trading agent for global stock markets with self-evolution capabilities.
## Documentation Entry
문서 우선순위/역할은 [docs/README.md](docs/README.md)를 기준으로 한다.
## Quick Start
```bash
@@ -81,9 +85,9 @@ SCANNER_TOP_N=3 # Max candidates per scan
- **Evolution-ready** — Selection context logged for strategy optimization
- **Fault-tolerant** — Falls back to static watchlist on API failure
### Realtime Mode Only
### Trading Mode Integration
Smart Scanner runs in `TRADE_MODE=realtime` only. Daily mode uses static watchlists for batch efficiency.
Smart Scanner behavior and mode integration are defined in [docs/architecture.md](docs/architecture.md).
## Documentation
@@ -122,7 +126,7 @@ src/
├── broker/ # KIS API client (domestic + overseas)
├── context/ # L1-L7 hierarchical memory system
├── core/ # Risk manager (READ-ONLY)
├── dashboard/ # FastAPI read-only monitoring (8 API endpoints)
├── dashboard/ # FastAPI read-only monitoring (endpoint details: docs/commands.md)
├── data/ # External data integration (news, market data, calendar)
├── evolution/ # Self-improvement (optimizer, daily review, scorecard)
├── logging/ # Decision logger (audit trail)
@@ -133,7 +137,7 @@ src/
├── main.py # Trading loop orchestrator
└── config.py # Settings (from .env)
tests/ # 551 tests across 25 files
tests/ # Test suite (details: docs/testing.md)
docs/ # Extended documentation
```

View File

@@ -2,6 +2,10 @@
KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자체 전략 코드를 TDD 기반으로 진화시키는 자율 주식 트레이딩 에이전트.
## 문서 네비게이션
문서 전체 라우팅/역할/우선순위는 [docs/README.md](docs/README.md)를 기준으로 본다.
## 아키텍처
```
@@ -39,7 +43,7 @@ KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자
| 컨텍스트 | `src/context/` | L1-L7 계층형 메모리 시스템 |
| 분석 | `src/analysis/` | RSI, ATR, Smart Volatility Scanner |
| 알림 | `src/notifications/` | 텔레그램 양방향 (알림 + 9개 명령어) |
| 대시보드 | `src/dashboard/` | FastAPI 읽기 전용 모니터링 (8개 API) |
| 대시보드 | `src/dashboard/` | FastAPI 읽기 전용 모니터링 (엔드포인트 목록은 `docs/commands.md`) |
| 진화 | `src/evolution/` | 전략 진화 + Daily Review + Scorecard |
| 의사결정 로그 | `src/logging/` | 전체 거래 결정 감사 추적 |
| 데이터 | `src/data/` | 뉴스, 시장 데이터, 경제 캘린더 연동 |
@@ -153,22 +157,10 @@ docker compose up -d ouroboros
## 테스트
551개 테스트가 25개 파일에 걸쳐 구현되어 있습니다. 최소 커버리지 80%.
테스트 정책/실행/구성은 [docs/testing.md](docs/testing.md)를 기준으로 한다.
```
tests/test_scenario_engine.py — 시나리오 매칭 (44개)
tests/test_data_integration.py — 외부 데이터 연동 (38개)
tests/test_pre_market_planner.py — 플레이북 생성 (37개)
tests/test_main.py — 거래 루프 통합 (37개)
tests/test_token_efficiency.py — 토큰 최적화 (34개)
tests/test_strategy_models.py — 전략 모델 검증 (33개)
tests/test_telegram_commands.py — 텔레그램 명령어 (31개)
tests/test_latency_control.py — 지연시간 제어 (30개)
tests/test_telegram.py — 텔레그램 알림 (25개)
... 외 16개 파일
```
**상세**: [docs/testing.md](docs/testing.md)
- 최소 커버리지: 80%
- 전체 수집 기준: `pytest --collect-only -q`
## 기술 스택
@@ -177,8 +169,8 @@ tests/test_telegram.py — 텔레그램 알림 (25개)
- **AI**: Google Gemini Pro
- **DB**: SQLite (5개 테이블: trades, contexts, decision_logs, playbooks, context_metadata)
- **대시보드**: FastAPI + uvicorn
- **검증**: pytest + coverage (551 tests)
- **CI/CD**: GitHub Actions
- **검증**: pytest + coverage (`docs/testing.md` 기준)
- **CI/CD**: Gitea CI (`.gitea/workflows/ci.yml`)
- **배포**: Docker + Docker Compose
## 프로젝트 구조
@@ -212,7 +204,7 @@ The-Ouroboros/
│ ├── config.py # Pydantic 설정
│ ├── db.py # SQLite 데이터베이스
│ └── main.py # 비동기 거래 루프
├── tests/ # 551개 테스트 (25개 파일)
├── tests/ # 테스트 스위트 (세부는 docs/testing.md 참조)
├── Dockerfile # 멀티스테이지 빌드
├── docker-compose.yml # 서비스 오케스트레이션
└── pyproject.toml # 의존성 및 도구 설정

29
docs/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Documentation Map
이 문서는 저장소 문서의 단일 라우팅/역할 정의다.
각 문서는 아래 역할 범위를 넘지 않는다.
## Reading Order
1. [Project README](../README.md): 빠른 시작, 개요
2. [Architecture](architecture.md): 시스템 구성/데이터 흐름
3. [Workflow](workflow.md): 개발/PR/검증 절차
4. [Commands](commands.md): 실행/운영 명령 레퍼런스
5. [Testing](testing.md): 테스트 전략/작성/운영
6. [Ouroboros Hub](ouroboros/README.md): 기획/요구사항/실행 통제 문서군
## Single Source of Truth (SSOT)
- 아키텍처/동작 기준: [Architecture](architecture.md)
- 실행 명령 기준: [Commands](commands.md)
- 테스트 정책 기준: [Testing](testing.md)
- 요구사항/REQ 기준: [Requirements Registry](ouroboros/01_requirements_registry.md)
- 작업/TASK 기준: [Code Work Orders](ouroboros/30_code_level_work_orders.md)
- 수용/TEST 기준: [Acceptance Plan](ouroboros/40_acceptance_and_test_plan.md)
## Authoring Rules
- `README.md`, `CLAUDE.md`는 입문/요약 역할만 가진다.
- 가변 수치(테스트 개수, API 개수, 세부 파일별 케이스 수)는 요약 문서에 고정값으로 중복 기재하지 않는다.
- 수치/정책 상세는 SSOT 문서에만 기록하고, 요약 문서에서는 링크로 참조한다.
- 동일 내용이 2개 이상 문서에 반복되면 요약 + 링크 형태로 축약한다.

View File

@@ -12,6 +12,8 @@ It is distinct from `docs/requirements-log.md`, which records **project/product
1. **Workflow enforcement**
- Follow `docs/workflow.md` for all changes.
- Before any Gitea issue/PR/comment operation, read `docs/commands.md` and `docs/workflow.md` troubleshooting section.
- Use `tea` for Gitea operations; do not use GitHub CLI (`gh`) in this repository workflow.
- Create a Gitea issue before any code or documentation change.
- Work on a feature branch `feature/issue-{N}-{short-description}` and open a PR.
- Never commit directly to `main`.
@@ -30,6 +32,16 @@ It is distinct from `docs/requirements-log.md`, which records **project/product
(or in a dedicated policy doc) and reference it when working.
- Keep entries short and concrete, with dates.
5. **Session start handover gate**
- Before implementation/verification work, run `python3 scripts/session_handover_check.py --strict`.
- Keep `workflow/session-handover.md` updated with a same-day entry for the active branch.
- If the check fails, stop and fix handover artifacts first.
6. **Process-change-first execution gate**
- If process/governance change is required, merge the process ticket to the feature branch first.
- Do not start code/test edits for implementation tickets until process merge evidence is confirmed.
- Subagents must be constrained to read-only exploration until the process gate is satisfied.
## Change Control
- Changes to this file follow the same workflow as code changes.
@@ -43,3 +55,15 @@ It is distinct from `docs/requirements-log.md`, which records **project/product
- When work requires guidance, consult the relevant `docs/` policies first.
- Any code change must be accompanied by relevant documentation updates.
- Persist user constraints across sessions by recording them in this document.
### 2026-02-27
- All agents must pre-read `docs/commands.md` and `docs/workflow.md` troubleshooting before running Gitea issue/PR/comment commands.
- `gh` CLI is prohibited for repository ticket/PR operations; use `tea` (or documented Gitea API fallback only).
- Session start must pass `python3 scripts/session_handover_check.py --strict`, with branch-matched entry in `workflow/session-handover.md`.
### 2026-02-27
- Apply process-change-first as an execution gate: process ticket must be merged before implementation ticket coding.
- Handover entry must record concrete `next_ticket` and `process_gate_checked`; placeholders are not allowed in strict gate.
- Before process merge confirmation, all subagent tasks must remain read-only (analysis only).

View File

@@ -84,6 +84,37 @@ High-frequency trading with individual stock analysis:
- Momentum scoring (0-100 scale)
- Breakout/breakdown pattern detection
**TripleBarrierLabeler** (`triple_barrier.py`) — Financial time-series labeling (v2)
- Triple Barrier method: upper (take-profit), lower (stop-loss), time barrier
- First-touch labeling: labels confirmed by whichever barrier is breached first
- `max_holding_minutes` (calendar-minute) time barrier — session-aware, bar-period independent
- Tie-break mode: `"stop_first"` (conservative) or `"take_first"`
- Feature-label strict separation to prevent look-ahead bias
**BacktestPipeline** (`backtest_pipeline.py`) — End-to-end validation pipeline (v2)
- `run_v2_backtest_pipeline()`: cost guard → triple barrier labeling → walk-forward splits → fold scoring
- `BacktestPipelineResult`: artifact contract for reproducible output
- `fold_has_leakage()`: leakage detection utility
**WalkForwardSplit** (`walk_forward_split.py`) — Time-series validation (v2)
- Fold-based walk-forward splits (no random shuffling)
- Purge/Embargo: excludes N bars before/after fold boundaries to prevent data leakage
**BacktestExecutionModel** (`backtest_execution_model.py`) — Conservative fill simulation (v2/v3)
- Session-aware slippage: KRX_REG 5bps, NXT_AFTER 15bps, US_REG 3bps, US_PRE/DAY 30-50bps
- Order failure rate simulation per session
- Partial fill rate simulation with min/max ratio bounds
- Unfavorable-direction fill assumption (no simple close-price fill)
**BacktestCostGuard** (`backtest_cost_guard.py`) — Cost model validator (v2)
- `validate_backtest_cost_model()`: fail-fast check that session cost assumptions are present
- Enforces realistic cost assumptions before any backtest run proceeds
**SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline
- **Domestic (KR)**:
@@ -98,7 +129,7 @@ High-frequency trading with individual stock analysis:
- **Step 4**: Return top N candidates (default 3)
- **Fallback (overseas only)**: If ranking API is unavailable, uses dynamic universe
from runtime active symbols + recent traded symbols + current holdings (no static watchlist)
- **Realtime mode only**: Daily mode uses batch processing for API efficiency
- **Both modes**: Realtime 중심이지만 Daily 경로(`run_daily_session()`)에서도 후보 선별에 사용
**Benefits:**
- Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates
@@ -124,9 +155,9 @@ High-frequency trading with individual stock analysis:
- Selects appropriate context layers for current market conditions
### 4. Risk Manager (`src/core/risk_manager.py`)
### 4. Risk Manager & Session Policy (`src/core/`)
**RiskManager** — Safety circuit breaker and order validation
**RiskManager** (`risk_manager.py`) — Safety circuit breaker and order validation
> **READ-ONLY by policy** (see [`docs/agents.md`](./agents.md))
@@ -136,8 +167,59 @@ High-frequency trading with individual stock analysis:
- **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash
- Must always be enforced, cannot be disabled
**OrderPolicy** (`order_policy.py`) — Session classification and order type enforcement (v3)
- `classify_session_id()`: Classifies current KR/US session from KST clock
- KR: `NXT_PRE` (08:00-08:50), `KRX_REG` (09:00-15:30), `NXT_AFTER` (15:30-20:00)
- US: `US_DAY` (10:00-18:00), `US_PRE` (18:00-23:30), `US_REG` (23:30-06:00), `US_AFTER` (06:00-07:00)
- Low-liquidity session detection: `NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`
- Market order forbidden in low-liquidity sessions (`OrderPolicyRejected` raised)
- Limit/IOC/FOK orders always allowed
**KillSwitch** (`kill_switch.py`) — Emergency trading halt orchestration (v2)
- Fixed 5-step atomic sequence:
1. Block new orders (`new_orders_blocked = True`)
2. Cancel all unfilled orders
3. Refresh order state (query final status)
4. Reduce risk (force-close or reduce positions)
5. Snapshot state + send Telegram alert
- Async, injectable step callables — each step individually testable
- Highest priority: overrides overnight exception and all other rules
**BlackoutManager** (`blackout_manager.py`) — KIS maintenance window handling (v3)
- Configurable blackout windows (e.g., `23:30-00:10 KST`)
- `queue_order()`: Queues order intent during blackout, enforces max queue size
- `pop_recovery_batch()`: Returns queued intents after recovery
- Recovery revalidation path (in `src/main.py`):
- Stale BUY drop (position already exists)
- Stale SELL drop (position absent)
- `validate_order_policy()` rechecked
- Price drift check (>5% → drop, configurable via `BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT`)
### 5. Strategy (`src/strategy/`)
**PositionStateMachine** (`position_state_machine.py`) — 4-state sell state machine (v2)
- States: `HOLDING` → `BE_LOCK` → `ARMED` → `EXITED`
- `HOLDING`: Normal holding
- `BE_LOCK`: Profit ≥ `be_arm_pct` — stop-loss elevated to break-even
- `ARMED`: Profit ≥ `arm_pct` — peak-tracking trailing stop active
- `EXITED`: Position closed
- `promote_state()`: Immediately elevates to highest admissible state (handles gaps/skips)
- `evaluate_exit_first()`: EXITED conditions checked before state promotion
- Monotonic: states only move up, never down
**ExitRules** (`exit_rules.py`) — 4-layer composite exit logic (v2)
- **Hard Stop**: `unrealized <= hard_stop_pct` (always enforced, ATR-adaptive for KR)
- **Break-Even Lock**: Once in BE_LOCK/ARMED, exit if price falls to entry price
- **ATR Trailing Stop**: `trailing_stop_price = peak_price - (atr_multiplier_k × ATR)`
- **Model Signal**: Exit if `pred_down_prob >= model_prob_threshold AND liquidity_weak`
- `evaluate_exit()`: Returns `ExitEvaluation` with next state, exit flag, reason, trailing price
- `ExitRuleConfig`: Frozen dataclass with all tunable parameters
**Pre-Market Planner** (`pre_market_planner.py`) — AI playbook generation
- Runs before market open (configurable `PRE_MARKET_MINUTES`, default 30)
@@ -195,7 +277,7 @@ High-frequency trading with individual stock analysis:
- Configurable host/port (`DASHBOARD_HOST`, `DASHBOARD_PORT`, default `127.0.0.1:8080`)
- Serves static HTML frontend
**8 API Endpoints:**
**10 API Endpoints:**
| Endpoint | Method | Description |
|----------|--------|-------------|
@@ -207,6 +289,8 @@ High-frequency trading with individual stock analysis:
| `/api/context/{layer}` | GET | Query context by layer (L1-L7) |
| `/api/decisions` | GET | Decision log entries with outcomes |
| `/api/scenarios/active` | GET | Today's matched scenarios |
| `/api/pnl/history` | GET | P&L history time series |
| `/api/positions` | GET | Current open positions |
### 8. Notifications (`src/notifications/telegram_client.py`)
@@ -448,8 +532,12 @@ CREATE TABLE trades (
pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX',
session_id TEXT DEFAULT 'UNKNOWN', -- v3: KRX_REG | NXT_AFTER | US_REG | US_PRE | ...
selection_context TEXT, -- JSON: {rsi, volume_ratio, signal, score}
decision_id TEXT -- Links to decision_logs
decision_id TEXT, -- Links to decision_logs
strategy_pnl REAL, -- v3: Core strategy P&L (separated from FX)
fx_pnl REAL DEFAULT 0.0, -- v3: FX gain/loss for USD trades (schema ready, activation pending)
mode TEXT -- paper | live
);
```
@@ -475,13 +563,14 @@ CREATE TABLE decision_logs (
stock_code TEXT,
market TEXT,
exchange_code TEXT,
session_id TEXT DEFAULT 'UNKNOWN', -- v3: session when decision was made
action TEXT,
confidence INTEGER,
rationale TEXT,
context_snapshot TEXT, -- JSON: full context at decision time
input_data TEXT, -- JSON: market data used
outcome_pnl REAL,
outcome_accuracy REAL,
outcome_accuracy INTEGER,
reviewed INTEGER DEFAULT 0,
review_notes TEXT
);
@@ -494,7 +583,7 @@ CREATE TABLE playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
market TEXT NOT NULL,
status TEXT DEFAULT 'generated',
status TEXT NOT NULL DEFAULT 'pending', -- pending → generated → active → expired
playbook_json TEXT NOT NULL, -- Full playbook with scenarios
generated_at TEXT NOT NULL,
token_count INTEGER,
@@ -552,6 +641,29 @@ PLANNER_TIMEOUT_SECONDS=60 # Timeout for playbook generation
DEFENSIVE_PLAYBOOK_ON_FAILURE=true # Fallback on AI failure
RESCAN_INTERVAL_SECONDS=300 # Scenario rescan interval during trading
# Optional — v2 Exit Rules (State Machine)
STAGED_EXIT_BE_ARM_PCT=1.2 # Break-even lock threshold (%)
STAGED_EXIT_ARM_PCT=3.0 # Armed state threshold (%)
KR_ATR_STOP_MULTIPLIER_K=2.0 # ATR multiplier for KR dynamic hard stop
KR_ATR_STOP_MIN_PCT=-2.0 # KR hard stop floor (must tighten, negative)
KR_ATR_STOP_MAX_PCT=-7.0 # KR hard stop ceiling (loosest, negative)
# Optional — v2 Trade Filters
STOP_LOSS_COOLDOWN_MINUTES=120 # Cooldown after stop-loss before re-entry (same ticker)
US_MIN_PRICE=5.0 # Minimum US stock price for BUY ($)
# Optional — v3 Session Risk Management
SESSION_RISK_RELOAD_ENABLED=true # Reload risk params at session boundaries
SESSION_RISK_PROFILES_JSON="{}" # Per-session overrides JSON: {"KRX_REG": {"be_arm_pct": 1.0}}
OVERNIGHT_EXCEPTION_ENABLED=true # Allow holding through session close (conditions apply)
# Optional — v3 Blackout (KIS maintenance windows)
ORDER_BLACKOUT_ENABLED=true
ORDER_BLACKOUT_WINDOWS_KST=23:30-00:10 # Comma-separated: "HH:MM-HH:MM"
ORDER_BLACKOUT_QUEUE_MAX=500 # Max queued orders during blackout
BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED=true
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT=5.0 # Drop recovery order if price drifted >5%
# Optional — Smart Scanner (realtime mode only)
RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold
RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold

View File

@@ -4,6 +4,23 @@
**Critical: Learn from failures. Never repeat the same failed command without modification.**
## Repository VCS Rule (Mandatory)
- 이 저장소의 티켓/PR/코멘트 작업은 Gitea 기준으로 수행한다.
- `gh`(GitHub CLI) 명령 사용은 금지한다.
- 기본 도구는 `tea`이며, `tea` 미지원 케이스만 Gitea API를 fallback으로 사용한다.
- 실행 전 `docs/workflow.md``Gitea CLI Formatting Troubleshooting`을 반드시 확인한다.
## Session Handover Preflight (Mandatory)
- 세션 시작 직후(코드 변경 전) 아래 명령을 먼저 실행한다.
```bash
python3 scripts/session_handover_check.py --strict
```
- 실패 시 `workflow/session-handover.md` 최신 엔트리를 보강한 뒤 재실행한다.
### tea CLI (Gitea Command Line Tool)
#### ❌ TTY Error - Interactive Confirmation Fails
@@ -119,9 +136,12 @@ No decorator needed for async tests.
# Install all dependencies (production + dev)
pip install -e ".[dev]"
# Run full test suite with coverage (551 tests across 25 files)
# Run full test suite with coverage
pytest -v --cov=src --cov-report=term-missing
# Validate docs sync rules (SSOT/duplication guard)
python3 scripts/validate_docs_sync.py
# Run a single test file
pytest tests/test_risk.py -v
@@ -140,6 +160,18 @@ python -m src.main --mode=paper
# Run with dashboard enabled
python -m src.main --mode=paper --dashboard
# Runtime verification monitor (coverage + forbidden invariants)
bash scripts/runtime_verify_monitor.sh
# Runtime monitor with explicit policy timezone (example: KST)
POLICY_TZ=Asia/Seoul bash scripts/runtime_verify_monitor.sh
# Session handover gate (must pass before implementation)
python3 scripts/session_handover_check.py --strict
# Follow runtime verification log
tail -f data/overnight/runtime_verify_*.log
# Docker
docker compose up -d ouroboros # Run agent
docker compose --profile test up test # Run tests in container
@@ -172,7 +204,9 @@ Dashboard runs as a daemon thread on `DASHBOARD_HOST:DASHBOARD_PORT` (default: `
| `GET /api/performance` | Performance metrics by market and combined |
| `GET /api/context/{layer}` | Context data by layer L1-L7 (query: `timeframe`) |
| `GET /api/decisions` | Decision log entries (query: `limit`, `market`) |
| `GET /api/pnl/history` | P&L history time series |
| `GET /api/scenarios/active` | Today's matched scenarios |
| `GET /api/positions` | Current open positions |
## Telegram Commands

View File

@@ -34,6 +34,12 @@ Main Agent 아이디에이션 책임:
- DCP-03 구현 착수: Phase 2 종료 전 Main Agent 승인 필수
- DCP-04 배포 승인: Phase 4 종료 후 Main Agent 최종 승인 필수
Main/Verifier 사고 재발 방지 규칙:
- Main Agent는 검증 위임 시 `Directive Contract`를 충족하지 않으면 검증 착수 금지
- Verifier Agent는 지시 누락/모호성 발견 시 즉시 `BLOCKED`를 선언하고 보완 요청
- Verifier Agent는 `미관측(NOT_OBSERVED)` 항목을 PASS로 보고할 수 없다
- Runtime 검증에서 요구 세션 증적이 없으면 "정상"이 아니라 `미검증 이상`으로 이슈화한다
## Phase Control Gates
### Phase 0: Scenario Intake and Scope Lock
@@ -112,7 +118,10 @@ Exit criteria:
Control checks:
- Verifier가 테스트 증적(로그/리포트/실행 커맨드) 첨부
- Verifier가 `Coverage Matrix`(`REQ/TASK/TEST` x `PASS/FAIL/NOT_OBSERVED`) 첨부
- `NOT_OBSERVED` 항목 수가 0인지 확인(0이 아니면 Gate 실패)
- Runtime Verifier가 스테이징/실운영 모니터링 계획 승인
- 정적 Verifier 승인 + Runtime Verifier 승인 2개 모두 확인
- 산출물: 수용 승인 레코드
### Phase 5: Release and Post-Release Control
@@ -150,6 +159,17 @@ TPM 티켓 운영 규칙:
- PR 본문에는 TPM이 지정한 우선순위와 범위가 그대로 반영되어야 한다.
- 우선순위 변경은 TPM 제안 + Main Agent 승인으로만 가능하다.
- PM/TPM/Dev/Reviewer/Verifier/Runtime Verifier는 주요 의사결정 시점마다 PR 코멘트를 남겨 결정 근거를 추적 가능 상태로 유지한다.
- PM/TPM/Dev/Reviewer/Verifier/Runtime Verifier는 이슈/PR/코멘트 조작 전에 `docs/commands.md``docs/workflow.md`의 Gitea 트러블슈팅 섹션을 선참조해야 한다.
- 저장소 협업에서 GitHub CLI(`gh`) 사용은 금지하며, Gitea 작업은 `tea`(필요 시 문서화된 API fallback)만 허용한다.
- 재발 방지/운영 규칙 변경이 합의되면, 기능 구현 이전에 process 티켓을 먼저 생성/머지해야 한다.
- process 티켓 미반영 상태에서 구현 티켓 진행 시 TPM이 즉시 `BLOCKED` 처리한다.
티켓 성숙도 단계 (Mandatory):
- `Implemented`: 코드/문서 변경 완료
- `Integrated`: 호출 경로/파이프라인 연결 확인
- `Observed`: 런타임/실행 증적 확보
- `Accepted`: Verifier + Runtime Verifier 승인 완료
- 단계는 순차 전진만 허용되며, 단계 점프는 허용되지 않는다.
브랜치 운영 규칙:
- TPM은 각 티켓에 대해 `ticket temp branch -> program feature branch` PR 경로를 지정한다.
@@ -168,6 +188,8 @@ TPM 티켓 운영 규칙:
- 시스템 실제 구동(스테이징/로컬 실운영 모드) 실행
- 모니터링 체크리스트(핵심 경보/주문 경로/예외 로그) 수행
- 결과를 티켓/PR 코멘트에 증적으로 첨부하지 않으면 완료로 간주하지 않음
- 세션별 필수 관측 포인트(`NXT`, `US_PRE`, `US_DAY`, `US_AFTER` 등) 중 미관측 항목은 `NOT_OBSERVED`로 기록
- `NOT_OBSERVED` 존재 시 승인 금지 + Runtime 이슈 발행
## Server Reflection Rule

View File

@@ -3,7 +3,7 @@ Doc-ID: DOC-OPS-002
Version: 1.0.0
Status: active
Owner: tpm
Updated: 2026-02-26
Updated: 2026-02-27
-->
# 저장소 강제 설정 체크리스트
@@ -24,11 +24,17 @@ Updated: 2026-02-26
## 2) 필수 상태 체크 (필수)
필수 CI 항목:
- `validate_ouroboros_docs` (명령: `python3 scripts/validate_ouroboros_docs.py`)
- `test` (명령: `pytest -q`)
| 참조 기준 | 이름 | 설명 |
|-----------|------|------|
| **job 단위** (브랜치 보호 설정 시 사용) | `test` | 전체 CI job (문서 검증 + 테스트 포함) |
| **step 단위** (로그 확인 시 참조) | `validate_ouroboros_docs` | `python3 scripts/validate_ouroboros_docs.py` 실행 step |
| **step 단위** | `run_tests` | `pytest -q` 실행 step |
> **주의**: Gitea 브랜치 보호의 Required Status Checks는 **job 이름** 기준으로 설정한다 (`test`). step 이름은 UI 로그 탐색용이며 보호 규칙에 직접 입력하지 않는다.
설정 기준:
- 위 2개 체크가 `success` 아니면 머지 금지
- `test` job이 `success` 아니면 머지 금지
- 체크 스킵/중립 상태 허용 금지
## 3) 필수 리뷰어 규칙 (권장 -> 필수)
@@ -48,6 +54,8 @@ Updated: 2026-02-26
병합 전 체크리스트:
- 이슈 연결(`Closes #N`) 존재
- PR 본문에 `REQ-*`, `TASK-*`, `TEST-*` 매핑 표 존재
- Main -> Verifier Directive Contract(범위/방법/합격/실패/미관측/증적 형식) 기재
- process-change-first 대상이면 process 티켓 PR이 선머지됨
- `src/core/risk_manager.py` 변경 없음
- 주요 의사결정 체크포인트(DCP-01~04) 중 해당 단계 Main Agent 확인 기록 존재
- 주요 의사결정(리뷰 지적/수정 합의/검증 승인)에 대한 에이전트 PR 코멘트 존재
@@ -56,7 +64,14 @@ Updated: 2026-02-26
자동 점검:
- 문서 검증 스크립트 통과
- 테스트 통과
- `python3 scripts/session_handover_check.py --strict` 통과
- 개발 완료 시 시스템 구동/모니터링 증적 코멘트 존재
- 이슈/PR 조작 전에 `docs/commands.md``docs/workflow.md` 트러블슈팅 확인 코멘트 존재
- `gh` CLI 미사용, `tea` 사용 증적 존재
- Verifier `Coverage Matrix` 첨부(PASS/FAIL/NOT_OBSERVED)
- `NOT_OBSERVED` 항목 0 확인(0이 아니면 머지 금지)
- 티켓 단계 기록(`Implemented` -> `Integrated` -> `Observed` -> `Accepted`) 존재
- 정적 Verifier 승인 + Runtime Verifier 승인 2개 확인
## 5) 감사 추적

View File

@@ -0,0 +1,399 @@
<!--
Doc-ID: DOC-AUDIT-001
Version: 1.1.0
Status: active
Owner: strategy
Updated: 2026-03-01
-->
# v2/v3 구현 감사 및 수익률 분석 보고서
작성일: 2026-02-28
최종 업데이트: 2026-03-01 (Phase 2 완료 + Phase 3 부분 완료 반영)
대상 기간: 2026-02-25 ~ 2026-02-28 (실거래)
분석 브랜치: `feature/v3-session-policy-stream`
---
## 1. 계획 대비 구현 감사
### 1.1 v2 구현 상태: 100% 완료
| REQ-ID | 요구사항 | 구현 파일 | 상태 |
|--------|----------|-----------|------|
| REQ-V2-001 | 4-상태 매도 상태기계 (HOLDING→BE_LOCK→ARMED→EXITED) | `src/strategy/position_state_machine.py` | ✅ 완료 |
| REQ-V2-002 | 즉시 최상위 상태 승격 (갭 대응) | `position_state_machine.py:51-70` | ✅ 완료 |
| REQ-V2-003 | EXITED 우선 평가 | `position_state_machine.py:38-48` | ✅ 완료 |
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model) | `src/strategy/exit_rules.py` | ✅ 완료 |
| REQ-V2-005 | Triple Barrier 라벨링 | `src/analysis/triple_barrier.py` | ✅ 완료 |
| REQ-V2-006 | Walk-Forward + Purge/Embargo 검증 | `src/analysis/walk_forward_split.py` | ✅ 완료 |
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py` | ✅ 완료 |
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh→Reduce→Snapshot) | `src/core/kill_switch.py` | ✅ 완료 |
### 1.2 v3 구현 상태: ~85% 완료 (2026-03-01 기준)
| REQ-ID | 요구사항 | 상태 | 비고 |
|--------|----------|------|------|
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ✅ 완료 | #326 머지 — `log_decision()` 파라미터 추가, `log_trade()` 명시적 전달 |
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | #327 머지 — 재로딩 메커니즘 구현, 세션 훅 테스트 미작성 |
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ✅ 완료 | #324(DB 기록) + #328(가격/세션 재검증) 머지 |
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 코드 완료 / 운영 미반영 | `src/db.py` 스키마·함수 완료, 운영 데이터 `fx_pnl` 전부 0 |
| REQ-V3-008 | 오버나잇 예외 vs Kill Switch 우선순위 | ✅ 완료 | `src/main.py``_should_force_exit_for_overnight()`, `_apply_staged_exit_override_for_hold()` |
### 1.3 운영 거버넌스: ~60% 완료 (2026-03-01 재평가)
| REQ-ID | 요구사항 | 상태 | 비고 |
|--------|----------|------|------|
| REQ-OPS-001 | 타임존 명시 (KST/UTC) | ⚠️ 부분 | DB 기록은 UTC, 세션은 KST. 일부 로그에서 타임존 미표기 |
| REQ-OPS-002 | 정책 변경 시 레지스트리 업데이트 강제 | ⚠️ 기본 구현 완료 | `scripts/validate_governance_assets.py` CI 연동 완료; 규칙 고도화 잔여 |
| REQ-OPS-003 | TASK-REQ 매핑 강제 | ⚠️ 기본 구현 완료 | `scripts/validate_ouroboros_docs.py` CI 연동 완료; PR 강제 검증 강화 잔여 |
---
## 2. 구현 갭 상세
> **2026-03-01 업데이트**: GAP-1~5 모두 해소되었거나 이슈 머지로 부분 해소됨.
### GAP-1: DecisionLogger에 session_id 미포함 → ✅ 해소 (#326)
- **위치**: `src/logging/decision_logger.py`
- ~~문제: `log_decision()` 함수에 `session_id` 파라미터가 없음~~
- **해소**: #326 머지 — `log_decision()` 파라미터에 `session_id` 추가, DB 기록 포함
- **요구사항**: REQ-V3-001
### GAP-2: src/main.py 거래 로그에 session_id 미전달 → ✅ 해소 (#326)
- **위치**: `src/main.py`
- ~~문제: `log_trade()` 호출 시 `session_id` 파라미터를 전달하지 않음~~
- **해소**: #326 머지 — `log_trade()` 호출 시 런타임 `session_id` 명시적 전달
- **요구사항**: REQ-V3-001
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 → ⚠️ 부분 해소 (#327)
- **위치**: `src/main.py`, `src/config.py`
- **해소 내용**: #327 머지 — `SESSION_RISK_PROFILES_JSON` 기반 세션별 파라미터 재로딩 메커니즘 구현
- `SESSION_RISK_RELOAD_ENABLED=true` 시 세션 경계에서 파라미터 재로딩
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
- **잔여 갭**: 세션 경계 실시간 전환 E2E 통합 테스트 보강 필요 (`test_main.py`에 설정 오버라이드/폴백 단위 테스트는 존재)
- **요구사항**: REQ-V3-002
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → ✅ 해소 (#324, #328)
- **위치**: `src/core/blackout_manager.py`, `src/main.py`
- **해소 내용**:
- #324 머지 — 복구 주문 실행 후 `log_trade()` 호출, rationale에 `[blackout-recovery]` prefix
- #328 머지 — 가격 유효성 검증 (진입가 대비 급변 시 드롭), 세션 변경 시 새 파라미터로 재검증
- **요구사항**: REQ-V3-004
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
- **위치**: `src/analysis/triple_barrier.py`
- ~~문제: `max_holding_bars` (고정 봉 수) 사용~~
- **해소**: #329 머지 — `max_holding_minutes` (캘린더 분) 기반 시간장벽 전환
- 봉 주기 무관하게 일정 시간 경과 시 장벽 도달
- `max_holding_bars` deprecated 경고 유지 (하위 호환)
- **요구사항**: REQ-V2-005 / v3 확장
### GAP-6 (신규): FX PnL 운영 미활성 (LOW — 코드 완료)
- **위치**: `src/db.py` (`fx_pnl`, `strategy_pnl` 컬럼 존재)
- **문제**: 스키마와 함수는 완료되었으나 운영 데이터에서 `fx_pnl` 전부 0
- **영향**: USD 거래에서 환율 손익과 전략 손익이 분리되지 않아 성과 분석 부정확
- **요구사항**: REQ-V3-007
---
## 3. 실거래 수익률 분석
### 3.1 종합 성적
| 지표 | 값 |
|------|-----|
| 총 실현 손익 | **-52,481** (KRW + USD 혼합, 통화 분리 집계는 3.4 참조) |
| 총 거래 기록 | 19,130건 (BUY 121, SELL 46, HOLD 18,963) |
| 집계 기준 | UTC `2026-02-25T00:00:00` ~ `2026-02-28T00:00:00`, SELL 45건 (기간 외 1건 제외) |
| 승률 | **39.1%** (18승 / 46매도, 0손익 포함 기준) |
| 평균 수익 거래 | +6,107 |
| 평균 손실 거래 | -7,382 |
| 최대 수익 거래 | +46,350 KRW (452260 KR) |
| 최대 손실 거래 | -26,400 KRW (000370 KR) |
| 운영 모드 | LIVE (실계좌) |
### 3.2 일별 손익
| 날짜 | 매도 수 | 승 | 패 | 일간 손익 |
|------|---------|----|----|-----------|
| 02-25 | 9 | 8 | 1 | +63.21 (USD, 미세 수익) |
| 02-26 | 14 | 5 | 5 | **-32,083.40** (KR 대량 손실) |
| 02-27 | 22 | 5 | 16 | **-20,461.11** (고빈도 매매, 대부분 손실) |
> 정확한 재현: `scripts/audit_queries.sql` 참조.
### 3.3 시장별 손익
| 시장 | 매도 수 | 승률 | 총 손익 |
|------|---------|------|---------|
| **KR** | 17 | 38.5% (0손익 제외, 5/13) | **-56,735 KRW** |
| US_AMEX | 12 | 75% | +4,476 USD |
| US_NASDAQ | 4 | 0% | -177 USD |
| US_NYSE | 13 | 30.8% | -45 USD |
**KR 시장이 손실의 주 원인.** US는 AMEX 제외 시 대체로 손실 또는 보합.
### 3.4 재계산 주석 반영 (통화 분리)
> 산식 주석: 기존 표의 `총 실현 손익 -52,481`은 KRW/USD를 단순 합산한 값으로, 회계적으로 해석 불가.
> 아래는 같은 기간(2026-02-25~2026-02-27, SELL 45건)을 통화별로 분리한 결과.
| 통화 | 매도 수 | 승/패 | 실현 손익 |
|------|---------|-------|-----------|
| KRW | 17 | 5승 / 8패 (4건 0손익) | **-56,735 KRW** |
| USD | 28 | 13승 / 14패 (1건 0손익) | **+4,253.70 USD** |
### 3.5 재계산 주석 반영 (기존 보유 청산 성과 분리)
> 분리 기준: 각 SELL의 직전 BUY가 `rationale LIKE '[startup-sync]%'` 인 경우를
> `기존 보유(시작 시점 동기화 포지션) 청산`으로 분류.
| 구분 | 통화 | 매도 수 | 손익 |
|------|------|---------|------|
| 기존 보유 청산분 | KRW | 10 | **+12,230 KRW** |
| 기존 보유 청산분 | USD | 2 | **+21.03 USD** |
| 신규/전략 진입분만 | KRW | 7 | **-68,965 KRW** |
| 신규/전략 진입분만 | USD | 26 | **+4,232.67 USD** |
추가로, 요청 취지(“기존 보유 수익 종목 정리 수익 제외”)에 맞춰 **기존 보유 청산 중 수익(+PnL)만 제외**하면:
- KRW: `-56,735`**-113,885 KRW** (기존 보유 수익 +57,150 KRW 제거)
- USD: `+4,253.70`**+4,232.67 USD** (기존 보유 수익 +21.03 USD 제거)
즉, 기존 성과표는 기보유 청산 이익(특히 KR 452260 +46,350 KRW)을 전략 성과에 포함해
전략 자체 손익을 과대평가한 상태다.
### 3.6 데이터 무결성 점검 (모의투자 혼합 여부 + USD 과대수익 원인)
- `mode` 점검 결과: `live` 19,130건, `paper` 0건
**모의투자 혼합은 확인되지 않음**.
- 다만 USD 손익에는 **체결 매칭 이상치 1건**이 존재:
- `CRCA` SELL(15주, $35.14, +4,612.15 USD) vs 직전 BUY(146주, $3.5499)
- BUY/SELL 수량 불일치(146→15) 상태에서 PnL이 계산되어, 역분할/동기화 이슈 가능성이 큼.
보수적 재집계(2026-02-25~2026-02-27, USD SELL 28건):
| 집계 기준 | USD 손익 | 환산 KRW (참고) | KRW 합산 참고값 |
|-----------|----------|-----------------|-----------------|
| 원집계 | **+4,253.70 USD** | +6,167,865 | -56,735 + 6,167,865 = **+6,111,130** |
| 기존보유(startup-sync) 제외 | **+4,232.67 USD** | +6,137,372 | -68,965 + 6,137,372 = **+6,068,407** |
| 수량 일치 체결만 포함 | **-358.45 USD** | -519,753 | -56,735 + (-519,753) = **-576,488** |
| 기존보유 제외 + 수량 일치 체결만 포함 | **-379.48 USD** | -550,246 | -68,965 + (-550,246) = **-619,211** |
> 가정 환율: **1 USD = 1,450 KRW** (2026-02-28 기준 참고 환율).
> 환산 KRW 및 합산값은 비교용 보조지표이며, 회계/정산 기준값과는 분리해 해석해야 한다.
결론적으로 USD 구간의 플러스 성과는 실질적으로 `CRCA` 이상치 1건 영향이 지배적이며,
해당 거래를 무결성 필터로 제외하면 USD 성과는 손실 구간으로 전환된다.
### 3.7 데이터 품질 이슈 요약
- **startup-sync 중복**: BUY 76건 반복 동기화, price=0 38건 → PnL 매칭 왜곡 가능. 분리 집계는 3.5 참조.
- **티커-거래소 드리프트**: 동일 티커가 다중 거래소에 혼재 기록 → ROOT-7 참조.
- **FX PnL 미활성**: 스키마 존재, 운영 데이터 전부 0 → REQ-V3-007 참조.
### 3.8 표준 집계 SQL (재현용)
성과표 재현을 위한 기준 쿼리는 [`scripts/audit_queries.sql`](../../scripts/audit_queries.sql)에 분리되어 있다.
- **Base**: 기간 + LIVE + SELL + 직전 BUY 메타 매칭
- **Q1**: 통화 분리 손익 (KRW/USD 혼합 금지)
- **Q2**: 기존 보유(startup-sync) 제외 성과
- **Q3**: 수량 일치 체결만 포함 (무결성 필터)
- **Q4**: 이상치 목록 (수량 불일치)
---
## 4. 수익률 저조 근본 원인 분석
### ROOT-1: hard_stop_pct 기본값(-2%)이 KR 소형주 변동성 대비 과소
- **현재 설정**: `stop_loss_threshold = -2.0` (`src/main.py:511`), staged exit의 `hard_stop_pct`로 전달
- **v2 계획**: ATR 기반 동적 trailing stop (ExitPrice = PeakPrice - k × ATR)
- **실제 동작**: staged exit는 호출되나, `atr_value`/`pred_down_prob` 등 피처가 0.0으로 공급되어 hard_stop 편향 발동 (ROOT-5 참조)
- **증거**:
- 000370: 매수 8,040 → 24분 후 -2.74% 손절
- 033340: 매수 2,080 → 18분 후 -3.13% 손절
- 229000: -3.7%, -3.25%, -3.2% 반복 손절
### ROOT-2: 동일 종목 반복 매매 (재진입 쿨다운 미구현)
- **문제**: 손절 후 동일 종목 즉시 재매수 → 고가 재진입 → 재손절 반복
- **최악 사례**: 종목 229000
| 매수가 | 매도가 | 손익 | 보유 시간 |
|--------|--------|------|-----------|
| 5,670 | 5,460 | -24,780 | 0.5h |
| 5,540 | 5,360 | -21,780 | 0.7h |
| 5,310 | 5,580 | +34,020 (승) | 0.8h |
| 5,620 | 5,440 | -21,420 | 1.5h |
- **순손실**: 하루 한 종목에서 **-33,960 KRW**
### ROOT-3: 미국 페니스탁/마이크로캡 무분별 진입
- **문제**: $2 이하 종목에 confidence 85~90으로 진입, 오버나잇 대폭락
- **사례**:
| 종목 | 손실률 | 보유시간 |
|------|--------|----------|
| ALBT | -27.7% | ~23h |
| SMJF | -15.9% | ~23h |
| KAPA | -18.2% | ~23h |
| CURX | -10.6% | ~23h |
| CELT | -8.3% | ~23h |
### ROOT-4: 진화 전략 코드 생성기 문법 오류
- **위치**: `src/strategies/v20260227_*_evolved.py`
- **문제**: 중첩 `def evaluate` 정의 (들여쓰기 오류)
- **영향**: 런타임 실패 → 기본 전략으로 폴백 → 진화 시스템 사실상 무효
### ROOT-5: v2 청산 로직이 부분 통합되었으나 실효성 부족 → ⚠️ 부분 해소 (#325)
**초기 진단 (2026-02-28 감사 기준):**
- `hard_stop_pct`에 고정 `-2.0`이 기본값으로 들어가 v2 계획의 ATR 적응형 의도와 괴리
- `be_arm_pct`/`arm_pct`가 playbook의 `take_profit_pct`에서 기계적 파생(`* 0.4`)되어 v2 계획의 독립 파라미터 튜닝 불가
- `atr_value`, `pred_down_prob` 등 런타임 피처가 0.0으로 공급되어 사실상 hard stop만 발동
**현재 상태 (#325 머지 후):**
- `STAGED_EXIT_BE_ARM_PCT`, `STAGED_EXIT_ARM_PCT` 환경변수로 독립 파라미터 설정 가능
- `_inject_staged_exit_features()`: KR 시장 ATR 실시간 계산 주입, RSI 기반 `pred_down_prob` 공급
- KR ATR dynamic hard stop (#318)으로 `-2.0` 고정값 문제 해소
**잔여 리스크:**
- KR 외 시장(US 등)에서 `atr_value` 공급 경로 불완전 — hard stop 편향 잔존 가능
- `pred_down_prob`가 RSI 프록시 수준 — 추후 실제 ML 모델 대체 권장
### ROOT-6: SELL 손익 계산이 부분청산/수량 불일치에 취약 (CRITICAL) → ✅ 해소 (#322)
> **현재 상태**: #322 머지로 해소됨. 아래는 원인 발견 시점(2026-02-28) 진단 기록.
- **위치**: `src/main.py:1658-1663`, `src/main.py:2755-2760`
- **문제**: PnL 계산이 실제 매도 수량(`sell_qty`)이 아닌 직전 BUY의 `buy_qty`를 사용
- `trade_pnl = (trade_price - buy_price) * buy_qty`
- **영향**: 부분청산, 역분할/액분할, startup-sync 후 수량 드리프트 시 손익 과대/과소 계상
- **실증**: CRCA 이상치(BUY 146주 → SELL 15주에서 PnL +4,612 USD) 가 이 버그와 정합
### ROOT-7: BUY 매칭 키에 exchange_code 미포함 — 잠재 오매칭 리스크 (HIGH) → ✅ 해소 (#323)
> **현재 상태**: #323 머지로 해소됨. 아래는 원인 발견 시점(2026-02-28) 진단 기록.
- **위치**: `src/db.py:292-313`
- **문제**: `get_latest_buy_trade()``(stock_code, market)`만으로 매칭, `exchange_code` 미사용
- **성격**: 현재 즉시 발생하는 확정 버그가 아닌, 동일 티커가 다중 거래소에 혼재 기록될 때 증폭되는 구조 리스크
- **영향**: 데이터 드리프트 조건(예: CCUP/CRCA 등 다중 exchange 기록)에서 오매칭 → 손익 왜곡 가능
---
## 5. 수익률 개선 방안
### 5.1 즉시 적용 가능 (파라미터/로직 수정)
| 우선순위 | 방안 | 예상 효과 | 난이도 |
|----------|------|-----------|--------|
| P0 | KR 손절선 확대: -2% → -4~5% 또는 ATR 기반 | 노이즈 손절 대폭 감소 | 낮음 |
| P0 | 재진입 쿨다운: 손절 후 동일 종목 1~2시간 매수 차단 | churn & burn 패턴 제거 | 낮음 |
| P1 | US 최소 가격 필터: $5 이하 종목 진입 차단 | 페니스탁 대폭락 방지 | 낮음 |
| P1 | 진화 전략 코드 생성 시 syntax 검증 추가 | 진화 시스템 정상화 | 낮음 |
### 5.2 구조적 개선 현황 (2026-03-01 기준)
**완료 항목 (모니터링 단계):**
| 항목 | 이슈 | 상태 |
|------|------|------|
| SELL PnL 계산을 sell_qty 기준으로 수정 (ROOT-6) | #322 | ✅ 머지 |
| v2 staged exit 피처 공급 + 독립 파라미터 설정 (ROOT-5) | #325 | ✅ 머지 |
| BUY 매칭 키에 exchange_code 추가 (ROOT-7) | #323 | ✅ 머지 |
| 블랙아웃 복구 주문 `log_trade()` 추가 (GAP-4) | #324 | ✅ 머지 |
| 세션 전환 리스크 파라미터 동적 재로딩 (GAP-3) | #327 | ✅ 머지 |
| session_id 거래/의사결정 로그 명시 전달 (GAP-1, GAP-2) | #326 | ✅ 머지 |
| 블랙아웃 복구 가격/세션 재검증 강화 (GAP-4 잔여) | #328 | ✅ 머지 |
**잔여 개선 항목:**
| 우선순위 | 방안 | 난이도 |
|----------|------|--------|
| P1 | US 시장 ATR 공급 경로 완성 (ROOT-5 잔여) | 중간 |
| P1 | FX PnL 운영 활성화 (REQ-V3-007) | 낮음 |
| P2 | pred_down_prob ML 모델 대체 (ROOT-5 잔여) | 높음 |
| P2 | 세션 경계 E2E 통합 테스트 보강 (GAP-3 잔여) | 낮음 |
### 5.3 권장 실행 순서
```
Phase 1 (즉시): 파라미터 조정
→ KR 손절 확대 + 재진입 쿨다운 + US 가격 필터
→ 예상: 가장 큰 손실 패턴 2개(노이즈 손절, 반복 매매) 즉시 제거
Phase 2 (단기): 데이터 정합성 + v2 실효화
→ SELL PnL을 sell_qty 기준으로 수정
→ BUY 매칭 키에 exchange_code 추가
→ 블랙아웃 복구 주문 DB 기록 추가
→ v2 staged exit에 실제 피처(ATR, pred_down_prob) 공급 + 독립 파라미터 설정
→ session_id 명시적 전달
→ 예상: 손익 정확도 확보 + 수익 구간 보호 메커니즘 실효화
Phase 3 (중기): v3 세션 최적화
→ 세션 전환 훅 + 파라미터 재로딩
→ 블랙아웃 재검증
→ 운영 거버넌스 CI 자동화
```
---
## 6. 테스트 커버리지 현황
### 테스트 존재 (통과)
- ✅ 상태기계 승격 (`test_strategy_state_machine.py`)
- ✅ 4중 청산 규칙 (`test_strategy_exit_rules.py`)
- ✅ Triple Barrier 라벨링 (`test_triple_barrier.py`)
- ✅ Walk-Forward + Purge/Embargo (`test_walk_forward_split.py`)
- ✅ 백테스트 비용 검증 (`test_backtest_cost_guard.py`)
- ✅ Kill Switch 순서 (`test_kill_switch.py`)
- ✅ 블랙아웃 관리 (`test_blackout_manager.py`)
- ✅ 주문 정책 저유동 거부 (`test_order_policy.py`)
- ✅ FX 손익 분리 (`test_db.py`)
- ✅ 블랙아웃 복구 후 유효 intent 실행 (`tests/test_main.py:5811`)
- ✅ 블랙아웃 복구 후 정책 거부 intent 드롭 (`tests/test_main.py:5851`)
### 테스트 추가됨 (Phase 1~3, 2026-03-01)
- ✅ KR ATR 기반 동적 hard stop (`test_main.py`#318)
- ✅ 재진입 쿨다운 (손절 후 동일 종목 매수 차단) (`test_main.py`#319)
- ✅ US 최소 가격 필터 ($5 이하 차단) (`test_main.py`#320)
- ✅ 진화 전략 syntax 검증 (`test_evolution.py`#321)
- ✅ SELL PnL sell_qty 기준 계산 (`test_main.py`#322)
- ✅ BUY 매칭 키 exchange_code 포함 (`test_db.py`#323)
- ✅ 블랙아웃 복구 주문 DB 기록 (`test_main.py`#324)
- ✅ staged exit에 실제 ATR/RSI 피처 공급 (`test_main.py`#325)
- ✅ session_id 거래/의사결정 로그 명시적 전달 (`test_main.py`, `test_decision_logger.py`#326)
- ✅ 블랙아웃 복구 후 유효 intent 실행 (`tests/test_main.py:5811`)
- ✅ 블랙아웃 복구 후 정책 거부 intent 드롭 (`tests/test_main.py:5851`)
### 테스트 미존재 (잔여)
- ❌ 세션 전환 훅 콜백 (GAP-3 잔여)
- ❌ 세션 경계 리스크 파라미터 재로딩 단위 테스트 (GAP-3 잔여)
- ❌ 실거래 경로 ↔ v2 상태기계 통합 테스트 (피처 공급 포함)
- ❌ FX PnL 운영 활성화 검증 (GAP-6)
---
## 7. 후속 문서
- **실행 계획**: [85_loss_recovery_action_plan.md](./85_loss_recovery_action_plan.md) — ROOT/GAP 해소를 위한 Phase별 작업 분해 및 Gitea 이슈 연결
- **표준 집계 SQL**: [scripts/audit_queries.sql](../../scripts/audit_queries.sql)
---
*끝.*

View File

@@ -0,0 +1,75 @@
<!--
Doc-ID: DOC-PLAN-082
Version: 1.1.0
Status: active
Owner: strategy
Updated: 2026-03-01
-->
# 문서 재구조화 실행 현황
## 목적
문서 중복/드리프트/탐색 난이도를 줄여, 에이전트가 문서 기반으로 기획/설계/구현/검증을 일관되게 수행하도록 운영 규칙을 고정한다.
## 적용 범위
- 루트 요약 문서: `README.md`, `CLAUDE.md`
- 운영 문서: `docs/architecture.md`, `docs/commands.md`, `docs/testing.md`, `docs/workflow.md`
- 실행 통제 문서군: `docs/ouroboros/*`
- 검증 자동화: `scripts/validate_docs_sync.py`, CI 워크플로우
## 완료 항목 (2026-03-01)
### 1) 문서 라우팅/역할 고정
- `docs/README.md` 신설
- 읽기 순서 + SSOT + 작성 규칙 명시
### 2) 요약 문서 중복 축소
- `README.md`, `CLAUDE.md`에 문서 진입점 추가
- 가변 수치/세부 동작의 직접 중복 기재를 축약하고 SSOT 문서 링크 중심으로 정리
### 3) 명령/테스트 문서 정합성 개선
- `docs/commands.md` 대시보드 API 목록 최신화(`pnl/history`, `positions` 포함)
- `docs/testing.md` 테스트 총량 확인 방식을 고정 수치 -> `pytest --collect-only -q`로 전환
### 4) 동기화 자동 검증 + CI 게이트
- `scripts/validate_docs_sync.py` 추가
- `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml``Validate docs sync` 단계 추가
- `docs/commands.md`에 동기화 검증 명령 추가
## 잔여 작업
### A) SSOT 강제 범위 확장
- `README.md`/`CLAUDE.md`의 남은 동작 설명을 더 축약하고 상세는 `docs/architecture.md`로 일원화
- `docs/testing.md`의 파일별 테스트 개수 스냅샷 자동 생성 여부 결정
### B) 검증 규칙 고도화
- `validate_docs_sync.py`에 추가 패턴(중복 정책 문구/금지 숫자 표현) 확대
- 필요 시 `docs/architecture.md`를 API/모드 동작의 유일한 근거 문서로 명시
### C) 유지보수 운영화
- 릴리즈/스프린트 종료 시 문서 동기화 점검 체크리스트 정례화
- 문서 변경 PR 템플릿에 "SSOT 링크 업데이트 여부" 체크박스 추가 검토
## 검증 상태
- `python3 scripts/validate_docs_sync.py`: PASS
- `python3 scripts/validate_ouroboros_docs.py`: PASS
- `python3 scripts/validate_governance_assets.py`: PASS
## 운영 규칙
- 문서 구조 변경 시 `docs/README.md`와 동기화 검증 규칙을 함께 갱신한다.
- 요약 문서는 "개요/진입점" 역할만 유지하고, 상세 사실/수치 정책은 SSOT 문서에만 기록한다.
- 가변 수치(테스트 개수, 엔드포인트 개수)는 자동 확인 명령 또는 SSOT 링크로 대체한다.
## 참조
- 문서 허브: [docs/README.md](../../docs/README.md)
- 실행 문서 허브: [docs/ouroboros/README.md](./README.md)
- 동기화 검증 스크립트: [`scripts/validate_docs_sync.py`](../../scripts/validate_docs_sync.py)

View File

@@ -0,0 +1,417 @@
<!--
Doc-ID: DOC-ACTION-085
Version: 1.1.0
Status: active
Owner: strategy
Updated: 2026-03-01
-->
# 손실 복구 실행 계획
작성일: 2026-02-28
최종 업데이트: 2026-03-01 (Phase 1~3 완료 상태 반영)
기반 문서: [80_implementation_audit.md](./80_implementation_audit.md) (ROOT 7개 + GAP 5개)
> **2026-03-01 현황**: Phase 1 ✅ 완료, Phase 2 ✅ 완료, Phase 3 ✅ 기본 완료 (ACT-13 고도화 잔여)
---
## 1. 요약
### 1.1 목표
80_implementation_audit.md에서 식별된 7개 근본 원인(ROOT-1~7)과 5개 구현 갭(GAP-1~5)을 해소하여 실거래 손실 구간에서 탈출한다.
### 1.2 성공 기준 (정량)
| 지표 | 현재 | 목표 |
|------|------|------|
| KR 시장 승률 | 38.5% | >= 50% |
| 동일 종목 반복 매매 (일간) | 최대 4회 | <= 2회 |
| US 페니스탁($5 이하) 진입 | 무제한 | 0건 |
| SELL PnL 수량 불일치 건 | 존재 | 0건 |
| 블랙아웃 복구 주문 DB 누락 | 존재 | 0건 |
| session_id 누락 거래 로그 | 다수 | 0건 |
| 진화 전략 syntax 오류율 | 100% (확인된 3건 모두) | 0% |
---
## 2. Phase별 작업 분해
### Phase 1: 즉시 — 손실 출혈 차단 ✅ 완료
가장 큰 손실 패턴(노이즈 손절, 반복 매매, 페니스탁)을 즉시 제거한다.
---
#### ACT-01: KR 손절선 ATR 기반 동적 확대 ✅ 머지
- **ROOT 참조**: ROOT-1 (hard_stop_pct -2%가 KR 소형주 변동성 대비 과소)
- **Gitea 이슈**: feat: KR 손절선 ATR 기반 동적 확대 (-2% → ATR 적응형)
- **Gitea 이슈 번호**: #318
- **변경 대상 파일**: `src/main.py`, `src/strategy/exit_rules.py`, `src/config.py`
- **현재 동작**: `hard_stop_pct = -2.0` 고정값으로 모든 시장에 동일 적용
- **목표 동작**: KR 시장은 ATR(14) 기반 동적 손절선 적용. 최소 -2%, 최대 -7%, 기본값은 `k * ATR / entry_price * 100` (k=2.0)
- **수용 기준**:
- ATR 값이 존재할 때 동적 손절선이 계산됨
- ATR 미제공 시 기존 -2% 폴백
- KR 이외 시장은 기존 동작 유지
- **테스트 계획**:
- 단위: ATR 기반 손절선 계산 로직 테스트 (경계값: ATR=0, ATR=극단값)
- 통합: 백테스트 파이프라인에서 KR 종목 손절 빈도 비교
- **의존성**: 없음
---
#### ACT-02: 손절 후 동일 종목 재진입 쿨다운 ✅ 머지
- **ROOT 참조**: ROOT-2 (동일 종목 반복 매매)
- **Gitea 이슈**: feat: 손절 후 동일 종목 재진입 쿨다운 (1~2시간)
- **Gitea 이슈 번호**: #319
- **변경 대상 파일**: `src/main.py`, `src/config.py`
- **현재 동작**: 손절 후 동일 종목 즉시 재매수 가능
- **목표 동작**: 손절(SELL with pnl < 0) 후 동일 종목은 `COOLDOWN_MINUTES` (기본 120분) 동안 매수 차단
- **수용 기준**:
- 손절 기록이 있는 종목에 대해 쿨다운 시간 내 BUY 시도 시 거부
- 쿨다운 경과 후 정상 진입 허용
- 익절(pnl >= 0)에는 쿨다운 미적용
- **테스트 계획**:
- 단위: 쿨다운 시간 내/외 매수 시도 테스트
- 통합: 229000 유사 패턴 백테스트 시나리오
- **의존성**: 없음
---
#### ACT-03: US $5 이하 종목 진입 차단 필터 ✅ 머지
- **ROOT 참조**: ROOT-3 (미국 페니스탁 무분별 진입)
- **Gitea 이슈**: feat: US $5 이하 종목 진입 차단 필터
- **Gitea 이슈 번호**: #320
- **변경 대상 파일**: `src/main.py`, `src/config.py`
- **현재 동작**: 가격 제한 없이 모든 US 종목 진입 가능
- **목표 동작**: US 시장 BUY 시 현재가 $5 이하이면 진입 차단. 임계값은 `US_MIN_PRICE` 환경변수로 설정 가능
- **수용 기준**:
- $5 이하 종목 BUY 시도 시 거부 + 로그 기록
- $5 초과 종목은 기존 동작 유지
- KR 등 다른 시장에는 미적용
- **테스트 계획**:
- 단위: 가격별 필터 동작 테스트 (경계값: $4.99, $5.00, $5.01)
- **의존성**: 없음
---
#### ACT-04: 진화 전략 코드 생성 시 syntax 검증 추가 ✅ 머지
- **ROOT 참조**: ROOT-4 (진화 전략 문법 오류)
- **Gitea 이슈**: fix: 진화 전략 코드 생성 시 syntax 검증 추가
- **Gitea 이슈 번호**: #321
- **변경 대상 파일**: `src/evolution/optimizer.py`
- **현재 동작**: 생성된 Python 코드를 검증 없이 파일로 저장
- **목표 동작**: `ast.parse()` + `compile()` 로 syntax 검증 후 통과한 코드만 저장. 실패 시 로그 경고 + 기존 전략 유지
- **수용 기준**:
- syntax 오류가 있는 코드는 저장되지 않음
- 검증 실패 시 기존 전략으로 폴백
- 검증 실패 로그가 기록됨
- **테스트 계획**:
- 단위: 정상 코드/오류 코드 검증 테스트
- 기존 `v20260227_*_evolved.py` 파일로 회귀 테스트
- **의존성**: 없음
---
### Phase 2: 단기 — 데이터 정합성 + v2 실효화 ✅ 완료
손익 계산 정확도를 확보하고, v2 청산 로직을 실효화한다.
---
#### ACT-05: SELL PnL 계산을 sell_qty 기준으로 수정 ✅ 머지
- **ROOT 참조**: ROOT-6 (CRITICAL — PnL 계산이 buy_qty 사용)
- **Gitea 이슈**: fix(critical): SELL PnL 계산을 sell_qty 기준으로 수정
- **Gitea 이슈 번호**: #322
- **변경 대상 파일**: `src/main.py` (line 1658-1663, 2755-2760)
- **현재 동작**: `trade_pnl = (trade_price - buy_price) * buy_qty` — 직전 BUY 수량 사용
- **목표 동작**: `trade_pnl = (trade_price - buy_price) * sell_qty` — 실제 매도 수량 사용
- **수용 기준**:
- 부분청산 시 매도 수량 기준 PnL 계산
- 기존 전량 매도(buy_qty == sell_qty) 케이스는 동일 결과
- CRCA 유사 이상치 재발 불가
- **테스트 계획**:
- 단위: 전량 매도, 부분 매도, 수량 불일치 케이스별 PnL 검증
- DB: Q4 쿼리(`scripts/audit_queries.sql`)로 이상치 0건 확인
- **의존성**: 없음
---
#### ACT-06: BUY 매칭 키에 exchange_code 추가 ✅ 머지
- **ROOT 참조**: ROOT-7 (BUY 매칭 키에 exchange_code 미포함)
- **Gitea 이슈**: fix: BUY 매칭 키에 exchange_code 추가
- **Gitea 이슈 번호**: #323
- **변경 대상 파일**: `src/db.py` (line 292-313)
- **현재 동작**: `get_latest_buy_trade()``(stock_code, market)`만으로 매칭
- **목표 동작**: `exchange_code`가 존재할 때 매칭 키에 포함. NULL인 경우 기존 동작 유지 (하위 호환)
- **수용 기준**:
- 동일 티커 다중 거래소 기록 시 정확한 BUY 매칭
- exchange_code가 NULL인 레거시 데이터에서도 정상 동작
- **테스트 계획**:
- 단위: 동일 티커 다중 exchange 매칭 테스트
- 단위: exchange_code NULL 하위 호환 테스트
- **의존성**: 없음
---
#### ACT-07: 블랙아웃 복구 주문에 log_trade() 추가 ✅ 머지
- **ROOT 참조**: GAP-4 (블랙아웃 복구 주문 DB 미기록)
- **Gitea 이슈**: fix: 블랙아웃 복구 주문에 log_trade() 추가
- **Gitea 이슈 번호**: #324
- **변경 대상 파일**: `src/main.py``process_blackout_recovery_orders()` 함수 내 복구 주문 실행 경로
- **현재 동작**: 블랙아웃 복구 주문이 실행되나 `log_trade()` 호출 없음 → DB에 기록 안 됨
- **목표 동작**: 복구 주문 실행 후 `log_trade()` 호출하여 DB에 기록. rationale에 `[blackout-recovery]` prefix 추가
- **수용 기준**:
- 블랙아웃 복구 주문이 trades 테이블에 기록됨
- rationale로 복구 주문 식별 가능
- 성과 리포트에 복구 주문 포함
- **테스트 계획**:
- 단위: 복구 주문 실행 후 DB 기록 존재 확인
- 통합: 블랙아웃 시나리오 end-to-end 테스트
- **의존성**: 없음
---
#### ACT-08: v2 staged exit에 실제 피처 공급 ✅ 머지
- **ROOT 참조**: ROOT-5 (v2 청산 로직 실효성 부족)
- **Gitea 이슈**: feat: v2 staged exit에 실제 피처(ATR, pred_down_prob) 공급
- **Gitea 이슈 번호**: #325
- **변경 대상 파일**: `src/main.py` (line 500-583), `src/strategy/exit_rules.py`, `src/analysis/technical.py`
- **현재 동작**: `atr_value=0.0`, `pred_down_prob=0.0`으로 공급 → hard stop만 발동
- **목표 동작**:
- `atr_value`: 보유 종목의 ATR(14) 실시간 계산하여 공급
- `pred_down_prob`: 최소한 RSI 기반 하락 확률 추정값 공급 (추후 ML 모델로 대체 가능)
- `be_arm_pct`/`arm_pct`: 독립 파라미터로 설정 가능 (take_profit_pct * 0.4 기계적 파생 제거)
- **수용 기준**:
- `evaluate_exit()` 호출 시 atr_value > 0 (ATR 계산 가능한 종목)
- ATR trailing stop이 실제 발동 가능
- be_arm_pct/arm_pct 독립 설정 가능
- **테스트 계획**:
- 단위: 피처 공급 경로별 값 검증
- 통합: 상태기계 전이 시나리오 (HOLDING→BE_LOCK→ARMED→EXITED)
- **의존성**: ACT-01 (ATR 계산 인프라 공유)
---
#### ACT-09: session_id를 거래/의사결정 로그에 명시적 전달 ✅ 머지
- **ROOT 참조**: GAP-1 (DecisionLogger session_id 미포함), GAP-2 (log_trade session_id 미전달)
- **Gitea 이슈**: feat: session_id를 거래/의사결정 로그에 명시적 전달
- **Gitea 이슈 번호**: #326
- **변경 대상 파일**: `src/logging/decision_logger.py`, `src/main.py` (line 1625, 1682, 2769), `src/db.py`
- **현재 동작**:
- `log_decision()`: session_id 파라미터 없음
- `log_trade()`: session_id 미전달, 시장 코드 기반 자동 추론에 의존
- **목표 동작**:
- `log_decision()`: session_id 파라미터 추가, 로그에 기록
- `log_trade()` 호출 시 런타임 session_id 명시적 전달
- **수용 기준**:
- 모든 SELL/BUY 로그에 session_id 필드 존재
- 의사결정 로그에 session_id 필드 존재
- session_id가 실제 런타임 세션과 일치
- **테스트 계획**:
- 단위: log_decision() session_id 캡처 테스트
- 단위: log_trade() session_id 전달 테스트
- **의존성**: 없음
---
### Phase 3: 중기 — v3 세션 최적화 ✅ 기본 완료 (ACT-13 고도화 잔여)
세션 경계 처리와 운영 거버넌스를 강화한다.
---
#### ACT-10: 세션 전환 시 리스크 파라미터 동적 재로딩 ✅ 머지
- **ROOT 참조**: GAP-3 (세션 전환 시 리스크 파라미터 재로딩 없음)
- **Gitea 이슈**: feat: 세션 전환 시 리스크 파라미터 동적 재로딩
- **Gitea 이슈 번호**: #327
- **변경 대상 파일**: `src/main.py`, `src/config.py`
- **현재 동작**: 리스크 파라미터가 시작 시 한 번만 로딩
- **목표 동작**: 세션 경계 변경 이벤트 시 해당 세션의 리스크 파라미터를 재로딩. 세션별 프로파일 지원
- **수용 기준**:
- NXT_AFTER → KRX_REG 전환 시 파라미터 재로딩 확인
- 재로딩 이벤트 로그 기록
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
- **테스트**: `test_main.py`에 설정 오버라이드/리로드/폴백 단위 테스트 포함. **잔여**: 세션 경계 실시간 전환 E2E 보강
- **의존성**: ACT-09 (session_id 인프라)
---
#### ACT-11: 블랙아웃 복구 시 가격/세션 재검증 강화 ✅ 머지
- **ROOT 참조**: GAP-4 잔여 (가격 유효성, 세션 변경 재적용 미구현)
- **Gitea 이슈**: feat: 블랙아웃 복구 시 가격/세션 재검증 강화
- **Gitea 이슈 번호**: #328
- **변경 대상 파일**: `src/main.py` (line 694-791), `src/core/blackout_manager.py`
- **현재 동작**: stale BUY/SELL 드롭 + order_policy 검증만 수행
- **목표 동작**:
- 복구 시 현재 시세 조회하여 가격 유효성 검증 (진입가 대비 급등/급락 시 드롭)
- 세션 변경 시 새 세션의 파라미터로 재검증
- **수용 기준**:
- 블랙아웃 전후 가격 변동 > 임계값(예: 5%) 시 주문 드롭
- 세션 변경 시 새 세션 파라미터로 재평가
- **테스트 계획**:
- 단위: 가격 변동 시나리오별 드롭/실행 테스트
- 통합: 블랙아웃 + 세션 전환 복합 시나리오
- **의존성**: ACT-07 (복구 주문 DB 기록), ACT-10 (세션 파라미터 재로딩)
---
#### ACT-12: Triple Barrier 시간장벽을 캘린더 시간(분) 기반으로 전환 ✅ 머지
- **ROOT 참조**: GAP-5 (시간장벽이 봉 개수 고정)
- **Gitea 이슈**: feat: Triple Barrier 시간장벽을 캘린더 시간(분) 기반으로 전환
- **Gitea 이슈 번호**: #329
- **변경 대상 파일**: `src/analysis/triple_barrier.py`
- **현재 동작**: `max_holding_bars` (고정 봉 수) 사용
- **목표 동작**: `max_holding_minutes` (캘린더 시간) 기반으로 전환. 봉 주기와 무관하게 일정 시간 경과 시 장벽 도달
- **수용 기준**:
- 분 단위 시간장벽이 봉 주기 변경에도 일관 동작
- 기존 max_holding_bars 하위 호환 (deprecated 경고)
- **테스트 계획**:
- 단위: 다양한 봉 주기(1분, 5분, 15분)에서 시간장벽 일관성 테스트
- 기존 triple_barrier 테스트 회귀 확인
- **의존성**: 없음
---
#### ACT-13: CI 자동 검증 (정책 레지스트리 + TASK-REQ 매핑) ✅ 기본 구현 완료, 고도화 잔여
- **ROOT 참조**: REQ-OPS-002 (정책 변경 시 레지스트리 업데이트 강제), REQ-OPS-003 (TASK-REQ 매핑 강제)
- **Gitea 이슈**: infra: CI 자동 검증 (정책 레지스트리 + TASK-REQ 매핑)
- **Gitea 이슈 번호**: #330
- **현재 동작**: `.gitea/workflows/ci.yml`에서 `scripts/validate_governance_assets.py` + `scripts/validate_ouroboros_docs.py` 자동 실행
- **잔여 고도화**: PR 본문 REQ/TASK/TEST 강제 레벨 상향, 정책 파일 미업데이트 시 CI 실패 기준 강화
- **의존성**: 없음
---
## 3. 검증 계획
### 3.1 단위 테스트
- 모든 ACT 항목에 대해 개별 테스트 작성
- 커버리지 >= 80% 유지
- 현재 CI 기준 전체 테스트 통과 확인 (2026-03-01 기준 998 tests collected)
### 3.2 통합 테스트
- 백테스트 파이프라인: Phase 1 적용 전후 KR 시장 손절 빈도, 반복 매매 횟수, 승률 비교
- 상태기계 통합: Phase 2 피처 공급 후 4중 청산 로직 end-to-end 시나리오
- 블랙아웃 복합: Phase 3 세션 전환 + 블랙아웃 복구 시나리오
### 3.3 실환경 검증
- Paper trading은 실환경과 괴리가 커 검증 신뢰도 부족 → **소액 live 운용**으로 검증
- Phase별 투입 기준: 단위/통합 테스트 통과 → 소액 live (1~2일) → 모니터링 → 정상 확인 후 본운용
---
## 4. 의존성 그래프
```
Phase 1 (병렬 실행 가능)
ACT-01 #318 ─┐
ACT-02 #319 │ (모두 독립)
ACT-03 #320 │
ACT-04 #321 ─┘
Phase 2
ACT-05 #322 ─┐
ACT-06 #323 │ (대부분 독립)
ACT-07 #324 │
ACT-09 #326 ─┘
ACT-08 #325 ←── ACT-01 #318 (ATR 인프라 공유)
Phase 3
ACT-10 #327 ←── ACT-09 #326 (session_id 인프라)
ACT-11 #328 ←── ACT-07 #324, ACT-10 #327
ACT-12 #329 (독립)
ACT-13 #330 (독립)
```
### Phase 간 관계
- Phase 1 → Phase 2: Phase 1 완료가 Phase 2의 전제 조건은 아니나, Phase 1로 출혈 차단 후 Phase 2 진행 권장
- Phase 2 → Phase 3: ACT-09(session_id)가 ACT-10(세션 재로딩)의 전제, ACT-07+ACT-10이 ACT-11의 전제
---
## 5. 롤백 계획
### Phase 1 롤백
- 각 ACT는 독립적이므로 개별 revert 가능
- 손절선(ACT-01): 기존 -2% 고정값으로 복원
- 쿨다운(ACT-02): 쿨다운 체크 제거
- 가격 필터(ACT-03): 필터 조건 제거
- syntax 검증(ACT-04): 검증 스킵, 기존 저장 로직 복원
### Phase 2 롤백
- PnL 수정(ACT-05): buy_qty 기준으로 복원 (단, 데이터 정합성 후퇴 감수)
- exchange_code(ACT-06): 매칭 키에서 제거
- 블랙아웃 DB(ACT-07): log_trade() 호출 제거
- 피처 공급(ACT-08): 0.0 공급으로 복원
- session_id(ACT-09): 파라미터 제거, 자동 추론 복원
### Phase 3 롤백
- 세션 재로딩(ACT-10): 시작 시 1회 로딩으로 복원
- 블랙아웃 재검증(ACT-11): 기존 stale 드롭만 유지
- 시간장벽(ACT-12): max_holding_bars로 복원
- CI(ACT-13): CI 워크플로우 제거
### 롤백 절차
1. 해당 ACT의 PR branch에서 `git revert` 수행
2. 기존 테스트 전체 통과 확인
3. 실환경 투입 전 소액 live 검증
---
## 6. 미진 사항 (2026-03-01 기준)
Phase 1~3 구현 완료 후에도 다음 항목이 운영상 미완료 상태이다.
### 6.1 운영 검증 필요
| 항목 | 설명 | 우선순위 |
|------|------|----------|
| FX PnL 운영 활성화 | `fx_pnl`/`strategy_pnl` 컬럼 존재하나 모든 운영 데이터 값이 0 | P1 |
| 세션 경계 E2E 통합 테스트 보강 | `test_main.py`에 단위 테스트 존재; 세션 경계 실시간 전환 E2E 미작성 | P2 |
| v2 상태기계 통합 end-to-end | 실거래 경로에서 HOLDING→BE_LOCK→ARMED→EXITED 전체 시나리오 테스트 미작성 | P2 |
### 6.2 아키텍처 수준 잔여 갭
| 항목 | 설명 | 배경 문서 |
|------|------|-----------|
| CI 자동 검증 고도화 (#330) | 기본 구현 완료(`validate_governance_assets.py` CI 연동); 규칙/강제수준 고도화 필요 | REQ-OPS-002, REQ-OPS-003 |
| pred_down_prob ML 모델 대체 | 현재 RSI 프록시 사용 — 추후 실제 GBDT/ML 모델로 대체 권장 | ROOT-5, ouroboros_plan_v2.txt §3.D |
| KR/US 파라미터 민감도 분석 | v2 계획의 be_arm_pct/arm_pct/atr_k 최적값 탐색 미수행 | ouroboros_plan_v2.txt §8 |
### 6.3 v3 실험 매트릭스 미착수
ouroboros_plan_v3.txt §9에 정의된 3개 실험이 아직 시작되지 않았다.
| 실험 ID | 시장 | 포커스 | 상태 |
|---------|------|--------|------|
| EXP-KR-01 | KR | NXT 야간 특화 (p_thresh 0.65) | ❌ 미착수 |
| EXP-US-01 | US | 21h 준연속 운용 (atr_k 2.5) | ❌ 미착수 |
| EXP-HYB-01 | Global | KR 낮 + US 밤 연계 레짐 자산배분 | ❌ 미착수 |
---
*끝.*

View File

@@ -18,10 +18,14 @@ Updated: 2026-02-26
4. v3 실행 지시서: [20_phase_v3_execution.md](./20_phase_v3_execution.md)
5. 코드 레벨 작업 지시: [30_code_level_work_orders.md](./30_code_level_work_orders.md)
6. 수용 기준/테스트 계획: [40_acceptance_and_test_plan.md](./40_acceptance_and_test_plan.md)
7. PM 시나리오/이슈 분류: [50_scenario_matrix_and_issue_taxonomy.md](./50_scenario_matrix_and_issue_taxonomy.md)
8. TPM 제어 프로토콜/수용 매트릭스: [50_tpm_control_protocol.md](./50_tpm_control_protocol.md)
7. PM 시나리오/이슈 분류 **(A)**: [50_scenario_matrix_and_issue_taxonomy.md](./50_scenario_matrix_and_issue_taxonomy.md)
8. TPM 제어 프로토콜/수용 매트릭스 **(B)**: [50_tpm_control_protocol.md](./50_tpm_control_protocol.md)
9. 저장소 강제 설정 체크리스트: [60_repo_enforcement_checklist.md](./60_repo_enforcement_checklist.md)
10. 메인 에이전트 아이디에이션 백로그: [70_main_agent_ideation.md](./70_main_agent_ideation.md)
11. v2/v3 구현 감사 및 수익률 분석: [80_implementation_audit.md](./80_implementation_audit.md)
12. 손실 복구 실행 계획: [85_loss_recovery_action_plan.md](./85_loss_recovery_action_plan.md)
> **참고**: 7번·8번은 `50_` 프리픽스를 공유합니다. (A) = 시나리오/이슈 분류, (B) = TPM 제어 프로토콜.
## 운영 규칙

View File

@@ -87,7 +87,7 @@
- 선정 기준 추적 → Evolution 시스템 최적화 가능
- API 장애 시 정적 watchlist로 자동 전환
**참고:** Realtime 모드 전용. Daily 모드는 배치 효율성을 위해 정적 watchlist 사용.
**참고 (당시 구현 기준):** Realtime 모드 전용으로 설계되었으나, 이후 Daily 경로에서도 스캐너를 사용하도록 변경됨. 해외 fallback도 정적 watchlist → 동적 유니버스(active/recent/holdings)로 전환 (2026-02-16 참조).
**이슈/PR:** #76, #77
@@ -355,3 +355,159 @@ Order result: 모의투자 매수주문이 완료 되었습니다. ✓
- `TestOverseasGhostPositionClose` 2개: ghost-close 로그 확인, 일반 오류 무시
**이슈/PR:** #235, PR #236
---
## 2026-02-27
### v2 백테스트 파이프라인 통합 (#305)
**배경:**
- `TripleBarrier`, `WalkForward`, `BacktestCostGuard`는 개별 모듈로 존재했으나,
하나의 실행 경로로 연결된 파이프라인이 없어 통합 검증이 불가능했다.
**구현 내용:**
1. `src/analysis/backtest_pipeline.py`
- `run_v2_backtest_pipeline()` 추가:
- `validate_backtest_cost_model()` 선검증(fail-fast)
- `label_with_triple_barrier()`로 entry 라벨 생성
- `generate_walk_forward_splits()`로 fold 생성
- fold별 baseline(`B0`, `B1`, `M1`) score 산출
- 결과 아티팩트 계약 구조(`BacktestPipelineResult`) 정의
- leakage 검사 유틸 `fold_has_leakage()` 제공
2. `tests/test_backtest_pipeline_integration.py` 신규
- happy path 통합 검증
- cost guard 실패 fail-fast 검증
- purge/embargo 기반 누수 방지 검증
- 동일 입력 재실행 결정성 검증
**검증:**
- `pytest -q tests/test_backtest_pipeline_integration.py tests/test_triple_barrier.py tests/test_walk_forward_split.py tests/test_backtest_cost_guard.py tests/test_backtest_execution_model.py`
- `ruff check src/analysis/backtest_pipeline.py tests/test_backtest_pipeline_integration.py`
**이슈/PR:** #305
---
## 2026-02-28 ~ 2026-03-01
### v2/v3 손실 복구 실행 계획 — Phase 1 완료 (#318~#321)
**배경:**
- `docs/ouroboros/80_implementation_audit.md` 감사 결과 식별된 7개 근본 원인(ROOT) 및 5개 구현 갭(GAP) 중
가장 큰 손실 패턴 4개를 Phase 1로 즉시 제거.
**구현 내용:**
1. **ACT-01: KR 손절선 ATR 기반 동적 확대** (#318)
- `src/main.py`, `src/config.py`
- KR 시장: ATR(14) 기반 동적 hard stop (`k=2.0`, 범위 -2%~-7%)
- ATR 미제공 시 기존 -2% 폴백
- ROOT-1 (hard_stop_pct 고정값 과소) 해소
2. **ACT-02: 손절 후 동일 종목 재진입 쿨다운** (#319)
- `src/main.py`, `src/config.py`
- 손절(pnl<0) 후 동일 종목 `COOLDOWN_MINUTES`(기본 120분) 동안 BUY 차단
- 익절에는 미적용
- ROOT-2 (동일 종목 반복 매매) 해소
3. **ACT-03: US $5 이하 종목 진입 차단 필터** (#320)
- `src/main.py`, `src/config.py`
- US 시장 BUY 시 현재가 `US_MIN_PRICE`(기본 $5) 이하 차단
- ROOT-3 (미국 페니스탁 무분별 진입) 해소
4. **ACT-04: 진화 전략 코드 syntax 검증** (#321)
- `src/evolution/optimizer.py`
- `ast.parse()` + `compile()` 선검증 후 통과한 코드만 저장
- ROOT-4 (진화 전략 문법 오류) 해소
**이슈/PR:** #318, #319, #320, #321
---
### v2/v3 손실 복구 실행 계획 — Phase 2 완료 (#322~#326)
**배경:**
- 손익 계산 정확도 확보 및 v2 청산 로직 실효화.
**구현 내용:**
1. **ACT-05: SELL PnL 계산을 sell_qty 기준으로 수정** (#322)
- `src/main.py` (line 1658-1663, 2755-2760)
- `trade_pnl = (trade_price - buy_price) * sell_qty`로 변경
- ROOT-6 (PnL 계산 buy_qty 사용 CRITICAL) 해소
2. **ACT-06: BUY 매칭 키에 exchange_code 추가** (#323)
- `src/db.py`
- `get_latest_buy_trade()``(stock_code, market, exchange_code)` 기준 매칭
- exchange_code NULL인 레거시 데이터 하위 호환 유지
- ROOT-7 (오매칭 리스크) 해소
3. **ACT-07: 블랙아웃 복구 주문에 log_trade() 추가** (#324)
- `src/main.py` (블랙아웃 복구 실행 경로)
- 복구 주문 실행 후 `log_trade()` 호출, rationale에 `[blackout-recovery]` prefix
- GAP-4 (블랙아웃 복구 주문 DB 미기록) 해소
4. **ACT-08: v2 staged exit에 실제 피처 공급** (#325)
- `src/main.py`, `src/strategy/exit_rules.py`
- `atr_value`: ATR(14) 실시간 계산 공급
- `pred_down_prob`: RSI 기반 하락 확률 추정값 공급 (ML 모델 대체 가능)
- `be_arm_pct`/`arm_pct` 독립 파라미터 설정 가능 (take_profit_pct * 0.4 파생 제거)
- ROOT-5 (v2 청산 로직 실효성 부족) 해소
5. **ACT-09: session_id를 거래/의사결정 로그에 명시적 전달** (#326)
- `src/logging/decision_logger.py`, `src/main.py`, `src/db.py`
- `log_decision()`: session_id 파라미터 추가
- `log_trade()`: 런타임 session_id 명시적 전달
- GAP-1, GAP-2 (session_id 미포함) 부분 해소
**이슈/PR:** #322, #323, #324, #325, #326
---
### v2/v3 손실 복구 실행 계획 — Phase 3 부분 완료 (#327~#329)
**배경:**
- 세션 경계 처리 및 시간장벽 캘린더 기반 전환.
**구현 내용:**
1. **ACT-10: 세션 전환 시 리스크 파라미터 동적 재로딩** (#327)
- `src/main.py`, `src/config.py`
- 세션 경계 변경 이벤트 시 `SESSION_RISK_PROFILES_JSON` 기반 재로딩
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
- GAP-3 (세션 전환 시 파라미터 재로딩 없음) 부분 해소
2. **ACT-11: 블랙아웃 복구 시 가격/세션 재검증 강화** (#328)
- `src/main.py`, `src/core/blackout_manager.py`
- 복구 시 현재 시세 조회하여 가격 유효성 검증 (진입가 대비 급등/급락 시 드롭)
- 세션 변경 시 새 세션의 파라미터로 재검증
- GAP-4 잔여 (가격/세션 재검증) 부분 해소
3. **ACT-12: Triple Barrier 시간장벽을 캘린더 시간(분) 기반으로 전환** (#329)
- `src/analysis/triple_barrier.py`
- `max_holding_minutes` (캘린더 분) 기반 전환, 봉 주기 무관 일관 동작
- 기존 `max_holding_bars` deprecated 경고 유지 (하위 호환)
- GAP-5 (시간장벽 봉 개수 고정) 해소
**미완료 (ACT-13):**
- **#330: CI 자동 검증 (정책 레지스트리 + TASK-REQ 매핑)** — 문서 구조화 작업으로 대체 진행 중
**이슈/PR:** #327, #328, #329
---
### v2/v3 문서 구조화 및 감사 문서 작성 (#331)
**배경:**
- Phase 1~3 구현 완료 후 감사 결과와 실행 계획을 문서화
- 기존 감사 문서가 산발적으로 관리되어 통합 정리 필요
**구현 내용:**
- `docs/ouroboros/80_implementation_audit.md` 신규 작성: v2/v3 구현 감사 + 실거래 수익률 분석
- `docs/ouroboros/85_loss_recovery_action_plan.md` 신규 작성: ROOT/GAP 해소 Phase별 실행 계획
- `scripts/audit_queries.sql` 신규 작성: 성과 재현용 표준 집계 SQL
**이슈/PR:** #331

View File

@@ -2,7 +2,13 @@
## Test Structure
**551 tests** across **25 files**. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator.
테스트 총량은 지속적으로 변동된다. 최신 수치는 아래 명령으로 확인한다.
```bash
pytest --collect-only -q
```
`asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator.
The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB.
@@ -23,6 +29,8 @@ The `settings` fixture in `conftest.py` provides safe defaults with test credent
- Network error handling
- SSL context configuration
> **Note**: 아래 파일별 테스트 수는 릴리즈 시점 스냅샷이며 실제 수치와 다를 수 있습니다. 현재 정확한 수치는 `pytest --collect-only -q`로 확인하세요.
##### `tests/test_brain.py` (24 tests)
- Valid JSON parsing and markdown-wrapped JSON handling
- Malformed JSON fallback
@@ -90,7 +98,7 @@ The `settings` fixture in `conftest.py` provides safe defaults with test credent
- Python-first filtering pipeline
- RSI and volume ratio filter logic
- Candidate scoring and ranking
- Fallback to static watchlist
- Fallback to static watchlist (domestic) or dynamic universe (overseas)
#### Context & Memory
@@ -138,8 +146,8 @@ The `settings` fixture in `conftest.py` provides safe defaults with test credent
#### Dashboard
##### `tests/test_dashboard.py` (14 tests)
- FastAPI endpoint responses (8 API routes)
- Status, playbook, scorecard, performance, context, decisions, scenarios
- FastAPI endpoint responses (10 API routes)
- Status, playbook, scorecard, performance, context, decisions, scenarios, pnl/history, positions
- Query parameter handling (market, date, limit)
#### Performance & Quality
@@ -181,6 +189,29 @@ pytest -v --cov=src --cov-report=term-missing
**Note:** `main.py` has lower coverage as it contains the main loop which is tested via integration/manual testing.
## Backtest Automation Gate
백테스트 관련 검증은 `scripts/backtest_gate.sh``.github/workflows/backtest-gate.yml`로 자동 실행된다.
- PR: 변경 파일 기준 `auto` 모드
- `feature/**` push: 변경 파일 기준 `auto` 모드
- Daily schedule: `full` 강제 실행
- Manual dispatch: `mode`(`auto|smoke|full`) 지정 가능
실행 기준:
- `src/analysis/`, `src/strategy/`, `src/strategies/`, `src/main.py`, `src/markets/`, `src/broker/`
- 백테스트 핵심 테스트 파일 변경
- `docs/ouroboros/` 변경
`auto` 모드에서 백테스트 민감 영역 변경이 없으면 게이트는 `skip` 처리되며 실패로 간주하지 않는다.
로컬 수동 실행:
```bash
bash scripts/backtest_gate.sh
BACKTEST_MODE=full bash scripts/backtest_gate.sh
BASE_REF=origin/feature/v3-session-policy-stream BACKTEST_MODE=auto bash scripts/backtest_gate.sh
```
## Test Configuration
### `pyproject.toml`

View File

@@ -16,6 +16,33 @@
**Never commit directly to `main`.** This policy applies to all changes, no exceptions.
## Agent Gitea Preflight (Mandatory)
Gitea 이슈/PR/코멘트 작업 전에 모든 에이전트는 아래를 먼저 확인해야 한다.
1. `docs/commands.md``tea CLI` 실패 사례/해결 패턴 확인
2. 본 문서의 `Gitea CLI Formatting Troubleshooting` 확인
3. 명령 실행 전 `gh`(GitHub CLI) 사용 금지 확인
강제 규칙:
- 이 저장소 협업 명령은 `tea`를 기본으로 사용한다.
- `gh issue`, `gh pr` 등 GitHub CLI 명령은 사용 금지다.
- `tea` 실패 시 동일 명령 재시도 전에 원인/수정사항을 PR 코멘트에 남긴다.
- 필요한 경우에만 Gitea API(`localhost:3000`)를 fallback으로 사용한다.
## Session Handover Gate (Mandatory)
새 세션에서 구현/검증을 시작하기 전에 아래를 선행해야 한다.
1. `docs/workflow.md`, `docs/commands.md`, `docs/agent-constraints.md` 재확인
2. `workflow/session-handover.md`에 최신 세션 엔트리 추가
3. `python3 scripts/session_handover_check.py --strict` 통과 확인
강제 규칙:
- handover check 실패 상태에서 코드 수정/이슈 상태 전이/PR 생성 금지
- 최신 handover 엔트리는 현재 작업 브랜치를 명시해야 한다
- 최신 handover 엔트리는 당일(UTC) 날짜를 포함해야 한다
## Branch Strategy (Mandatory)
- Team operation default branch is the **program feature branch**, not `main`.
@@ -24,6 +51,21 @@
- Until final user sign-off, `main` merge is prohibited.
- 각 에이전트는 주요 의사결정(리뷰 지적, 수정 방향, 검증 승인)마다 PR 코멘트를 적극 작성해 의사결정 과정을 남긴다.
## Backtest Gate Policy (Mandatory)
사람 의존도를 줄이기 위해 백테스트 검증은 자동 게이트를 기본으로 한다.
- 워크플로우: `.github/workflows/backtest-gate.yml`
- 실행 스크립트: `scripts/backtest_gate.sh`
- 기본 모드: `auto` (변경 파일 기반 실행/skip 판정)
- 정기 스케줄: daily `full` 강제 실행
- 수동 재실행: workflow dispatch + `mode` 지정
강제 규칙:
- 백테스트 민감 변경(PR/feature push)에서 게이트 실패 시 머지 금지
- 스케줄 게이트 실패 시 이슈 등록 후 원인/복구 계획 기록
- `python` 대신 `python3` 기준으로 실행한다
## Gitea CLI Formatting Troubleshooting
Issue/PR 본문 작성 시 줄바꿈(`\n`)이 문자열 그대로 저장되는 문제가 반복될 수 있다. 원인은 `-d "...\n..."` 형태에서 쉘/CLI가 이스케이프를 실제 개행으로 해석하지 않기 때문이다.
@@ -137,6 +179,62 @@ task_tool(
Use `run_in_background=True` for independent tasks that don't block subsequent work.
### Main -> Verifier Directive Contract (Mandatory)
메인 에이전트가 검증 에이전트에 작업을 위임할 때, 아래 6개를 누락하면 지시가 무효다.
1. 검증 대상 범위: `REQ-*`, `TASK-*`, 코드/로그 경로
2. 검증 방법: 실행 커맨드와 관측 포인트(예: 세션별 로그 키워드)
3. 합격 기준: PASS 조건을 수치/문구로 명시
4. 실패 기준: FAIL 조건을 수치/문구로 명시
5. 미관측 기준: `NOT_OBSERVED` 조건과 즉시 에스컬레이션 규칙
6. 증적 형식: PR 코멘트에 `Coverage Matrix` 표로 제출
`NOT_OBSERVED` 처리 규칙:
- 요구사항 항목이 관측되지 않았으면 PASS로 간주 금지
- `NOT_OBSERVED`는 운영상 `FAIL`과 동일하게 처리
- `NOT_OBSERVED`가 하나라도 있으면 승인/머지 금지
`FORBIDDEN` 처리 규칙:
- 정책 위반 신호(예: 주말 `session=KRX_REG`)는 `FORBIDDEN=HIT`으로 별도 기록한다
- `FORBIDDEN=HIT`은 즉시 `P0 FAIL`로 간주하고 모니터링 승인 불가
- 실시간 모니터는 `alive`만으로 정상 판정하지 않는다(정책 불변식 통과가 필수)
### Process-Change-First Rule (Mandatory)
재발 방지/운영 규칙 변경이 결정되면, 기능 구현 티켓보다 먼저 서버(feature branch)에 반영해야 한다.
- 순서: `process ticket merge` -> `implementation ticket start`
- process ticket 미반영 상태에서 기능 티켓 코딩/머지 금지
- 세션 전환 시에도 동일 규칙 유지
### Implementation Start Gate (Mandatory)
구현 티켓을 시작하기 전에 아래 3개를 모두 만족해야 한다.
1. `process ticket merge` 증적 확인 (feature branch 반영 커밋/PR)
2. `workflow/session-handover.md` 최신 엔트리에 `next_ticket``process_gate_checked` 기록
3. `python3 scripts/session_handover_check.py --strict` 통과
강제 규칙:
- 위 3개 중 하나라도 불충족이면 코드/테스트 수정 금지
- 서브에이전트 지시도 동일하게 제한한다 (`process merged 확인 전 read-only 탐색만 허용`)
- 성급 착수 발견 시 구현 작업을 즉시 중단하고 handover/proces gate부터 복구한다
### Ticket Maturity Stages (Mandatory)
모든 티켓은 아래 4단계를 순서대로 통과해야 한다.
1. `Implemented`: 코드/문서 변경 완료
2. `Integrated`: 호출 경로/파이프라인 연결 완료
3. `Observed`: 런타임/실행 증적 확보 완료
4. `Accepted`: 정적 Verifier + Runtime Verifier 승인 완료
강제 규칙:
- 단계 점프 금지 (예: Implemented -> Accepted 금지)
- `Observed` 전에는 완료 선언 금지
- `Accepted` 전에는 머지 금지
## Code Review Checklist
**CRITICAL: Every PR review MUST verify plan-implementation consistency.**
@@ -170,3 +268,10 @@ Before approving any PR, the reviewer (human or agent) must check ALL of the fol
- [ ] PR references the Gitea issue number
- [ ] Feature branch follows naming convention (`feature/issue-N-description`)
- [ ] Commit messages are clear and descriptive
- [ ] 이슈/PR 작업 전에 `docs/commands.md`와 본 문서 트러블슈팅 섹션을 확인했다
- [ ] `gh` 명령을 사용하지 않고 `tea`(또는 허용된 Gitea API fallback)만 사용했다
- [ ] Main -> Verifier 지시가 Directive Contract 6개 항목을 모두 포함한다
- [ ] Verifier 결과에 `Coverage Matrix`(PASS/FAIL/NOT_OBSERVED)가 있고, `NOT_OBSERVED=0`이다
- [ ] Process-change-first 대상이면 해당 process PR이 먼저 머지되었다
- [ ] 티켓 단계가 `Implemented -> Integrated -> Observed -> Accepted` 순서로 기록되었다
- [ ] 정적 Verifier와 Runtime Verifier 승인 코멘트가 모두 존재한다

184
scripts/audit_queries.sql Normal file
View File

@@ -0,0 +1,184 @@
-- audit_queries.sql
-- 용도: 80_implementation_audit.md 성과표 재현을 위한 표준 집계 SQL
-- 대상 DB: trading.db (SQLite)
-- 기간: 2026-02-25 ~ 2026-02-28 (UTC)
-- 참조: docs/ouroboros/80_implementation_audit.md Section 3
------------------------------------------------------------------------
-- Base: 기간 + LIVE + SELL + 직전 BUY 메타 매칭
------------------------------------------------------------------------
-- 모든 후속 쿼리의 기반이 되는 CTE.
-- prev_buy_rationale: 직전 BUY의 rationale (startup-sync 분류용)
-- prev_buy_qty: 직전 BUY 수량 (수량 일치 무결성 필터용)
------------------------------------------------------------------------
WITH base AS (
SELECT *
FROM trades
WHERE mode='live'
AND action='SELL'
AND timestamp >= '2026-02-25T00:00:00+00:00'
AND timestamp < '2026-02-28T00:00:00+00:00'
),
labeled AS (
SELECT
s.id,
s.timestamp,
s.stock_code,
s.market,
s.exchange_code,
s.quantity AS sell_qty,
s.price AS sell_price,
s.pnl,
COALESCE((
SELECT b.rationale
FROM trades b
WHERE b.mode='live'
AND b.action='BUY'
AND b.stock_code=s.stock_code
AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC
LIMIT 1
), '') AS prev_buy_rationale,
(
SELECT b.quantity
FROM trades b
WHERE b.mode='live'
AND b.action='BUY'
AND b.stock_code=s.stock_code
AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC
LIMIT 1
) AS prev_buy_qty
FROM base s
)
SELECT * FROM labeled;
------------------------------------------------------------------------
-- Q1) 통화 분리 손익 (KRW/USD 혼합 금지)
------------------------------------------------------------------------
WITH base AS (
SELECT * FROM trades
WHERE mode='live' AND action='SELL'
AND timestamp >= '2026-02-25T00:00:00+00:00'
AND timestamp < '2026-02-28T00:00:00+00:00'
),
labeled AS (
SELECT s.*,
s.quantity AS sell_qty,
COALESCE((SELECT b.rationale FROM trades b
WHERE b.mode='live' AND b.action='BUY'
AND b.stock_code=s.stock_code AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC LIMIT 1), '') AS prev_buy_rationale,
(SELECT b.quantity FROM trades b
WHERE b.mode='live' AND b.action='BUY'
AND b.stock_code=s.stock_code AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC LIMIT 1) AS prev_buy_qty
FROM base s
)
SELECT
CASE WHEN market='KR' THEN 'KRW' ELSE 'USD' END AS ccy,
COUNT(*) AS sells,
ROUND(SUM(pnl),2) AS pnl_sum
FROM labeled
GROUP BY ccy
ORDER BY ccy;
------------------------------------------------------------------------
-- Q2) 기존 보유(startup-sync) 제외 성과
------------------------------------------------------------------------
WITH base AS (
SELECT * FROM trades
WHERE mode='live' AND action='SELL'
AND timestamp >= '2026-02-25T00:00:00+00:00'
AND timestamp < '2026-02-28T00:00:00+00:00'
),
labeled AS (
SELECT s.*,
s.quantity AS sell_qty,
COALESCE((SELECT b.rationale FROM trades b
WHERE b.mode='live' AND b.action='BUY'
AND b.stock_code=s.stock_code AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC LIMIT 1), '') AS prev_buy_rationale,
(SELECT b.quantity FROM trades b
WHERE b.mode='live' AND b.action='BUY'
AND b.stock_code=s.stock_code AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC LIMIT 1) AS prev_buy_qty
FROM base s
)
SELECT
CASE WHEN market='KR' THEN 'KRW' ELSE 'USD' END AS ccy,
COUNT(*) AS sells,
ROUND(SUM(pnl),2) AS pnl_sum
FROM labeled
WHERE prev_buy_rationale NOT LIKE '[startup-sync]%'
GROUP BY ccy
ORDER BY ccy;
------------------------------------------------------------------------
-- Q3) 수량 일치 체결만 포함 (무결성 필터)
------------------------------------------------------------------------
WITH base AS (
SELECT * FROM trades
WHERE mode='live' AND action='SELL'
AND timestamp >= '2026-02-25T00:00:00+00:00'
AND timestamp < '2026-02-28T00:00:00+00:00'
),
labeled AS (
SELECT s.*,
s.quantity AS sell_qty,
COALESCE((SELECT b.rationale FROM trades b
WHERE b.mode='live' AND b.action='BUY'
AND b.stock_code=s.stock_code AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC LIMIT 1), '') AS prev_buy_rationale,
(SELECT b.quantity FROM trades b
WHERE b.mode='live' AND b.action='BUY'
AND b.stock_code=s.stock_code AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC LIMIT 1) AS prev_buy_qty
FROM base s
)
SELECT
CASE WHEN market='KR' THEN 'KRW' ELSE 'USD' END AS ccy,
COUNT(*) AS sells,
ROUND(SUM(pnl),2) AS pnl_sum
FROM labeled
WHERE prev_buy_qty = sell_qty
GROUP BY ccy
ORDER BY ccy;
------------------------------------------------------------------------
-- Q4) 이상치 목록 (수량 불일치)
------------------------------------------------------------------------
WITH base AS (
SELECT * FROM trades
WHERE mode='live' AND action='SELL'
AND timestamp >= '2026-02-25T00:00:00+00:00'
AND timestamp < '2026-02-28T00:00:00+00:00'
),
labeled AS (
SELECT s.id, s.timestamp, s.stock_code, s.market, s.quantity AS sell_qty, s.pnl,
(SELECT b.quantity FROM trades b
WHERE b.mode='live' AND b.action='BUY'
AND b.stock_code=s.stock_code AND b.market=s.market
AND b.timestamp < s.timestamp
ORDER BY b.timestamp DESC, b.id DESC LIMIT 1) AS prev_buy_qty
FROM base s
)
SELECT
id, timestamp, stock_code, market, sell_qty, prev_buy_qty, ROUND(pnl,2) AS pnl
FROM labeled
WHERE prev_buy_qty IS NOT NULL
AND prev_buy_qty != sell_qty
ORDER BY ABS(pnl) DESC;

106
scripts/backtest_gate.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Backtest gate for PR/push/scheduled verification.
set -euo pipefail
MODE="${BACKTEST_MODE:-auto}" # auto | smoke | full
BASE_REF="${BASE_REF:-origin/main}" # used when MODE=auto
FORCE_FULL="${FORCE_FULL_BACKTEST:-false}"
LOG_DIR="${LOG_DIR:-data/backtest-gate}"
mkdir -p "$LOG_DIR"
STAMP="$(date -u +%Y%m%d_%H%M%S)"
LOG_FILE="$LOG_DIR/backtest_gate_${STAMP}.log"
log() {
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" | tee -a "$LOG_FILE"
}
run_cmd() {
log "[RUN] $*"
"$@" 2>&1 | tee -a "$LOG_FILE"
}
resolve_mode_from_changes() {
if [ "$FORCE_FULL" = "true" ]; then
echo "full"
return
fi
if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then
log "[WARN] BASE_REF not found: $BASE_REF; fallback to full"
echo "full"
return
fi
changed_files="$(git diff --name-only "$BASE_REF"...HEAD || true)"
if [ -z "$changed_files" ]; then
log "[INFO] no changed files between $BASE_REF...HEAD; skip backtest gate"
echo "skip"
return
fi
log "[INFO] changed files from $BASE_REF...HEAD:"
while IFS= read -r line; do
[ -n "$line" ] && log " - $line"
done <<< "$changed_files"
# Backtest-sensitive areas: analysis/strategy/runtime execution semantics.
if printf '%s\n' "$changed_files" | rg -q \
'^(src/analysis/|src/strategy/|src/strategies/|src/main.py|src/markets/|src/broker/|tests/test_backtest_|tests/test_triple_barrier.py|tests/test_walk_forward_split.py|tests/test_main.py|docs/ouroboros/)'
then
echo "full"
else
echo "skip"
fi
}
SMOKE_TESTS=(
tests/test_backtest_pipeline_integration.py
tests/test_triple_barrier.py
tests/test_walk_forward_split.py
tests/test_backtest_cost_guard.py
tests/test_backtest_execution_model.py
)
FULL_TESTS=(
tests/test_backtest_pipeline_integration.py
tests/test_triple_barrier.py
tests/test_walk_forward_split.py
tests/test_backtest_cost_guard.py
tests/test_backtest_execution_model.py
tests/test_main.py
)
main() {
log "[INFO] backtest gate started mode=$MODE base_ref=$BASE_REF force_full=$FORCE_FULL"
selected_mode="$MODE"
if [ "$MODE" = "auto" ]; then
selected_mode="$(resolve_mode_from_changes)"
fi
case "$selected_mode" in
skip)
log "[PASS] backtest gate skipped (no backtest-sensitive changes)"
exit 0
;;
smoke)
run_cmd python3 -m pytest -q "${SMOKE_TESTS[@]}"
log "[PASS] smoke backtest gate passed"
;;
full)
run_cmd python3 -m pytest -q "${SMOKE_TESTS[@]}"
# Runtime semantics tied to v2 staged-exit must remain covered in full gate.
run_cmd python3 -m pytest -q tests/test_main.py -k \
"staged_exit_override or runtime_exit_cache_cleared or run_daily_session_applies_staged_exit_override_on_hold"
log "[PASS] full backtest gate passed"
;;
*)
log "[FAIL] invalid BACKTEST_MODE=$selected_mode (expected auto|smoke|full)"
exit 2
;;
esac
}
main "$@"

114
scripts/runtime_verify_monitor.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Runtime verification monitor with coverage + forbidden invariant checks.
set -euo pipefail
ROOT_DIR="${ROOT_DIR:-/home/agentson/repos/The-Ouroboros}"
LOG_DIR="${LOG_DIR:-$ROOT_DIR/data/overnight}"
INTERVAL_SEC="${INTERVAL_SEC:-60}"
MAX_HOURS="${MAX_HOURS:-24}"
POLICY_TZ="${POLICY_TZ:-Asia/Seoul}"
cd "$ROOT_DIR"
OUT_LOG="$LOG_DIR/runtime_verify_$(date +%Y%m%d_%H%M%S).log"
END_TS=$(( $(date +%s) + MAX_HOURS*3600 ))
log() {
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" | tee -a "$OUT_LOG" >/dev/null
}
check_signal() {
local name="$1"
local pattern="$2"
local run_log="$3"
if rg -q "$pattern" "$run_log"; then
log "[COVERAGE] ${name}=PASS pattern=${pattern}"
return 0
fi
log "[COVERAGE] ${name}=NOT_OBSERVED pattern=${pattern}"
return 1
}
check_forbidden() {
local name="$1"
local pattern="$2"
local run_log="$3"
if rg -q "$pattern" "$run_log"; then
log "[FORBIDDEN] ${name}=HIT pattern=${pattern}"
return 1
fi
log "[FORBIDDEN] ${name}=CLEAR pattern=${pattern}"
return 0
}
log "[INFO] runtime verify monitor started interval=${INTERVAL_SEC}s max_hours=${MAX_HOURS} policy_tz=${POLICY_TZ}"
while true; do
now=$(date +%s)
if [ "$now" -ge "$END_TS" ]; then
log "[INFO] monitor completed (time window reached)"
exit 0
fi
latest_run="$(ls -t "$LOG_DIR"/run_*.log 2>/dev/null | head -n1 || true)"
if [ -z "$latest_run" ]; then
log "[ANOMALY] no run log found"
sleep "$INTERVAL_SEC"
continue
fi
# Basic liveness hints.
app_pid="$(cat "$LOG_DIR/app.pid" 2>/dev/null || true)"
wd_pid="$(cat "$LOG_DIR/watchdog.pid" 2>/dev/null || true)"
app_alive=0
wd_alive=0
port_alive=0
[ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null && app_alive=1
[ -n "$wd_pid" ] && kill -0 "$wd_pid" 2>/dev/null && wd_alive=1
ss -ltnp 2>/dev/null | rg -q ':8080' && port_alive=1
log "[HEARTBEAT] run_log=$latest_run app_alive=$app_alive watchdog_alive=$wd_alive port8080=$port_alive"
# Coverage matrix rows (session paths and policy gate evidence).
not_observed=0
check_signal "LIVE_MODE" "Mode: live" "$latest_run" || not_observed=$((not_observed+1))
check_signal "KR_LOOP" "Processing market: Korea Exchange" "$latest_run" || not_observed=$((not_observed+1))
check_signal "NXT_PATH" "NXT_PRE|NXT_AFTER|session=NXT_" "$latest_run" || not_observed=$((not_observed+1))
check_signal "US_PRE_PATH" "US_PRE|session=US_PRE" "$latest_run" || not_observed=$((not_observed+1))
check_signal "US_DAY_PATH" "US_DAY|session=US_DAY|Processing market: .*NASDAQ|Processing market: .*NYSE|Processing market: .*AMEX" "$latest_run" || not_observed=$((not_observed+1))
check_signal "US_AFTER_PATH" "US_AFTER|session=US_AFTER" "$latest_run" || not_observed=$((not_observed+1))
check_signal "ORDER_POLICY_SESSION" "Order policy rejected .*\\[session=" "$latest_run" || not_observed=$((not_observed+1))
if [ "$not_observed" -gt 0 ]; then
log "[ANOMALY] coverage_not_observed=$not_observed (treat as FAIL)"
else
log "[OK] coverage complete (NOT_OBSERVED=0)"
fi
# Forbidden invariants: must never happen under given policy context.
forbidden_hits=0
policy_dow="$(TZ="$POLICY_TZ" date +%u)" # 1..7 (Mon..Sun)
is_weekend=0
if [ "$policy_dow" -ge 6 ]; then
is_weekend=1
fi
if [ "$is_weekend" -eq 1 ]; then
# Weekend policy: KR regular session loop must never appear.
check_forbidden "WEEKEND_KR_SESSION_ACTIVE" \
"Market session active: KR|session=KRX_REG|Processing market: Korea Exchange" \
"$latest_run" || forbidden_hits=$((forbidden_hits+1))
else
log "[FORBIDDEN] WEEKEND_KR_SESSION_ACTIVE=SKIP reason=weekday"
fi
if [ "$forbidden_hits" -gt 0 ]; then
log "[P0] forbidden_invariant_hits=$forbidden_hits (treat as immediate FAIL)"
else
log "[OK] forbidden invariants clear"
fi
sleep "$INTERVAL_SEC"
done

146
scripts/session_handover_check.py Executable file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Session handover preflight gate.
This script enforces a minimal handover record per working branch so that
new sessions cannot start implementation without reading the required docs
and recording current intent.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
REQUIRED_DOCS = (
Path("docs/workflow.md"),
Path("docs/commands.md"),
Path("docs/agent-constraints.md"),
)
HANDOVER_LOG = Path("workflow/session-handover.md")
def _run_git(*args: str) -> str:
try:
return (
subprocess.check_output(["git", *args], stderr=subprocess.DEVNULL)
.decode("utf-8")
.strip()
)
except Exception:
return ""
def _current_branch() -> str:
branch = _run_git("branch", "--show-current")
if branch:
return branch
return _run_git("rev-parse", "--abbrev-ref", "HEAD")
def _latest_entry(text: str) -> str:
chunks = text.split("\n### ")
if not chunks:
return ""
if chunks[0].startswith("### "):
chunks[0] = chunks[0][4:]
latest = chunks[-1].strip()
if not latest:
return ""
if not latest.startswith("### "):
latest = f"### {latest}"
return latest
def _check_required_files(errors: list[str]) -> None:
for path in REQUIRED_DOCS:
if not path.exists():
errors.append(f"missing required document: {path}")
if not HANDOVER_LOG.exists():
errors.append(f"missing handover log: {HANDOVER_LOG}")
def _check_handover_entry(
*,
branch: str,
strict: bool,
errors: list[str],
) -> None:
if not HANDOVER_LOG.exists():
return
text = HANDOVER_LOG.read_text(encoding="utf-8")
latest = _latest_entry(text)
if not latest:
errors.append("handover log has no session entry")
return
required_tokens = (
"- branch:",
"- docs_checked:",
"- open_issues_reviewed:",
"- next_ticket:",
"- process_gate_checked:",
)
for token in required_tokens:
if token not in latest:
errors.append(f"latest handover entry missing token: {token}")
if strict:
today_utc = datetime.now(UTC).date().isoformat()
if today_utc not in latest:
errors.append(
f"latest handover entry must contain today's UTC date ({today_utc})"
)
branch_token = f"- branch: {branch}"
if branch_token not in latest:
errors.append(
"latest handover entry must target current branch "
f"({branch_token})"
)
if "- next_ticket: #TBD" in latest:
errors.append("latest handover entry must not use placeholder next_ticket (#TBD)")
if "merged_to_feature_branch=no" in latest:
errors.append(
"process gate indicates not merged; implementation must stay blocked "
"(merged_to_feature_branch=no)"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate session handover gate requirements."
)
parser.add_argument(
"--strict",
action="store_true",
help="Enforce today-date and current-branch match on latest handover entry.",
)
args = parser.parse_args()
errors: list[str] = []
_check_required_files(errors)
branch = _current_branch()
if not branch:
errors.append("cannot resolve current git branch")
elif branch in {"main", "master"}:
errors.append(f"working branch must not be {branch}")
_check_handover_entry(branch=branch, strict=args.strict, errors=errors)
if errors:
print("[FAIL] session handover check failed")
for err in errors:
print(f"- {err}")
return 1
print("[OK] session handover check passed")
print(f"[OK] branch={branch}")
print(f"[OK] handover_log={HANDOVER_LOG}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Validate lightweight documentation synchronization rules."""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(".")
def read_text(path: Path, errors: list[str]) -> str:
if not path.exists():
errors.append(f"missing file: {path}")
return ""
return path.read_text(encoding="utf-8")
def main() -> int:
errors: list[str] = []
docs_index = ROOT / "docs" / "README.md"
readme = ROOT / "README.md"
claude = ROOT / "CLAUDE.md"
commands = ROOT / "docs" / "commands.md"
docs_index_text = read_text(docs_index, errors)
readme_text = read_text(readme, errors)
claude_text = read_text(claude, errors)
commands_text = read_text(commands, errors)
if docs_index_text and "Single Source of Truth" not in docs_index_text:
errors.append("docs/README.md: missing 'Single Source of Truth' section")
if readme_text and "docs/README.md" not in readme_text:
errors.append("README.md: missing docs/README.md routing link")
if claude_text and "docs/README.md" not in claude_text:
errors.append("CLAUDE.md: missing docs/README.md routing link")
# Prevent volatile hard-coded scale numbers in summary docs.
volatile_patterns: list[tuple[str, re.Pattern[str]]] = [
("README.md", re.compile(r"\b\d+\s*개 테스트\b")),
("README.md", re.compile(r"\b\d+\s*tests\s+across\b", re.IGNORECASE)),
("README.md", re.compile(r"\(\d+\s*개 API\)")),
("README.md", re.compile(r"\(\d+\s*API endpoints?\)", re.IGNORECASE)),
("CLAUDE.md", re.compile(r"\b\d+\s*tests\s+across\b", re.IGNORECASE)),
("CLAUDE.md", re.compile(r"\(\d+\s*API endpoints?\)", re.IGNORECASE)),
("docs/commands.md", re.compile(r"Run full test suite with coverage\s*\(\d+", re.IGNORECASE)),
]
text_by_name = {
"README.md": readme_text,
"CLAUDE.md": claude_text,
"docs/commands.md": commands_text,
}
for name, pattern in volatile_patterns:
text = text_by_name.get(name, "")
if text and pattern.search(text):
errors.append(f"{name}: contains volatile hard-coded scale text ({pattern.pattern})")
# Command doc should list all dashboard endpoints exposed by app.py.
for endpoint in ("/api/pnl/history", "/api/positions"):
if commands_text and endpoint not in commands_text:
errors.append(f"docs/commands.md: missing dashboard endpoint {endpoint}")
if errors:
print("[FAIL] docs sync validation failed")
for err in errors:
print(f"- {err}")
return 1
print("[OK] docs sync validation passed")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Validate persistent governance assets for agent workflow safety."""
from __future__ import annotations
import subprocess
import sys
import os
import re
from pathlib import Path
REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md"
TASK_WORK_ORDERS_DOC = "docs/ouroboros/30_code_level_work_orders.md"
TASK_DEF_LINE = re.compile(r"^-\s+`(?P<task_id>TASK-[A-Z0-9-]+-\d{3})`(?P<body>.*)$")
REQ_ID_IN_LINE = re.compile(r"\bREQ-[A-Z0-9-]+-\d{3}\b")
TASK_ID_IN_TEXT = re.compile(r"\bTASK-[A-Z0-9-]+-\d{3}\b")
TEST_ID_IN_TEXT = re.compile(r"\bTEST-[A-Z0-9-]+-\d{3}\b")
def must_contain(path: Path, required: list[str], errors: list[str]) -> None:
if not path.exists():
errors.append(f"missing file: {path}")
return
text = path.read_text(encoding="utf-8")
for token in required:
if token not in text:
errors.append(f"{path}: missing required token -> {token}")
def normalize_changed_path(path: str) -> str:
normalized = path.strip().replace("\\", "/")
if normalized.startswith("./"):
normalized = normalized[2:]
return normalized
def is_policy_file(path: str) -> bool:
normalized = normalize_changed_path(path)
if not normalized.endswith(".md"):
return False
if not normalized.startswith("docs/ouroboros/"):
return False
return normalized != REQUIREMENTS_REGISTRY
def load_changed_files(args: list[str], errors: list[str]) -> list[str]:
if not args:
return []
# Single range input (e.g. BASE..HEAD or BASE...HEAD)
if len(args) == 1 and ".." in args[0]:
range_spec = args[0]
try:
completed = subprocess.run(
["git", "diff", "--name-only", range_spec],
check=True,
capture_output=True,
text=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
errors.append(f"failed to load changed files from range '{range_spec}': {exc}")
return []
return [
normalize_changed_path(line)
for line in completed.stdout.splitlines()
if line.strip()
]
return [normalize_changed_path(path) for path in args if path.strip()]
def validate_registry_sync(changed_files: list[str], errors: list[str]) -> None:
if not changed_files:
return
changed_set = set(changed_files)
policy_changed = any(is_policy_file(path) for path in changed_set)
registry_changed = REQUIREMENTS_REGISTRY in changed_set
if policy_changed and not registry_changed:
errors.append(
"policy file changed without updating docs/ouroboros/01_requirements_registry.md"
)
def validate_task_req_mapping(errors: list[str], *, task_doc: Path | None = None) -> None:
path = task_doc or Path(TASK_WORK_ORDERS_DOC)
if not path.exists():
errors.append(f"missing file: {path}")
return
text = path.read_text(encoding="utf-8")
found_task = False
for line in text.splitlines():
m = TASK_DEF_LINE.match(line.strip())
if not m:
continue
found_task = True
if not REQ_ID_IN_LINE.search(m.group("body")):
errors.append(
f"{path}: TASK without REQ mapping -> {m.group('task_id')}"
)
if not found_task:
errors.append(f"{path}: no TASK definitions found")
def validate_pr_traceability(warnings: list[str]) -> None:
title = os.getenv("GOVERNANCE_PR_TITLE", "").strip()
body = os.getenv("GOVERNANCE_PR_BODY", "").strip()
if not title and not body:
return
text = f"{title}\n{body}"
if not REQ_ID_IN_LINE.search(text):
warnings.append("PR text missing REQ-ID reference")
if not TASK_ID_IN_TEXT.search(text):
warnings.append("PR text missing TASK-ID reference")
if not TEST_ID_IN_TEXT.search(text):
warnings.append("PR text missing TEST-ID reference")
def main() -> int:
errors: list[str] = []
warnings: list[str] = []
changed_files = load_changed_files(sys.argv[1:], errors)
pr_template = Path(".gitea/PULL_REQUEST_TEMPLATE.md")
issue_template = Path(".gitea/ISSUE_TEMPLATE/runtime_verification.md")
workflow_doc = Path("docs/workflow.md")
commands_doc = Path("docs/commands.md")
handover_script = Path("scripts/session_handover_check.py")
handover_log = Path("workflow/session-handover.md")
must_contain(
pr_template,
[
"Closes #N",
"Main -> Verifier Directive Contract",
"Coverage Matrix",
"NOT_OBSERVED",
"tea",
"gh",
"Session Handover Gate",
"session_handover_check.py --strict",
],
errors,
)
must_contain(
issue_template,
[
"[RUNTIME-VERIFY][SCN-XXX]",
"Requirement Mapping",
"Close Criteria",
"NOT_OBSERVED = 0",
],
errors,
)
must_contain(
workflow_doc,
[
"Session Handover Gate (Mandatory)",
"session_handover_check.py --strict",
],
errors,
)
must_contain(
commands_doc,
[
"Session Handover Preflight (Mandatory)",
"session_handover_check.py --strict",
],
errors,
)
must_contain(
handover_log,
[
"Session Handover Log",
"- branch:",
"- docs_checked:",
"- open_issues_reviewed:",
"- next_ticket:",
],
errors,
)
if not handover_script.exists():
errors.append(f"missing file: {handover_script}")
validate_registry_sync(changed_files, errors)
validate_task_req_mapping(errors)
validate_pr_traceability(warnings)
if errors:
print("[FAIL] governance asset validation failed")
for err in errors:
print(f"- {err}")
return 1
print("[OK] governance assets validated")
if warnings:
print(f"[WARN] governance advisory: {len(warnings)}")
for warn in warnings:
print(f"- {warn}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,201 @@
"""Integrated v2 backtest pipeline.
Wires TripleBarrier labeling + WalkForward split + CostGuard validation
into a single deterministic orchestration path.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from statistics import mean
from typing import Literal
from typing import cast
from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
from src.analysis.walk_forward_split import WalkForwardFold, generate_walk_forward_splits
@dataclass(frozen=True)
class BacktestBar:
high: float
low: float
close: float
session_id: str
timestamp: datetime | None = None
@dataclass(frozen=True)
class WalkForwardConfig:
train_size: int
test_size: int
step_size: int | None = None
purge_size: int = 0
embargo_size: int = 0
min_train_size: int = 1
@dataclass(frozen=True)
class BaselineScore:
name: Literal["B0", "B1", "M1"]
accuracy: float
@dataclass(frozen=True)
class BacktestFoldResult:
fold_index: int
train_indices: list[int]
test_indices: list[int]
train_label_distribution: dict[int, int]
test_label_distribution: dict[int, int]
baseline_scores: list[BaselineScore]
@dataclass(frozen=True)
class BacktestPipelineResult:
run_id: str
n_bars: int
n_entries: int
required_sessions: list[str]
label_distribution: dict[int, int]
folds: list[BacktestFoldResult]
def run_v2_backtest_pipeline(
*,
bars: Sequence[BacktestBar],
entry_indices: Sequence[int],
side: int,
triple_barrier_spec: TripleBarrierSpec,
walk_forward: WalkForwardConfig,
cost_model: BacktestCostModel,
required_sessions: list[str] | None = None,
) -> BacktestPipelineResult:
"""Run v2 integrated pipeline (cost guard -> labels -> walk-forward baselines)."""
if not bars:
raise ValueError("bars must not be empty")
if not entry_indices:
raise ValueError("entry_indices must not be empty")
resolved_sessions = (
sorted(set(required_sessions))
if required_sessions is not None
else sorted({bar.session_id for bar in bars})
)
validate_backtest_cost_model(model=cost_model, required_sessions=resolved_sessions)
highs = [float(bar.high) for bar in bars]
lows = [float(bar.low) for bar in bars]
closes = [float(bar.close) for bar in bars]
timestamps = [bar.timestamp for bar in bars]
normalized_entries = sorted(set(int(i) for i in entry_indices))
if normalized_entries[0] < 0 or normalized_entries[-1] >= len(bars):
raise IndexError("entry index out of range")
resolved_timestamps: list[datetime] | None = None
if triple_barrier_spec.max_holding_minutes is not None:
if any(ts is None for ts in timestamps):
raise ValueError(
"BacktestBar.timestamp is required for all bars when "
"triple_barrier_spec.max_holding_minutes is set"
)
resolved_timestamps = cast(list[datetime], timestamps)
labels_by_bar_index: dict[int, int] = {}
for idx in normalized_entries:
labels_by_bar_index[idx] = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
timestamps=resolved_timestamps,
entry_index=idx,
side=side,
spec=triple_barrier_spec,
).label
ordered_labels = [labels_by_bar_index[idx] for idx in normalized_entries]
folds = generate_walk_forward_splits(
n_samples=len(normalized_entries),
train_size=walk_forward.train_size,
test_size=walk_forward.test_size,
step_size=walk_forward.step_size,
purge_size=walk_forward.purge_size,
embargo_size=walk_forward.embargo_size,
min_train_size=walk_forward.min_train_size,
)
fold_results: list[BacktestFoldResult] = []
for fold_idx, fold in enumerate(folds):
train_labels = [ordered_labels[i] for i in fold.train_indices]
test_labels = [ordered_labels[i] for i in fold.test_indices]
if not test_labels:
continue
fold_results.append(
BacktestFoldResult(
fold_index=fold_idx,
train_indices=fold.train_indices,
test_indices=fold.test_indices,
train_label_distribution=_label_dist(train_labels),
test_label_distribution=_label_dist(test_labels),
baseline_scores=[
BaselineScore(name="B0", accuracy=_baseline_b0(train_labels, test_labels)),
BaselineScore(name="B1", accuracy=_score_constant(1, test_labels)),
BaselineScore(
name="M1",
accuracy=_score_constant(_m1_pred(train_labels), test_labels),
),
],
)
)
return BacktestPipelineResult(
run_id=_build_run_id(
n_entries=len(normalized_entries),
n_folds=len(fold_results),
sessions=resolved_sessions,
),
n_bars=len(bars),
n_entries=len(normalized_entries),
required_sessions=resolved_sessions,
label_distribution=_label_dist(ordered_labels),
folds=fold_results,
)
def _label_dist(labels: Sequence[int]) -> dict[int, int]:
dist: dict[int, int] = {-1: 0, 0: 0, 1: 0}
for val in labels:
if val in dist:
dist[val] += 1
return dist
def _score_constant(pred: int, actual: Sequence[int]) -> float:
return mean(1.0 if pred == label else 0.0 for label in actual)
def _baseline_b0(train_labels: Sequence[int], test_labels: Sequence[int]) -> float:
if not train_labels:
return _score_constant(0, test_labels)
# Majority-class baseline from training fold.
choices = (-1, 0, 1)
pred = max(choices, key=lambda c: train_labels.count(c))
return _score_constant(pred, test_labels)
def _m1_pred(train_labels: Sequence[int]) -> int:
if not train_labels:
return 0
return train_labels[-1]
def _build_run_id(*, n_entries: int, n_folds: int, sessions: Sequence[str]) -> str:
sess_key = "_".join(sessions)
return f"v2p-e{n_entries}-f{n_folds}-s{sess_key}"
def fold_has_leakage(fold: WalkForwardFold) -> bool:
"""Utility for tests/verification: True when train/test overlap exists."""
return bool(set(fold.train_indices).intersection(fold.test_indices))

View File

@@ -5,7 +5,9 @@ Implements first-touch labeling with upper/lower/time barriers.
from __future__ import annotations
import warnings
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal, Sequence
@@ -16,9 +18,18 @@ TieBreakMode = Literal["stop_first", "take_first"]
class TripleBarrierSpec:
take_profit_pct: float
stop_loss_pct: float
max_holding_bars: int
max_holding_bars: int | None = None
max_holding_minutes: int | None = None
tie_break: TieBreakMode = "stop_first"
def __post_init__(self) -> None:
if self.max_holding_minutes is None and self.max_holding_bars is None:
raise ValueError("one of max_holding_minutes or max_holding_bars must be set")
if self.max_holding_minutes is not None and self.max_holding_minutes <= 0:
raise ValueError("max_holding_minutes must be positive")
if self.max_holding_bars is not None and self.max_holding_bars <= 0:
raise ValueError("max_holding_bars must be positive")
@dataclass(frozen=True)
class TripleBarrierLabel:
@@ -35,6 +46,7 @@ def label_with_triple_barrier(
highs: Sequence[float],
lows: Sequence[float],
closes: Sequence[float],
timestamps: Sequence[datetime] | None = None,
entry_index: int,
side: int,
spec: TripleBarrierSpec,
@@ -53,8 +65,6 @@ def label_with_triple_barrier(
raise ValueError("highs, lows, closes lengths must match")
if entry_index < 0 or entry_index >= len(closes):
raise IndexError("entry_index out of range")
if spec.max_holding_bars <= 0:
raise ValueError("max_holding_bars must be positive")
entry_price = float(closes[entry_index])
if entry_price <= 0:
@@ -68,13 +78,31 @@ def label_with_triple_barrier(
upper = entry_price * (1.0 + spec.stop_loss_pct)
lower = entry_price * (1.0 - spec.take_profit_pct)
if spec.max_holding_minutes is not None:
if timestamps is None:
raise ValueError("timestamps are required when max_holding_minutes is set")
if len(timestamps) != len(closes):
raise ValueError("timestamps length must match OHLC lengths")
expiry_timestamp = timestamps[entry_index] + timedelta(minutes=spec.max_holding_minutes)
last_index = entry_index
for idx in range(entry_index + 1, len(closes)):
if timestamps[idx] > expiry_timestamp:
break
last_index = idx
else:
assert spec.max_holding_bars is not None
warnings.warn(
"TripleBarrierSpec.max_holding_bars is deprecated; use max_holding_minutes with timestamps instead.",
DeprecationWarning,
stacklevel=2,
)
last_index = min(len(closes) - 1, entry_index + spec.max_holding_bars)
for idx in range(entry_index + 1, last_index + 1):
h = float(highs[idx])
l = float(lows[idx])
high_price = float(highs[idx])
low_price = float(lows[idx])
up_touch = h >= upper
down_touch = l <= lower
up_touch = high_price >= upper
down_touch = low_price <= lower
if not up_touch and not down_touch:
continue

View File

@@ -60,7 +60,16 @@ class Settings(BaseSettings):
# This value is used as a fallback when the balance API returns 0 in paper mode.
PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0)
USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0)
US_MIN_PRICE: float = Field(default=5.0, ge=0.0)
STAGED_EXIT_BE_ARM_PCT: float = Field(default=1.2, gt=0.0, le=30.0)
STAGED_EXIT_ARM_PCT: float = Field(default=3.0, gt=0.0, le=100.0)
STOPLOSS_REENTRY_COOLDOWN_MINUTES: int = Field(default=120, ge=1, le=1440)
KR_ATR_STOP_MULTIPLIER_K: float = Field(default=2.0, ge=0.1, le=10.0)
KR_ATR_STOP_MIN_PCT: float = Field(default=-2.0, le=0.0)
KR_ATR_STOP_MAX_PCT: float = Field(default=-7.0, le=0.0)
OVERNIGHT_EXCEPTION_ENABLED: bool = True
SESSION_RISK_RELOAD_ENABLED: bool = True
SESSION_RISK_PROFILES_JSON: str = "{}"
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
@@ -69,6 +78,8 @@ class Settings(BaseSettings):
ORDER_BLACKOUT_ENABLED: bool = True
ORDER_BLACKOUT_WINDOWS_KST: str = "23:30-00:10"
ORDER_BLACKOUT_QUEUE_MAX: int = Field(default=500, ge=10, le=5000)
BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED: bool = True
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT: float = Field(default=5.0, ge=0.0, le=100.0)
# Pre-Market Planner
PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120)

View File

@@ -109,6 +109,7 @@ def init_db(db_path: str) -> sqlite3.Connection:
stock_code TEXT NOT NULL,
market TEXT NOT NULL,
exchange_code TEXT NOT NULL,
session_id TEXT DEFAULT 'UNKNOWN',
action TEXT NOT NULL,
confidence INTEGER NOT NULL,
rationale TEXT NOT NULL,
@@ -121,6 +122,27 @@ def init_db(db_path: str) -> sqlite3.Connection:
)
"""
)
decision_columns = {
row[1]
for row in conn.execute("PRAGMA table_info(decision_logs)").fetchall()
}
if "session_id" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN session_id TEXT DEFAULT 'UNKNOWN'")
conn.execute(
"""
UPDATE decision_logs
SET session_id = 'UNKNOWN'
WHERE session_id IS NULL OR session_id = ''
"""
)
if "outcome_pnl" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN outcome_pnl REAL")
if "outcome_accuracy" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN outcome_accuracy INTEGER")
if "reviewed" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN reviewed INTEGER DEFAULT 0")
if "review_notes" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN review_notes TEXT")
conn.execute(
"""
@@ -290,9 +312,34 @@ def _resolve_session_id(*, market: str, session_id: str | None) -> str:
def get_latest_buy_trade(
conn: sqlite3.Connection, stock_code: str, market: str
conn: sqlite3.Connection,
stock_code: str,
market: str,
exchange_code: str | None = None,
) -> dict[str, Any] | None:
"""Fetch the most recent BUY trade for a stock and market."""
if exchange_code:
cursor = conn.execute(
"""
SELECT decision_id, price, quantity
FROM trades
WHERE stock_code = ?
AND market = ?
AND action = 'BUY'
AND decision_id IS NOT NULL
AND (
exchange_code = ?
OR exchange_code IS NULL
OR exchange_code = ''
)
ORDER BY
CASE WHEN exchange_code = ? THEN 0 ELSE 1 END,
timestamp DESC
LIMIT 1
""",
(stock_code, market, exchange_code, exchange_code),
)
else:
cursor = conn.execute(
"""
SELECT decision_id, price, quantity

View File

@@ -9,6 +9,7 @@ This module:
from __future__ import annotations
import ast
import json
import logging
import sqlite3
@@ -28,7 +29,7 @@ from src.logging.decision_logger import DecisionLogger
logger = logging.getLogger(__name__)
STRATEGIES_DIR = Path("src/strategies")
STRATEGY_TEMPLATE = textwrap.dedent("""\
STRATEGY_TEMPLATE = """\
\"\"\"Auto-generated strategy: {name}
Generated at: {timestamp}
@@ -45,7 +46,7 @@ STRATEGY_TEMPLATE = textwrap.dedent("""\
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
{body}
""")
"""
class EvolutionOptimizer:
@@ -235,7 +236,8 @@ class EvolutionOptimizer:
file_path = STRATEGIES_DIR / file_name
# Indent the body for the class method
indented_body = textwrap.indent(body, " ")
normalized_body = textwrap.dedent(body).strip()
indented_body = textwrap.indent(normalized_body, " ")
# Generate rationale from patterns
rationale = f"Auto-evolved from {len(failures)} failures. "
@@ -247,9 +249,16 @@ class EvolutionOptimizer:
timestamp=datetime.now(UTC).isoformat(),
rationale=rationale,
class_name=class_name,
body=indented_body.strip(),
body=indented_body.rstrip(),
)
try:
parsed = ast.parse(content, filename=str(file_path))
compile(parsed, filename=str(file_path), mode="exec")
except SyntaxError as exc:
logger.warning("Generated strategy failed syntax validation: %s", exc)
return None
file_path.write_text(content)
logger.info("Generated strategy file: %s", file_path)
return file_path

View File

@@ -19,6 +19,7 @@ class DecisionLog:
stock_code: str
market: str
exchange_code: str
session_id: str
action: str
confidence: int
rationale: str
@@ -47,6 +48,7 @@ class DecisionLogger:
rationale: str,
context_snapshot: dict[str, Any],
input_data: dict[str, Any],
session_id: str | None = None,
) -> str:
"""Log a trading decision with full context.
@@ -59,20 +61,22 @@ class DecisionLogger:
rationale: Reasoning for the decision
context_snapshot: L1-L7 context snapshot at decision time
input_data: Market data inputs (price, volume, orderbook, etc.)
session_id: Runtime session identifier
Returns:
decision_id: Unique identifier for this decision
"""
decision_id = str(uuid.uuid4())
timestamp = datetime.now(UTC).isoformat()
resolved_session = session_id or "UNKNOWN"
self.conn.execute(
"""
INSERT INTO decision_logs (
decision_id, timestamp, stock_code, market, exchange_code,
action, confidence, rationale, context_snapshot, input_data
session_id, action, confidence, rationale, context_snapshot, input_data
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
decision_id,
@@ -80,6 +84,7 @@ class DecisionLogger:
stock_code,
market,
exchange_code,
resolved_session,
action,
confidence,
rationale,
@@ -106,7 +111,7 @@ class DecisionLogger:
query = """
SELECT
decision_id, timestamp, stock_code, market, exchange_code,
action, confidence, rationale, context_snapshot, input_data,
session_id, action, confidence, rationale, context_snapshot, input_data,
outcome_pnl, outcome_accuracy, reviewed, review_notes
FROM decision_logs
WHERE reviewed = 0 AND confidence >= ?
@@ -168,7 +173,7 @@ class DecisionLogger:
"""
SELECT
decision_id, timestamp, stock_code, market, exchange_code,
action, confidence, rationale, context_snapshot, input_data,
session_id, action, confidence, rationale, context_snapshot, input_data,
outcome_pnl, outcome_accuracy, reviewed, review_notes
FROM decision_logs
WHERE decision_id = ?
@@ -196,7 +201,7 @@ class DecisionLogger:
"""
SELECT
decision_id, timestamp, stock_code, market, exchange_code,
action, confidence, rationale, context_snapshot, input_data,
session_id, action, confidence, rationale, context_snapshot, input_data,
outcome_pnl, outcome_accuracy, reviewed, review_notes
FROM decision_logs
WHERE confidence >= ?
@@ -223,13 +228,14 @@ class DecisionLogger:
stock_code=row[2],
market=row[3],
exchange_code=row[4],
action=row[5],
confidence=row[6],
rationale=row[7],
context_snapshot=json.loads(row[8]),
input_data=json.loads(row[9]),
outcome_pnl=row[10],
outcome_accuracy=row[11],
reviewed=bool(row[12]),
review_notes=row[13],
session_id=row[5] or "UNKNOWN",
action=row[6],
confidence=row[7],
rationale=row[8],
context_snapshot=json.loads(row[9]),
input_data=json.loads(row[10]),
outcome_pnl=row[11],
outcome_accuracy=row[12],
reviewed=bool(row[13]),
review_notes=row[14],
)

View File

@@ -68,6 +68,14 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager(
max_queue_size=500,
)
_SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"}
_RUNTIME_EXIT_STATES: dict[str, PositionState] = {}
_RUNTIME_EXIT_PEAKS: dict[str, float] = {}
_STOPLOSS_REENTRY_COOLDOWN_UNTIL: dict[str, float] = {}
_VOLATILITY_ANALYZER = VolatilityAnalyzer()
_SESSION_RISK_PROFILES_RAW = "{}"
_SESSION_RISK_PROFILES_MAP: dict[str, dict[str, Any]] = {}
_SESSION_RISK_LAST_BY_MARKET: dict[str, str] = {}
_SESSION_RISK_OVERRIDES_BY_MARKET: dict[str, dict[str, Any]] = {}
def safe_float(value: str | float | None, default: float = 0.0) -> float:
@@ -108,6 +116,258 @@ DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
def _resolve_sell_qty_for_pnl(*, sell_qty: int | None, buy_qty: int | None) -> int:
"""Choose quantity basis for SELL outcome PnL with safe fallback."""
resolved_sell = int(sell_qty or 0)
if resolved_sell > 0:
return resolved_sell
return max(0, int(buy_qty or 0))
def _compute_kr_dynamic_stop_loss_pct(
*,
market: MarketInfo | None = None,
entry_price: float,
atr_value: float,
fallback_stop_loss_pct: float,
settings: Settings | None,
) -> float:
"""Compute KR dynamic hard-stop threshold in percent."""
if entry_price <= 0 or atr_value <= 0:
return fallback_stop_loss_pct
k = _resolve_market_setting(
market=market,
settings=settings,
key="KR_ATR_STOP_MULTIPLIER_K",
default=2.0,
)
min_pct = _resolve_market_setting(
market=market,
settings=settings,
key="KR_ATR_STOP_MIN_PCT",
default=-2.0,
)
max_pct = _resolve_market_setting(
market=market,
settings=settings,
key="KR_ATR_STOP_MAX_PCT",
default=-7.0,
)
if max_pct > min_pct:
min_pct, max_pct = max_pct, min_pct
dynamic_stop_pct = -((k * atr_value) / entry_price) * 100.0
return max(max_pct, min(min_pct, dynamic_stop_pct))
def _stoploss_cooldown_key(*, market: MarketInfo, stock_code: str) -> str:
return f"{market.code}:{stock_code}"
def _parse_session_risk_profiles(settings: Settings | None) -> dict[str, dict[str, Any]]:
if settings is None:
return {}
global _SESSION_RISK_PROFILES_RAW, _SESSION_RISK_PROFILES_MAP
raw = str(getattr(settings, "SESSION_RISK_PROFILES_JSON", "{}") or "{}")
if raw == _SESSION_RISK_PROFILES_RAW:
return _SESSION_RISK_PROFILES_MAP
parsed_map: dict[str, dict[str, Any]] = {}
try:
decoded = json.loads(raw)
if isinstance(decoded, dict):
for session_id, session_values in decoded.items():
if isinstance(session_id, str) and isinstance(session_values, dict):
parsed_map[session_id] = session_values
except (ValueError, TypeError) as exc:
logger.warning("Invalid SESSION_RISK_PROFILES_JSON; using defaults: %s", exc)
parsed_map = {}
_SESSION_RISK_PROFILES_RAW = raw
_SESSION_RISK_PROFILES_MAP = parsed_map
return _SESSION_RISK_PROFILES_MAP
def _coerce_setting_value(*, value: Any, default: Any) -> Any:
if isinstance(default, bool):
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
if isinstance(value, (int, float)):
return value != 0
return default
if isinstance(default, int) and not isinstance(default, bool):
try:
return int(value)
except (ValueError, TypeError):
return default
if isinstance(default, float):
return safe_float(value, float(default))
if isinstance(default, str):
return str(value)
return value
def _session_risk_overrides(
*,
market: MarketInfo | None,
settings: Settings | None,
) -> dict[str, Any]:
if market is None or settings is None:
return {}
if not bool(getattr(settings, "SESSION_RISK_RELOAD_ENABLED", True)):
return {}
session_id = get_session_info(market).session_id
previous_session = _SESSION_RISK_LAST_BY_MARKET.get(market.code)
if previous_session == session_id:
return _SESSION_RISK_OVERRIDES_BY_MARKET.get(market.code, {})
profile_map = _parse_session_risk_profiles(settings)
merged: dict[str, Any] = {}
default_profile = profile_map.get("default")
if isinstance(default_profile, dict):
merged.update(default_profile)
session_profile = profile_map.get(session_id)
if isinstance(session_profile, dict):
merged.update(session_profile)
_SESSION_RISK_LAST_BY_MARKET[market.code] = session_id
_SESSION_RISK_OVERRIDES_BY_MARKET[market.code] = merged
if previous_session is None:
logger.info(
"Session risk profile initialized for %s: %s (overrides=%s)",
market.code,
session_id,
",".join(sorted(merged.keys())) if merged else "none",
)
else:
logger.info(
"Session risk profile reloaded for %s: %s -> %s (overrides=%s)",
market.code,
previous_session,
session_id,
",".join(sorted(merged.keys())) if merged else "none",
)
return merged
def _resolve_market_setting(
*,
market: MarketInfo | None,
settings: Settings | None,
key: str,
default: Any,
) -> Any:
if settings is None:
return default
fallback = getattr(settings, key, default)
overrides = _session_risk_overrides(market=market, settings=settings)
if key not in overrides:
return fallback
return _coerce_setting_value(value=overrides[key], default=fallback)
def _stoploss_cooldown_minutes(
settings: Settings | None,
market: MarketInfo | None = None,
) -> int:
minutes = _resolve_market_setting(
market=market,
settings=settings,
key="STOPLOSS_REENTRY_COOLDOWN_MINUTES",
default=120,
)
return max(1, int(minutes))
def _estimate_pred_down_prob_from_rsi(rsi: float | str | None) -> float:
"""Estimate downside probability from RSI using a simple linear mapping."""
if rsi is None:
return 0.5
rsi_value = max(0.0, min(100.0, safe_float(rsi, 50.0)))
return rsi_value / 100.0
async def _compute_kr_atr_value(
*,
broker: KISBroker,
stock_code: str,
period: int = 14,
) -> float:
"""Compute ATR(period) for KR stocks using daily OHLC."""
days = max(period + 1, 30)
try:
daily_prices = await _retry_connection(
broker.get_daily_prices,
stock_code,
days=days,
label=f"daily_prices:{stock_code}",
)
except ConnectionError as exc:
logger.warning("ATR source unavailable for %s: %s", stock_code, exc)
return 0.0
except Exception as exc:
logger.warning("Unexpected ATR fetch failure for %s: %s", stock_code, exc)
return 0.0
if not isinstance(daily_prices, list):
return 0.0
highs: list[float] = []
lows: list[float] = []
closes: list[float] = []
for row in daily_prices:
if not isinstance(row, dict):
continue
high = safe_float(row.get("high"), 0.0)
low = safe_float(row.get("low"), 0.0)
close = safe_float(row.get("close"), 0.0)
if high <= 0 or low <= 0 or close <= 0:
continue
highs.append(high)
lows.append(low)
closes.append(close)
if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1:
return 0.0
return max(0.0, _VOLATILITY_ANALYZER.calculate_atr(highs, lows, closes, period=period))
async def _inject_staged_exit_features(
*,
market: MarketInfo,
stock_code: str,
open_position: dict[str, Any] | None,
market_data: dict[str, Any],
broker: KISBroker | None,
) -> None:
"""Inject ATR/pred_down_prob used by staged exit evaluation."""
if not open_position:
return
if "pred_down_prob" not in market_data:
market_data["pred_down_prob"] = _estimate_pred_down_prob_from_rsi(
market_data.get("rsi")
)
existing_atr = safe_float(market_data.get("atr_value"), 0.0)
if existing_atr > 0:
return
if market.is_domestic and broker is not None:
market_data["atr_value"] = await _compute_kr_atr_value(
broker=broker,
stock_code=stock_code,
)
return
market_data["atr_value"] = 0.0
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
"""Call an async function retrying on ConnectionError with exponential backoff.
@@ -215,6 +475,7 @@ async def sync_positions_from_broker(
price=avg_price,
market=log_market,
exchange_code=market.exchange_code,
session_id=get_session_info(market).session_id,
mode=settings.MODE,
)
logger.info(
@@ -450,7 +711,14 @@ def _should_block_overseas_buy_for_fx_buffer(
):
return False, total_cash - order_amount, 0.0
remaining = total_cash - order_amount
required = settings.USD_BUFFER_MIN
required = float(
_resolve_market_setting(
market=market,
settings=settings,
key="USD_BUFFER_MIN",
default=1000.0,
)
)
return remaining < required, remaining, required
@@ -466,7 +734,161 @@ def _should_force_exit_for_overnight(
return True
if settings is None:
return False
return not settings.OVERNIGHT_EXCEPTION_ENABLED
overnight_enabled = _resolve_market_setting(
market=market,
settings=settings,
key="OVERNIGHT_EXCEPTION_ENABLED",
default=True,
)
return not bool(overnight_enabled)
def _build_runtime_position_key(
*,
market_code: str,
stock_code: str,
open_position: dict[str, Any],
) -> str:
decision_id = str(open_position.get("decision_id") or "")
timestamp = str(open_position.get("timestamp") or "")
return f"{market_code}:{stock_code}:{decision_id}:{timestamp}"
def _clear_runtime_exit_cache_for_symbol(*, market_code: str, stock_code: str) -> None:
prefix = f"{market_code}:{stock_code}:"
stale_keys = [key for key in _RUNTIME_EXIT_STATES if key.startswith(prefix)]
for key in stale_keys:
_RUNTIME_EXIT_STATES.pop(key, None)
_RUNTIME_EXIT_PEAKS.pop(key, None)
def _apply_staged_exit_override_for_hold(
*,
decision: TradeDecision,
market: MarketInfo,
stock_code: str,
open_position: dict[str, Any] | None,
market_data: dict[str, Any],
stock_playbook: Any | None,
settings: Settings | None = None,
) -> TradeDecision:
"""Apply v2 staged exit semantics for HOLD positions using runtime state."""
if decision.action != "HOLD" or not open_position:
return decision
entry_price = safe_float(open_position.get("price"), 0.0)
current_price = safe_float(market_data.get("current_price"), 0.0)
if entry_price <= 0 or current_price <= 0:
return decision
stop_loss_threshold = -2.0
take_profit_threshold = 3.0
if stock_playbook and stock_playbook.scenarios:
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
atr_value = safe_float(market_data.get("atr_value"), 0.0)
if market.code == "KR":
stop_loss_threshold = _compute_kr_dynamic_stop_loss_pct(
market=market,
entry_price=entry_price,
atr_value=atr_value,
fallback_stop_loss_pct=stop_loss_threshold,
settings=settings,
)
if settings is None:
be_arm_pct = max(0.5, take_profit_threshold * 0.4)
arm_pct = take_profit_threshold
else:
be_arm_pct = max(
0.1,
float(
_resolve_market_setting(
market=market,
settings=settings,
key="STAGED_EXIT_BE_ARM_PCT",
default=1.2,
)
),
)
arm_pct = max(
be_arm_pct,
float(
_resolve_market_setting(
market=market,
settings=settings,
key="STAGED_EXIT_ARM_PCT",
default=3.0,
)
),
)
runtime_key = _build_runtime_position_key(
market_code=market.code,
stock_code=stock_code,
open_position=open_position,
)
current_state = _RUNTIME_EXIT_STATES.get(runtime_key, PositionState.HOLDING)
prev_peak = _RUNTIME_EXIT_PEAKS.get(runtime_key, 0.0)
peak_hint = max(
safe_float(market_data.get("peak_price"), 0.0),
safe_float(market_data.get("session_high_price"), 0.0),
)
peak_price = max(entry_price, current_price, prev_peak, peak_hint)
exit_eval = evaluate_exit(
current_state=current_state,
config=ExitRuleConfig(
hard_stop_pct=stop_loss_threshold,
be_arm_pct=be_arm_pct,
arm_pct=arm_pct,
),
inp=ExitRuleInput(
current_price=current_price,
entry_price=entry_price,
peak_price=peak_price,
atr_value=atr_value,
pred_down_prob=safe_float(market_data.get("pred_down_prob"), 0.0),
liquidity_weak=safe_float(market_data.get("volume_ratio"), 1.0) < 1.0,
),
)
_RUNTIME_EXIT_STATES[runtime_key] = exit_eval.state
_RUNTIME_EXIT_PEAKS[runtime_key] = peak_price
if not exit_eval.should_exit:
return decision
pnl_pct = (current_price - entry_price) / entry_price * 100.0
if exit_eval.reason == "hard_stop":
rationale = (
f"Stop-loss triggered ({pnl_pct:.2f}% <= "
f"{stop_loss_threshold:.2f}%)"
)
elif exit_eval.reason == "arm_take_profit":
rationale = (
f"Take-profit triggered ({pnl_pct:.2f}% >= "
f"{arm_pct:.2f}%)"
)
elif exit_eval.reason == "atr_trailing_stop":
rationale = "ATR trailing-stop triggered"
elif exit_eval.reason == "be_lock_threat":
rationale = "Break-even lock threat detected"
elif exit_eval.reason == "model_liquidity_exit":
rationale = "Model/liquidity exit triggered"
else:
rationale = f"Exit rule triggered ({exit_eval.reason})"
logger.info(
"Staged exit override for %s (%s): HOLD -> SELL (reason=%s, state=%s)",
stock_code,
market.name,
exit_eval.reason,
exit_eval.state.value,
)
return TradeDecision(
action="SELL",
confidence=max(decision.confidence, 90),
rationale=rationale,
)
async def build_overseas_symbol_universe(
@@ -582,6 +1004,7 @@ async def process_blackout_recovery_orders(
broker: KISBroker,
overseas_broker: OverseasBroker,
db_conn: Any,
settings: Settings | None = None,
) -> None:
intents = BLACKOUT_ORDER_MANAGER.pop_recovery_batch()
if not intents:
@@ -613,6 +1036,63 @@ async def process_blackout_recovery_orders(
continue
try:
revalidation_enabled = bool(
_resolve_market_setting(
market=market,
settings=settings,
key="BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED",
default=True,
)
)
if revalidation_enabled:
if market.is_domestic:
current_price, _, _ = await _retry_connection(
broker.get_current_price,
intent.stock_code,
label=f"recovery_price:{market.code}:{intent.stock_code}",
)
else:
price_data = await _retry_connection(
overseas_broker.get_overseas_price,
market.exchange_code,
intent.stock_code,
label=f"recovery_price:{market.code}:{intent.stock_code}",
)
current_price = safe_float(price_data.get("output", {}).get("last"), 0.0)
queued_price = float(intent.price)
max_drift_pct = float(
_resolve_market_setting(
market=market,
settings=settings,
key="BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT",
default=5.0,
)
)
if queued_price <= 0 or current_price <= 0:
logger.info(
"Drop queued intent by price revalidation (invalid price): %s %s (%s) queued=%.4f current=%.4f",
intent.order_type,
intent.stock_code,
market.code,
queued_price,
current_price,
)
continue
drift_pct = abs(current_price - queued_price) / queued_price * 100.0
if drift_pct > max_drift_pct:
logger.info(
"Drop queued intent by price revalidation: %s %s (%s) queued=%.4f current=%.4f drift=%.2f%% max=%.2f%%",
intent.order_type,
intent.stock_code,
market.code,
queued_price,
current_price,
drift_pct,
max_drift_pct,
)
continue
validate_order_policy(
market=market,
order_type=intent.order_type,
@@ -636,6 +1116,20 @@ async def process_blackout_recovery_orders(
accepted = result.get("rt_cd", "0") == "0"
if accepted:
runtime_session_id = get_session_info(market).session_id
log_trade(
conn=db_conn,
stock_code=intent.stock_code,
action=intent.order_type,
confidence=0,
rationale=f"[blackout-recovery] {intent.source}",
quantity=intent.quantity,
price=float(intent.price),
pnl=0.0,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
)
logger.info(
"Recovered queued order executed: %s %s (%s) qty=%d price=%.4f source=%s",
intent.order_type,
@@ -876,6 +1370,7 @@ async def trading_cycle(
) -> None:
"""Execute one trading cycle for a single stock."""
cycle_start_time = asyncio.get_event_loop().time()
_session_risk_overrides(market=market, settings=settings)
# 1. Fetch market data
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
@@ -977,6 +1472,11 @@ async def trading_cycle(
"foreigner_net": foreigner_net,
"price_change_pct": price_change_pct,
}
session_high_price = safe_float(
price_output.get("high") or price_output.get("ovrs_hgpr") or price_output.get("stck_hgpr")
)
if session_high_price > 0:
market_data["session_high_price"] = session_high_price
# Enrich market_data with scanner metrics for scenario engine
market_candidates = scan_candidates.get(market.code, {})
@@ -1120,7 +1620,14 @@ async def trading_cycle(
# 2.1. Apply market_outlook-based BUY confidence threshold
if decision.action == "BUY":
base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80)
base_threshold = int(
_resolve_market_setting(
market=market,
settings=settings,
key="CONFIDENCE_THRESHOLD",
default=80,
)
)
outlook = playbook.market_outlook
if outlook == MarketOutlook.BEARISH:
min_confidence = 90
@@ -1172,69 +1679,73 @@ async def trading_cycle(
stock_code,
market.name,
)
elif market.code.startswith("US"):
min_price = float(
_resolve_market_setting(
market=market,
settings=settings,
key="US_MIN_PRICE",
default=5.0,
)
)
if current_price <= min_price:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"US minimum price filter blocked BUY "
f"(price={current_price:.4f} <= {min_price:.4f})"
),
)
logger.info(
"BUY suppressed for %s (%s): US min price filter %.4f <= %.4f",
stock_code,
market.name,
current_price,
min_price,
)
if decision.action == "BUY":
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
now_epoch = datetime.now(UTC).timestamp()
cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0)
if now_epoch < cooldown_until:
remaining = int(cooldown_until - now_epoch)
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)",
)
logger.info(
"BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)",
stock_code,
market.name,
remaining,
)
if decision.action == "HOLD":
open_position = get_open_position(db_conn, stock_code, market.code)
if open_position:
entry_price = safe_float(open_position.get("price"), 0.0)
if entry_price > 0 and current_price > 0:
loss_pct = (current_price - entry_price) / entry_price * 100
stop_loss_threshold = -2.0
take_profit_threshold = 3.0
if stock_playbook and stock_playbook.scenarios:
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
exit_eval = evaluate_exit(
current_state=PositionState.HOLDING,
config=ExitRuleConfig(
hard_stop_pct=stop_loss_threshold,
be_arm_pct=max(0.5, take_profit_threshold * 0.4),
arm_pct=take_profit_threshold,
),
inp=ExitRuleInput(
current_price=current_price,
entry_price=entry_price,
peak_price=max(entry_price, current_price),
atr_value=0.0,
pred_down_prob=0.0,
liquidity_weak=market_data.get("volume_ratio", 1.0) < 1.0,
),
if not open_position:
_clear_runtime_exit_cache_for_symbol(
market_code=market.code,
stock_code=stock_code,
)
if exit_eval.reason == "hard_stop":
decision = TradeDecision(
action="SELL",
confidence=95,
rationale=(
f"Stop-loss triggered ({loss_pct:.2f}% <= "
f"{stop_loss_threshold:.2f}%)"
),
await _inject_staged_exit_features(
market=market,
stock_code=stock_code,
open_position=open_position,
market_data=market_data,
broker=broker,
)
logger.info(
"Stop-loss override for %s (%s): %.2f%% <= %.2f%%",
stock_code,
market.name,
loss_pct,
stop_loss_threshold,
decision = _apply_staged_exit_override_for_hold(
decision=decision,
market=market,
stock_code=stock_code,
open_position=open_position,
market_data=market_data,
stock_playbook=stock_playbook,
settings=settings,
)
elif exit_eval.reason == "arm_take_profit":
decision = TradeDecision(
action="SELL",
confidence=90,
rationale=(
f"Take-profit triggered ({loss_pct:.2f}% >= "
f"{take_profit_threshold:.2f}%)"
),
)
logger.info(
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
stock_code,
market.name,
loss_pct,
take_profit_threshold,
)
if decision.action == "HOLD" and _should_force_exit_for_overnight(
if open_position and decision.action == "HOLD" and _should_force_exit_for_overnight(
market=market,
settings=settings,
):
@@ -1295,10 +1806,12 @@ async def trading_cycle(
"pnl_pct": pnl_pct,
}
runtime_session_id = get_session_info(market).session_id
decision_id = decision_logger.log_decision(
stock_code=stock_code,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
action=decision.action,
confidence=decision.confidence,
rationale=decision.rationale,
@@ -1563,6 +2076,7 @@ async def trading_cycle(
pnl=0.0,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
mode=settings.MODE if settings else "paper",
)
logger.info("Order result: %s", result.get("msg1", "OK"))
@@ -1582,16 +2096,34 @@ async def trading_cycle(
logger.warning("Telegram notification failed: %s", exc)
if decision.action == "SELL" and order_succeeded:
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
buy_trade = get_latest_buy_trade(
db_conn,
stock_code,
market.code,
exchange_code=market.exchange_code,
)
if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"])
buy_qty = int(buy_trade.get("quantity") or 1)
trade_pnl = (trade_price - buy_price) * buy_qty
buy_qty = int(buy_trade.get("quantity") or 0)
sell_qty = _resolve_sell_qty_for_pnl(sell_qty=quantity, buy_qty=buy_qty)
trade_pnl = (trade_price - buy_price) * sell_qty
decision_logger.update_outcome(
decision_id=buy_trade["decision_id"],
pnl=trade_pnl,
accuracy=1 if trade_pnl > 0 else 0,
)
if trade_pnl < 0:
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
cooldown_minutes = _stoploss_cooldown_minutes(settings, market=market)
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
datetime.now(UTC).timestamp() + cooldown_minutes * 60
)
logger.info(
"Stop-loss cooldown set for %s (%s): %d minutes",
stock_code,
market.name,
cooldown_minutes,
)
# 6. Log trade with selection context (skip if order was rejected)
if decision.action in ("BUY", "SELL") and not order_succeeded:
@@ -1617,6 +2149,7 @@ async def trading_cycle(
pnl=trade_pnl,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
selection_context=selection_context,
decision_id=decision_id,
mode=settings.MODE if settings else "paper",
@@ -2033,10 +2566,12 @@ async def run_daily_session(
# Process each open market
for market in open_markets:
_session_risk_overrides(market=market, settings=settings)
await process_blackout_recovery_orders(
broker=broker,
overseas_broker=overseas_broker,
db_conn=db_conn,
settings=settings,
)
# Use market-local date for playbook keying
market_today = datetime.now(market.timezone).date()
@@ -2190,6 +2725,14 @@ async def run_daily_session(
"foreigner_net": foreigner_net,
"price_change_pct": price_change_pct,
}
if not market.is_domestic:
session_high_price = safe_float(
price_data.get("output", {}).get("high")
or price_data.get("output", {}).get("ovrs_hgpr")
or price_data.get("output", {}).get("stck_hgpr")
)
if session_high_price > 0:
stock_data["session_high_price"] = session_high_price
# Enrich with scanner metrics
cand = candidate_map.get(stock_code)
if cand:
@@ -2317,6 +2860,7 @@ async def run_daily_session(
)
for stock_data in stocks_data:
stock_code = stock_data["stock_code"]
stock_playbook = playbook.get_stock_playbook(stock_code)
match = scenario_engine.evaluate(
playbook, stock_code, stock_data, portfolio_data,
)
@@ -2360,9 +2904,72 @@ async def run_daily_session(
stock_code,
market.name,
)
elif market.code.startswith("US"):
min_price = float(
_resolve_market_setting(
market=market,
settings=settings,
key="US_MIN_PRICE",
default=5.0,
)
)
if stock_data["current_price"] <= min_price:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"US minimum price filter blocked BUY "
f"(price={stock_data['current_price']:.4f} <= {min_price:.4f})"
),
)
logger.info(
"BUY suppressed for %s (%s): US min price filter %.4f <= %.4f",
stock_code,
market.name,
stock_data["current_price"],
min_price,
)
if decision.action == "BUY":
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
now_epoch = datetime.now(UTC).timestamp()
cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0)
if now_epoch < cooldown_until:
remaining = int(cooldown_until - now_epoch)
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)",
)
logger.info(
"BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)",
stock_code,
market.name,
remaining,
)
if decision.action == "HOLD":
daily_open = get_open_position(db_conn, stock_code, market.code)
if daily_open and _should_force_exit_for_overnight(
if not daily_open:
_clear_runtime_exit_cache_for_symbol(
market_code=market.code,
stock_code=stock_code,
)
await _inject_staged_exit_features(
market=market,
stock_code=stock_code,
open_position=daily_open,
market_data=stock_data,
broker=broker,
)
decision = _apply_staged_exit_override_for_hold(
decision=decision,
market=market,
stock_code=stock_code,
open_position=daily_open,
market_data=stock_data,
stock_playbook=stock_playbook,
settings=settings,
)
if daily_open and decision.action == "HOLD" and _should_force_exit_for_overnight(
market=market,
settings=settings,
):
@@ -2402,10 +3009,12 @@ async def run_daily_session(
"pnl_pct": pnl_pct,
}
runtime_session_id = get_session_info(market).session_id
decision_id = decision_logger.log_decision(
stock_code=stock_code,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
action=decision.action,
confidence=decision.confidence,
rationale=decision.rationale,
@@ -2657,16 +3266,40 @@ async def run_daily_session(
continue
if decision.action == "SELL" and order_succeeded:
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
buy_trade = get_latest_buy_trade(
db_conn,
stock_code,
market.code,
exchange_code=market.exchange_code,
)
if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"])
buy_qty = int(buy_trade.get("quantity") or 1)
trade_pnl = (trade_price - buy_price) * buy_qty
buy_qty = int(buy_trade.get("quantity") or 0)
sell_qty = _resolve_sell_qty_for_pnl(
sell_qty=quantity,
buy_qty=buy_qty,
)
trade_pnl = (trade_price - buy_price) * sell_qty
decision_logger.update_outcome(
decision_id=buy_trade["decision_id"],
pnl=trade_pnl,
accuracy=1 if trade_pnl > 0 else 0,
)
if trade_pnl < 0:
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
cooldown_minutes = _stoploss_cooldown_minutes(
settings,
market=market,
)
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
datetime.now(UTC).timestamp() + cooldown_minutes * 60
)
logger.info(
"Stop-loss cooldown set for %s (%s): %d minutes",
stock_code,
market.name,
cooldown_minutes,
)
# Log trade (skip if order was rejected by API)
if decision.action in ("BUY", "SELL") and not order_succeeded:
@@ -2682,6 +3315,7 @@ async def run_daily_session(
pnl=trade_pnl,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
decision_id=decision_id,
mode=settings.MODE,
)
@@ -3408,7 +4042,10 @@ async def run(settings: Settings) -> None:
_run_context_scheduler(context_scheduler, now=datetime.now(UTC))
# Get currently open markets
open_markets = get_open_markets(settings.enabled_market_list)
open_markets = get_open_markets(
settings.enabled_market_list,
include_extended_sessions=True,
)
if not open_markets:
# Notify market close for any markets that were open
@@ -3437,7 +4074,8 @@ async def run(settings: Settings) -> None:
# No markets open — wait until next market opens
try:
next_market, next_open_time = get_next_market_open(
settings.enabled_market_list
settings.enabled_market_list,
include_extended_sessions=True,
)
now = datetime.now(UTC)
wait_seconds = (next_open_time - now).total_seconds()
@@ -3459,10 +4097,20 @@ async def run(settings: Settings) -> None:
if shutdown.is_set():
break
session_info = get_session_info(market)
_session_risk_overrides(market=market, settings=settings)
logger.info(
"Market session active: %s (%s) session=%s",
market.code,
market.name,
session_info.session_id,
)
await process_blackout_recovery_orders(
broker=broker,
overseas_broker=overseas_broker,
db_conn=db_conn,
settings=settings,
)
# Notify market open if it just opened

View File

@@ -1,7 +1,7 @@
"""Market schedule management with timezone support."""
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from datetime import UTC, datetime, time, timedelta
from zoneinfo import ZoneInfo
@@ -181,7 +181,10 @@ def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
def get_open_markets(
enabled_markets: list[str] | None = None, now: datetime | None = None
enabled_markets: list[str] | None = None,
now: datetime | None = None,
*,
include_extended_sessions: bool = False,
) -> list[MarketInfo]:
"""
Get list of currently open markets.
@@ -196,17 +199,31 @@ def get_open_markets(
if enabled_markets is None:
enabled_markets = list(MARKETS.keys())
def is_available(market: MarketInfo) -> bool:
if not include_extended_sessions:
return is_market_open(market, now)
if market.code == "KR" or market.code.startswith("US"):
# Import lazily to avoid module cycle at import-time.
from src.core.order_policy import classify_session_id
session_id = classify_session_id(market, now)
return session_id not in {"KR_OFF", "US_OFF"}
return is_market_open(market, now)
open_markets = [
MARKETS[code]
for code in enabled_markets
if code in MARKETS and is_market_open(MARKETS[code], now)
if code in MARKETS and is_available(MARKETS[code])
]
return sorted(open_markets, key=lambda m: m.code)
def get_next_market_open(
enabled_markets: list[str] | None = None, now: datetime | None = None
enabled_markets: list[str] | None = None,
now: datetime | None = None,
*,
include_extended_sessions: bool = False,
) -> tuple[MarketInfo, datetime]:
"""
Find the next market that will open and when.
@@ -233,6 +250,21 @@ def get_next_market_open(
next_open_time: datetime | None = None
next_market: MarketInfo | None = None
def first_extended_open_after(market: MarketInfo, start_utc: datetime) -> datetime | None:
# Search minute-by-minute for KR/US session transition into active window.
# Bounded to 7 days to match existing behavior.
from src.core.order_policy import classify_session_id
ts = start_utc.astimezone(ZoneInfo("UTC")).replace(second=0, microsecond=0)
prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"}
for _ in range(7 * 24 * 60):
ts = ts + timedelta(minutes=1)
active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"}
if active and not prev_active:
return ts
prev_active = active
return None
for code in enabled_markets:
if code not in MARKETS:
continue
@@ -240,6 +272,13 @@ def get_next_market_open(
market = MARKETS[code]
market_now = now.astimezone(market.timezone)
if include_extended_sessions and (market.code == "KR" or market.code.startswith("US")):
ext_open = first_extended_open_after(market, now.astimezone(UTC))
if ext_open and (next_open_time is None or ext_open < next_open_time):
next_open_time = ext_open
next_market = market
continue
# Calculate next open time for this market
for days_ahead in range(7): # Check next 7 days
check_date = market_now.date() + timedelta(days=days_ahead)

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from src.analysis.backtest_cost_guard import BacktestCostModel
from src.analysis.backtest_pipeline import (
BacktestBar,
WalkForwardConfig,
fold_has_leakage,
run_v2_backtest_pipeline,
)
from src.analysis.triple_barrier import TripleBarrierSpec
from src.analysis.walk_forward_split import generate_walk_forward_splits
def _bars() -> list[BacktestBar]:
base_ts = datetime(2026, 2, 28, 0, 0, tzinfo=UTC)
closes = [100.0, 101.0, 102.0, 101.5, 103.0, 102.5, 104.0, 103.5, 105.0, 104.5, 106.0, 105.5]
bars: list[BacktestBar] = []
for i, close in enumerate(closes):
bars.append(
BacktestBar(
high=close + 1.0,
low=close - 1.0,
close=close,
session_id="KRX_REG" if i % 2 == 0 else "US_PRE",
timestamp=base_ts + timedelta(minutes=i),
)
)
return bars
def _cost_model() -> BacktestCostModel:
return BacktestCostModel(
commission_bps=3.0,
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
unfavorable_fill_required=True,
)
def test_pipeline_happy_path_returns_fold_and_artifact_contract() -> None:
out = run_v2_backtest_pipeline(
bars=_bars(),
entry_indices=[0, 1, 2, 3, 4, 5, 6, 7],
side=1,
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
),
walk_forward=WalkForwardConfig(
train_size=4,
test_size=2,
step_size=2,
purge_size=1,
embargo_size=1,
min_train_size=3,
),
cost_model=_cost_model(),
)
assert out.run_id.startswith("v2p-e8-f")
assert out.n_bars == 12
assert out.n_entries == 8
assert out.required_sessions == ["KRX_REG", "US_PRE"]
assert len(out.folds) > 0
assert set(out.label_distribution) == {-1, 0, 1}
for fold in out.folds:
names = {score.name for score in fold.baseline_scores}
assert names == {"B0", "B1", "M1"}
for score in fold.baseline_scores:
assert 0.0 <= score.accuracy <= 1.0
def test_pipeline_cost_guard_fail_fast() -> None:
bad = BacktestCostModel(
commission_bps=3.0,
slippage_bps_by_session={"KRX_REG": 10.0},
failure_rate_by_session={"KRX_REG": 0.01},
unfavorable_fill_required=True,
)
try:
run_v2_backtest_pipeline(
bars=_bars(),
entry_indices=[0, 1, 2, 3],
side=1,
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
),
walk_forward=WalkForwardConfig(train_size=2, test_size=1),
cost_model=bad,
required_sessions=["KRX_REG", "US_PRE"],
)
except ValueError as exc:
assert "missing slippage_bps_by_session" in str(exc)
else:
raise AssertionError("expected cost guard validation error")
def test_pipeline_fold_leakage_guard() -> None:
folds = generate_walk_forward_splits(
n_samples=12,
train_size=6,
test_size=2,
step_size=2,
purge_size=1,
embargo_size=1,
min_train_size=5,
)
assert folds
for fold in folds:
assert not fold_has_leakage(fold)
def test_pipeline_deterministic_seed_free_deterministic_result() -> None:
cfg = dict(
bars=_bars(),
entry_indices=[0, 1, 2, 3, 4, 5, 6, 7],
side=1,
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
),
walk_forward=WalkForwardConfig(
train_size=4,
test_size=2,
step_size=2,
purge_size=1,
embargo_size=1,
min_train_size=3,
),
cost_model=_cost_model(),
)
out1 = run_v2_backtest_pipeline(**cfg)
out2 = run_v2_backtest_pipeline(**cfg)
assert out1 == out2
def test_pipeline_rejects_minutes_spec_when_timestamp_missing() -> None:
bars = _bars()
bars[2] = BacktestBar(
high=bars[2].high,
low=bars[2].low,
close=bars[2].close,
session_id=bars[2].session_id,
timestamp=None,
)
try:
run_v2_backtest_pipeline(
bars=bars,
entry_indices=[0, 1, 2, 3],
side=1,
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
),
walk_forward=WalkForwardConfig(train_size=2, test_size=1),
cost_model=_cost_model(),
)
except ValueError as exc:
assert "BacktestBar.timestamp is required" in str(exc)
else:
raise AssertionError("expected timestamp validation error")

View File

@@ -3,7 +3,7 @@
import tempfile
import os
from src.db import get_open_position, init_db, log_trade
from src.db import get_latest_buy_trade, get_open_position, init_db, log_trade
def test_get_open_position_returns_latest_buy() -> None:
@@ -329,3 +329,89 @@ def test_log_trade_unknown_market_falls_back_to_unknown_session() -> None:
row = conn.execute("SELECT session_id FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "UNKNOWN"
def test_get_latest_buy_trade_prefers_exchange_code_match() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="AAPL",
action="BUY",
confidence=80,
rationale="legacy",
quantity=10,
price=120.0,
market="US_NASDAQ",
exchange_code="",
decision_id="legacy-buy",
)
log_trade(
conn=conn,
stock_code="AAPL",
action="BUY",
confidence=85,
rationale="matched",
quantity=5,
price=125.0,
market="US_NASDAQ",
exchange_code="NASD",
decision_id="matched-buy",
)
matched = get_latest_buy_trade(
conn,
stock_code="AAPL",
market="US_NASDAQ",
exchange_code="NASD",
)
assert matched is not None
assert matched["decision_id"] == "matched-buy"
def test_decision_logs_session_id_migration_backfills_unknown() -> None:
import sqlite3
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
old_conn = sqlite3.connect(db_path)
old_conn.execute(
"""
CREATE TABLE decision_logs (
decision_id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
stock_code TEXT NOT NULL,
market TEXT NOT NULL,
exchange_code TEXT NOT NULL,
action TEXT NOT NULL,
confidence INTEGER NOT NULL,
rationale TEXT NOT NULL,
context_snapshot TEXT NOT NULL,
input_data TEXT NOT NULL
)
"""
)
old_conn.execute(
"""
INSERT INTO decision_logs (
decision_id, timestamp, stock_code, market, exchange_code,
action, confidence, rationale, context_snapshot, input_data
) VALUES (
'd1', '2026-01-01T00:00:00+00:00', 'AAPL', 'US_NASDAQ', 'NASD',
'BUY', 80, 'legacy row', '{}', '{}'
)
"""
)
old_conn.commit()
old_conn.close()
conn = init_db(db_path)
columns = {row[1] for row in conn.execute("PRAGMA table_info(decision_logs)").fetchall()}
assert "session_id" in columns
row = conn.execute(
"SELECT session_id FROM decision_logs WHERE decision_id='d1'"
).fetchone()
assert row is not None
assert row[0] == "UNKNOWN"
conn.close()
finally:
os.unlink(db_path)

View File

@@ -49,7 +49,7 @@ def test_log_decision_creates_record(logger: DecisionLogger, db_conn: sqlite3.Co
# Verify record exists in database
cursor = db_conn.execute(
"SELECT decision_id, action, confidence FROM decision_logs WHERE decision_id = ?",
"SELECT decision_id, action, confidence, session_id FROM decision_logs WHERE decision_id = ?",
(decision_id,),
)
row = cursor.fetchone()
@@ -57,6 +57,7 @@ def test_log_decision_creates_record(logger: DecisionLogger, db_conn: sqlite3.Co
assert row[0] == decision_id
assert row[1] == "BUY"
assert row[2] == 85
assert row[3] == "UNKNOWN"
def test_log_decision_stores_context_snapshot(logger: DecisionLogger) -> None:
@@ -84,6 +85,24 @@ def test_log_decision_stores_context_snapshot(logger: DecisionLogger) -> None:
assert decision is not None
assert decision.context_snapshot == context_snapshot
assert decision.input_data == input_data
assert decision.session_id == "UNKNOWN"
def test_log_decision_stores_explicit_session_id(logger: DecisionLogger) -> None:
decision_id = logger.log_decision(
stock_code="AAPL",
market="US_NASDAQ",
exchange_code="NASD",
action="BUY",
confidence=88,
rationale="session check",
context_snapshot={},
input_data={},
session_id="US_PRE",
)
decision = logger.get_decision_by_id(decision_id)
assert decision is not None
assert decision.session_id == "US_PRE"
def test_get_unreviewed_decisions(logger: DecisionLogger) -> None:
@@ -278,6 +297,7 @@ def test_decision_log_dataclass() -> None:
stock_code="005930",
market="KR",
exchange_code="KRX",
session_id="KRX_REG",
action="BUY",
confidence=85,
rationale="Test",
@@ -286,6 +306,7 @@ def test_decision_log_dataclass() -> None:
)
assert log.decision_id == "test-uuid"
assert log.session_id == "KRX_REG"
assert log.action == "BUY"
assert log.confidence == 85
assert log.reviewed is False

View File

@@ -245,6 +245,52 @@ async def test_generate_strategy_creates_file(optimizer: EvolutionOptimizer, tmp
assert "def evaluate" in strategy_path.read_text()
@pytest.mark.asyncio
async def test_generate_strategy_saves_valid_python_code(
optimizer: EvolutionOptimizer, tmp_path: Path,
) -> None:
"""Test that syntactically valid generated code is saved."""
failures = [{"decision_id": "1", "timestamp": "2024-01-15T09:30:00+00:00"}]
mock_response = Mock()
mock_response.text = (
'price = market_data.get("current_price", 0)\n'
'if price > 0:\n'
' return {"action": "BUY", "confidence": 80, "rationale": "Positive price"}\n'
'return {"action": "HOLD", "confidence": 50, "rationale": "No signal"}\n'
)
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
strategy_path = await optimizer.generate_strategy(failures)
assert strategy_path is not None
assert strategy_path.exists()
@pytest.mark.asyncio
async def test_generate_strategy_blocks_invalid_python_code(
optimizer: EvolutionOptimizer, tmp_path: Path, caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that syntactically invalid generated code is not saved."""
failures = [{"decision_id": "1", "timestamp": "2024-01-15T09:30:00+00:00"}]
mock_response = Mock()
mock_response.text = (
'if market_data.get("current_price", 0) > 0\n'
' return {"action": "BUY", "confidence": 80, "rationale": "broken"}\n'
)
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
with caplog.at_level("WARNING"):
strategy_path = await optimizer.generate_strategy(failures)
assert strategy_path is None
assert list(tmp_path.glob("*.py")) == []
assert "failed syntax validation" in caplog.text
@pytest.mark.asyncio
async def test_generate_strategy_handles_api_error(optimizer: EvolutionOptimizer) -> None:
"""Test that generate_strategy handles Gemini API errors gracefully."""

File diff suppressed because it is too large Load Diff

View File

@@ -147,6 +147,24 @@ class TestGetOpenMarkets:
codes = [m.code for m in open_markets]
assert codes == sorted(codes)
def test_get_open_markets_us_pre_extended_session(self) -> None:
"""US premarket should be considered open when extended sessions enabled."""
# Monday 2026-02-02 08:30 EST = 13:30 UTC (premarket window)
test_time = datetime(2026, 2, 2, 13, 30, tzinfo=ZoneInfo("UTC"))
regular = get_open_markets(
enabled_markets=["US_NASDAQ", "US_NYSE", "US_AMEX"],
now=test_time,
)
assert regular == []
extended = get_open_markets(
enabled_markets=["US_NASDAQ", "US_NYSE", "US_AMEX"],
now=test_time,
include_extended_sessions=True,
)
assert {m.code for m in extended} == {"US_NASDAQ", "US_NYSE", "US_AMEX"}
class TestGetNextMarketOpen:
"""Test get_next_market_open function."""
@@ -201,6 +219,20 @@ class TestGetNextMarketOpen:
)
assert market.code == "KR"
def test_get_next_market_open_prefers_extended_session(self) -> None:
"""Extended lookup should return premarket open time before regular open."""
# Monday 2026-02-02 07:00 EST = 12:00 UTC
# By v3 KST session rules, US is OFF only in KST 07:00-10:00 (UTC 22:00-01:00).
# At 12:00 UTC market is active, so next OFF->ON transition is 01:00 UTC next day.
test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("UTC"))
market, next_open = get_next_market_open(
enabled_markets=["US_NASDAQ"],
now=test_time,
include_extended_sessions=True,
)
assert market.code == "US_NASDAQ"
assert next_open == datetime(2026, 2, 3, 1, 0, tzinfo=ZoneInfo("UTC"))
class TestExpandMarketCodes:
"""Test shorthand market expansion."""

View File

@@ -1,5 +1,9 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
@@ -129,3 +133,52 @@ def test_short_tie_break_modes() -> None:
)
assert out_take.label == 1
assert out_take.touched == "take_profit"
def test_minutes_time_barrier_consistent_across_sampling() -> None:
base = datetime(2026, 2, 28, 9, 0, tzinfo=UTC)
highs = [100.0, 100.5, 100.6, 100.4]
lows = [100.0, 99.6, 99.4, 99.5]
closes = [100.0, 100.1, 100.0, 100.0]
spec = TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.02,
max_holding_minutes=5,
)
out_1m = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
timestamps=[base + timedelta(minutes=i) for i in range(4)],
entry_index=0,
side=1,
spec=spec,
)
out_5m = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
timestamps=[base + timedelta(minutes=5 * i) for i in range(4)],
entry_index=0,
side=1,
spec=spec,
)
assert out_1m.touch_bar == 3
assert out_5m.touch_bar == 1
def test_bars_mode_emits_deprecation_warning() -> None:
highs = [100, 101, 103]
lows = [100, 99.6, 100]
closes = [100, 100, 102]
spec = TripleBarrierSpec(take_profit_pct=0.02, stop_loss_pct=0.01, max_holding_bars=3)
with pytest.deprecated_call(match="max_holding_bars is deprecated"):
label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=1,
spec=spec,
)

View File

@@ -0,0 +1,116 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
def _load_module():
script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_governance_assets.py"
spec = importlib.util.spec_from_file_location("validate_governance_assets", script_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_is_policy_file_detects_ouroboros_policy_docs() -> None:
module = _load_module()
assert module.is_policy_file("docs/ouroboros/85_loss_recovery_action_plan.md")
assert not module.is_policy_file("docs/ouroboros/01_requirements_registry.md")
assert not module.is_policy_file("docs/workflow.md")
assert not module.is_policy_file("docs/ouroboros/notes.txt")
def test_validate_registry_sync_requires_registry_update_when_policy_changes() -> None:
module = _load_module()
errors: list[str] = []
module.validate_registry_sync(
["docs/ouroboros/85_loss_recovery_action_plan.md"],
errors,
)
assert errors
assert "policy file changed without updating" in errors[0]
def test_validate_registry_sync_passes_when_registry_included() -> None:
module = _load_module()
errors: list[str] = []
module.validate_registry_sync(
[
"docs/ouroboros/85_loss_recovery_action_plan.md",
"docs/ouroboros/01_requirements_registry.md",
],
errors,
)
assert errors == []
def test_load_changed_files_supports_explicit_paths() -> None:
module = _load_module()
errors: list[str] = []
changed = module.load_changed_files(
["./docs/ouroboros/85_loss_recovery_action_plan.md", " src/main.py "],
errors,
)
assert errors == []
assert changed == [
"docs/ouroboros/85_loss_recovery_action_plan.md",
"src/main.py",
]
def test_load_changed_files_with_range_uses_git_diff(monkeypatch) -> None:
module = _load_module()
errors: list[str] = []
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
assert cmd[:3] == ["git", "diff", "--name-only"]
assert check is True
assert capture_output is True
assert text is True
return SimpleNamespace(stdout="docs/ouroboros/85_loss_recovery_action_plan.md\nsrc/main.py\n")
monkeypatch.setattr(module.subprocess, "run", fake_run)
changed = module.load_changed_files(["abc...def"], errors)
assert errors == []
assert changed == [
"docs/ouroboros/85_loss_recovery_action_plan.md",
"src/main.py",
]
def test_validate_task_req_mapping_reports_missing_req_reference(tmp_path) -> None:
module = _load_module()
doc = tmp_path / "work_orders.md"
doc.write_text(
"- `TASK-OPS-999` no req mapping line\n",
encoding="utf-8",
)
errors: list[str] = []
module.validate_task_req_mapping(errors, task_doc=doc)
assert errors
assert "TASK without REQ mapping" in errors[0]
def test_validate_task_req_mapping_passes_when_req_present(tmp_path) -> None:
module = _load_module()
doc = tmp_path / "work_orders.md"
doc.write_text(
"- `TASK-OPS-999` (`REQ-OPS-001`): enforce timezone labels\n",
encoding="utf-8",
)
errors: list[str] = []
module.validate_task_req_mapping(errors, task_doc=doc)
assert errors == []
def test_validate_pr_traceability_warns_when_req_missing(monkeypatch) -> None:
module = _load_module()
monkeypatch.setenv("GOVERNANCE_PR_TITLE", "feat: update policy checker")
monkeypatch.setenv("GOVERNANCE_PR_BODY", "Refs: TASK-OPS-001 TEST-ACC-007")
warnings: list[str] = []
module.validate_pr_traceability(warnings)
assert warnings
assert "PR text missing REQ-ID reference" in warnings

View File

@@ -0,0 +1,91 @@
# Session Handover Log
목적: 세션 시작 시 인수인계 확인을 기록하고, 구현/검증 작업 시작 전에 공통 컨텍스트를 강제한다.
작성 규칙:
- 세션 시작마다 최신 엔트리를 맨 아래에 추가한다.
- `docs/workflow.md`, `docs/commands.md`, `docs/agent-constraints.md`를 먼저 확인한 뒤 기록한다.
- 각 엔트리는 현재 작업 브랜치 기준으로 작성한다.
템플릿:
```md
### YYYY-MM-DD | session=<id or short label>
- branch: <current-branch>
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #...
- next_ticket: #...
- process_gate_checked: process_ticket=#..., merged_to_feature_branch=yes|no|n/a
- risks_or_notes: ...
```
### 2026-02-27 | session=handover-gate-bootstrap
- branch: feature/v3-session-policy-stream
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #304, #305, #306
- next_ticket: #304
- risks_or_notes: 세션 시작 게이트를 문서/스크립트/CI로 강제 적용
### 2026-02-27 | session=codex-handover-start
- branch: feature/v3-session-policy-stream
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #306, #308, #309
- next_ticket: #304
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: 미추적 로컬 파일 존재(문서/DB/lock)로 커밋 범위 분리 필요
### 2026-02-27 | session=codex-process-gate-hardening
- branch: feature/issue-304-runtime-staged-exit-semantics
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #304, #305
- next_ticket: #304
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: process-change-first 실행 게이트를 문서+스크립트로 강화
### 2026-02-27 | session=codex-handover-start-2
- branch: feature/issue-304-runtime-staged-exit-semantics
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #304, #305
- next_ticket: #304
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: handover 재시작 요청으로 세션 엔트리 추가, 미추적 산출물(AMS/NAS/NYS, DB, lock, xlsx) 커밋 분리 필요
### 2026-02-27 | session=codex-issue305-start
- branch: feature/v3-session-policy-stream
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #305
- next_ticket: #305
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: #305 구현을 위해 분석/백테스트 모듈 통합 경로 점검 시작
### 2026-02-27 | session=codex-issue305-ticket-branch
- branch: feature/issue-305-backtest-pipeline-integration
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #305
- next_ticket: #305
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: 티켓 브랜치 분기 후 strict gate 재통과를 위한 엔트리 추가
### 2026-02-27 | session=codex-backtest-gate-automation
- branch: feature/v3-session-policy-stream
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #304, #305
- next_ticket: (create) backtest automation gate
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: 백테스트 자동화 누락 재발 방지 위해 이슈/티켓 브랜치/PR 절차로 즉시 정규화
### 2026-02-27 | session=codex-issue314-ticket-branch
- branch: feature/issue-314-backtest-gate-automation
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #314
- next_ticket: #314
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: 백테스트 자동 게이트 도입 티켓 브랜치 strict gate 통과용 엔트리
### 2026-02-28 | session=codex-issue316-forbidden-monitor
- branch: feature/issue-316-weekend-forbidden-monitor
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
- open_issues_reviewed: #316
- next_ticket: #316
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- risks_or_notes: 모니터 판정을 liveness 중심에서 policy invariant(FORBIDDEN) 중심으로 전환